Compare commits

..

184 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
cb61274187 test(ai-chat): simplify msg factory and lock signature↔render coupling
Address non-blocking review items on the AI-chat stream-perf PR:

- Drop the unused `metadata` param from the `msg` test factory in
  message-item.test.ts; no caller passed it.
- Add a per-part-kind coupling guard to message-signature.test.ts that, for
  each part kind rendered today (text, reasoning, tool-*) plus the metadata
  banners, asserts that mutating a field the MessageItem render body DRAWS
  flips messageSignature — an executable lock for the load-bearing memo
  invariant documented in message-signature.ts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:30:07 +03:00
claude code agent 227
5b59a70e3f fix(ai-chat): New chat during first-turn stream resets the chat, not just the role badge (#161)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:22:20 +03:00
claude_code
eafd15f0ef docs(ai-chat): document load-bearing invariant of messageSignature memo
PR #182 review (post-fix pass) surfaced two latent correctness risks in the
new MessageItem memo: the per-message signature tracks only [type, text length,
state, error/output presence] + metadata, so a part kind whose VISIBLE content
can change WITHOUT changing those fields would silently freeze a stale row.
Neither is reachable with the current toolset (tool output is set once;
streaming is append-only with a fixed id), so the correct fix is to harden the
documented invariant rather than hash output content on every delta (getPage
returns full page content — hashing it per-delta would tax the hot path this
PR optimizes).

Add a WARNING in messageSignature naming the two future triggers (a tool that
streams `preliminary` output; a client-side regenerate/edit that mutates a
finalized row in place) and the required action (extend the signature).

No behavior change (comment only). vitest src/features/ai-chat 189/189 pass,
tsc clean for the touched files.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:39:09 +03:00
claude_code
63c26042ba test(review): address PR #182 review — tests + extract messageSignature, CHANGELOG
Resolve the PR #182 code-review (Request changes) on top of the already-merged
develop (the merge commit preserves both the markdown useMemo and the
collapseBlankLines fix in reasoning-block.tsx).

- Extract messageSignature from message-item.tsx into utils/message-signature.ts
  (matches the feature's "pure UIMessage helper + colocated test" convention) and
  export arePropsEqual so the memo seam is unit-testable. No logic change.
- Add utils/message-signature.test.ts covering every change signal (text grows,
  part appended, state flip, output appears, errorText appears, usage.reasoningTokens
  arriving on finish-step, metadata error/finishReason) plus the negative
  content-identical-clone case.
- Add components/message-item.test.ts for arePropsEqual (each prop diff -> false,
  identity fast-path -> true, same-content-different-object -> true, changed -> false).
- Add components/message-item-memo.test.tsx: render-level proof that finalized text
  parts are not re-parsed when only a tail part grows (MarkdownPart memo).
- CHANGELOG: add the user-facing 100% CPU freeze fix under [Unreleased] / Fixed.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
2f058a6e40 Merge remote-tracking branch 'gitea/develop' into fix/ai-chat-stream-perf
# Conflicts:
#	apps/client/src/features/ai-chat/components/reasoning-block.tsx
2026-06-25 22:21:41 +03:00
claude_code
3ddc329bba Merge pull request 'Батч: ai-chat/footnotes/mcp/db/tree + red-team (#163 #181 #164 #173 #168 #180 + #159 8/10)' (#185) from batch/issues-2026-06-25 into develop 2026-06-25 12:49:15 +03:00
claude code agent 227
ed3b65c36b Merge remote-tracking branch 'gitea/develop' into batch/issues-2026-06-25
# Conflicts:
#	apps/server/src/core/ai-chat/ai-chat.service.spec.ts
#	apps/server/src/core/ai-chat/ai-chat.service.ts
2026-06-25 12:48:47 +03:00
claude_code
de115ade1e Merge pull request 'feat(ai-chat): persistent history as source of truth — step durability + server export (#183)' (#186) from feat/ai-chat-persistent-history into develop 2026-06-25 12:40:36 +03:00
claude code agent 227
364838d0b2 test(review): close the two test-coverage gaps from PR #185 auto-review
Approve-with-comments auto-review (8 axes); no blockers. Closes the two flagged
test gaps; the two forward-looking dedup suggestions (reconcileHasChildren helper;
unifying reconcileChildren/mergeRootTrees) are non-blocking architecture notes and
left for a follow-up (as with #186's forward-looking point).

1. Ambiguous-id refusal end-to-end (#159): the patch_node/delete_node guard
   `if (replaced/deleted !== 1) return null` was only covered in pieces — the
   replaceNodeById/deleteNodeById counts and assertUnambiguousMatch in isolation —
   so loosening the guard would not have failed a test. New mock test stands up a
   REAL Hocuspocus collab server seeded (via buildYDoc, same docmost extensions)
   with a two-blocks-one-id document and drives the real client methods: both must
   reject with /ambiguous/ AND never write to collab. Tracked via Hocuspocus
   onChange (fires synchronously per update, unlike the debounced onStoreDocument)
   so a clobbering write is actually observed — verified the test FAILS when the
   guard is loosened to `< 1`.

2. scrollToReference zero-match bail: the branch "non-empty id but querySelectorAll
   returns 0 -> matches[index] ?? matches[0] is undefined -> return false" (the real
   desync: definition present, inline ref removed from the DOM) was uncovered. Added
   a footnote.test.ts case: a definition for 'ghost' with no rendered ref -> false,
   no scroll.

Verified: 313 mcp tests + 24 editor-ext footnote tests; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:18 +03:00
claude code agent 227
aa7a115f66 refactor(review): address PR #186 re-review (approve-with-comments)
Approve-with-comments re-review; no blockers. All 7 actionable points (8 is a
forward-looking architecture note — recommendation A, keep as-is):

1. chat-markdown.util spec: restore parity coverage of the removed client spec —
   tool error state (+ errorText), unknown-tool fallback (`Ran tool <name>` en /
   `Выполнил инструмент <name>` ru), and the circular-output stringify catch.
2. findAllByChat row cap is now testable (injectable limit) + an int-spec proves
   truncation on a modest volume.
3. Stability: the per-step durability updates are SERIALIZED via a promise chain
   (stepUpdateChain) so they commit in step order — onlyIfStreaming already
   closed the finalize race, this closes inter-step ordering.
4. findAllByChat keeps the NEWEST messages on truncation (order DESC + reverse,
   like findRecent) and logs a warning with chatId, instead of silently dropping
   the newest tail.
5. The LABELS parity comment already references the real path (tool-parts.tsx /
   toolLabelKey) — confirmed accurate.
6. Removed the redundant 'off-by-one boundary' test (strict subset of the two
   adjacent prepareAgentStep cases).
7. Extracted the terminal-finalize dispatch into a shared `applyFinalize`, used
   by BOTH the service's finalizeAssistant and its test — the test now exercises
   the real path, not a copy, so a production drift fails it.

Verified: server build + 325 ai-chat unit + 6 integration; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:28:35 +03:00
claude code agent 227
30c358a2f8 test(review): add the 4 new test-coverage points from PR #185 re-review
The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:

- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
  selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
  to the first for out-of-range, returns false for an empty id (scrollIntoView
  stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
  (25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
  string scalar holding non-array JSON) reads back as null ("no restriction"),
  covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
  the cached count 2 -> 3. (#168)

Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:08:21 +03:00
claude code agent 227
ea61c96a7c refactor(review): address PR #186 review (#183 — recency sweep, #174 export, tests, cleanups)
15-point review of the persistent-history PR. Architecture decisions: crash
recovery = recency threshold; tool-label duplication = leave as-is.

Must-fix:
1. Boot-sweep bounded by recency. sweepStreaming now also requires
   `updatedAt < now() - SWEEP_STREAMING_STALE_MS` (10 min), so a fresh replica's
   startup sweep can't abort a turn another replica is actively streaming
   (multi-instance deploy). Int-spec: a FRESH 'streaming' row is NOT swept, a
   STALE one IS.
2. Restore export during the FIRST streaming turn of a new chat (#174). The
   server chatId is now adopted EARLY (in-place, on the start-chunk metadata) via
   a new `onServerChatId` callback wired through use-chat-session → chat-thread,
   so `activeChatId` is set at turn start and the Copy button is live mid-first-
   turn (canExport = !!activeChatId). Hook tests for early/in-place/no-op adopt.
3. Cover finalizeAssistant's fallback-insert branch: extracted pure
   `planFinalizeAssistant(assistantId)` (update when id present, insert when the
   upfront insert failed) + a dispatch harness test for both arms.

Tests: onModuleInit lifecycle spec (sweep called; throw → resolves + warns);
int-spec updatedAt assertion → toBeGreaterThan.

Cleanups: cap findAllByChat at 5000 rows; upfront-insert-failure log carries
chatId+workspaceId; removed the now-dead buildPartialAssistantRecord (only the
spec consumed it; shapes still pinned by the flushAssistant suite); controller
passes `lang: dto.lang` (normalizeLang handles undefined); dropped a no-op
`?? undefined` in errorOf; documented the content-column semantics change
(concatenated step text, UI renders from metadata.parts); CHANGELOG [Unreleased]
entry (#183, #174); reworded the stale LABELS parity comment.

Verified: server build + 323 ai-chat unit + 5 integration; client tsc + 160
ai-chat unit; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:53:25 +03:00
claude code agent 227
f80276d41a refactor(review): address PR #185 review (lease leak, tests, changelog, jsonb seam)
8-point multi-aspect review of the batch PR; security/regressions were clean.

1. Lease leak: the #180 reorder moved `toolsFor` (which leases external MCP
   clients, refCount+1) ahead of buildSystemPrompt + forUser, but the only
   release (closeExternalClients) was bound to the streamText callbacks. A throw
   in between leaked the lease (refCount stuck, undici sockets held until
   restart). Define closeExternalClients right after the lease and wrap
   buildSystemPrompt+forUser in try/catch that closes-then-rethrows.
2. Cover the patch_node/delete_node dup-id refusal (#159 #6): extract the guard
   into a pure `assertUnambiguousMatch` (node-ops) and unit-test 0/1/>1.
3. Regress the body-before-title order (#159 #10): mock-HTTP test (collab fails
   fast against a server with no WS upgrade) asserts /pages/update (title) is
   NEVER posted when the body write fails — for updatePage AND updatePageJson.
4. CHANGELOG [Unreleased]: #180, #168 (Added); #163 (Fixed).
5. Add the missing en-US i18n keys (Back to references / {{label}}).
6. Drop the duplicate content/empty/blank cases in ai-chat.prompt.spec.ts (they
   repeat the buildMcpToolingBlock unit tests); keep only sandwich placement +
   both-safety-copies.
7. CI Postgres pg16 -> pg18 (match docker-compose).
8. jsonb decode seam: shared `parseJsonbValue(value, guard)` in database/utils.ts
   holds the legacy double-encoding self-heal in one place; parseToolAllowlist /
   parseModelConfig keep only a type-guard.

Verified: server build + 124 unit + 15 integration; mcp 311; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8218c1a8ef fix(tree): refresh loaded branches on reconnect so they don't go stale (#159)
Third tree-sync finding (#8). On a socket reconnect after a missed-events gap
(laptop sleep / wifi blip), the resync only invalidated the ROOT sidebar query;
a move/rename/delete that happened INSIDE an already-loaded, expanded branch was
never reflected — the branch stayed stale until the user manually interacted.
(The #2 fix reconciles the root level; this covers the deeper loaded branches.)

- `treeModel.reconcileChildren(tree, parentId, fresh)`: replace a loaded
  branch's DIRECT children with the authoritative fresh set (drop removed, add
  new, reorder to server) while PRESERVING each surviving child's already-loaded
  grandchildren, so deeper expansion is not collapsed. An unloaded branch
  (children === undefined) is left untouched (lazy-load fetches it fresh).
- `loadedOpenBranchIds(tree, openIds)`: the branches a reconnect should refresh
  (open AND loaded). `fetchAllAncestorChildren(..., { fresh: true })` bypasses
  the 30-min sidebar cache so the reconcile sees current data (handler-order
  independent).
- space-tree: on socket `connect`, re-fetch + reconcile each open loaded branch
  of the active space (space-switch-guarded; an unloaded branch is skipped).

Tests: reconcileChildren (drop/add/reorder + preserve grandchildren + unloaded
no-op) and loadedOpenBranchIds (open+loaded only, skip unloaded, nested). The
pure logic is unit-tested; the live socket-reconnect round-trip is not
browser-automated (simulating a reconnect gap is impractical) — sidebar render +
expand were smoke-tested with no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
d7e7489654 fix(tree): stop silent page loss on move-to-unloaded-parent + reconnect ghost roots (#159)
Two confirmed P1 data-loss findings in the sidebar tree sync.

#1 — Move into an unloaded/collapsed parent silently dropped pages. When a
moveTreeNode (or addTreeNode) broadcast targeted a parent whose children were
NOT yet lazy-loaded, `insertByPosition` did `kids = parent.children ?? []` and
inserted the moved node, MATERIALIZING a misleading partial child list
(`[movedNode]`) out of an unloaded (`children === undefined`) parent. The
lazy-load gate fetches only when children are absent/empty, so it then refused
to fetch — leaving the parent showing ONLY the moved node and HIDING all its
other real children (and, when the parent wasn't in the tree at all, the node
was removed and never re-fetched). Fix: `insertByPosition` distinguishes
`children === undefined` (not loaded) from `[]` (loaded-empty) and, for an
unloaded parent, does NOT insert — it leaves children unloaded and just flags
`hasChildren`, so expanding fetches the FULL set (including the moved/added
node) via the existing lazy-load.

#2 — After a socket reconnect, a deleted/moved-away root lingered as a 404
"ghost". `mergeRootTrees` was append-only: it kept every previously-loaded root
and only added new ones, so a root removed during the missed-events gap was
never dropped. It runs only once all root pages are fetched, so the incoming
list is the authoritative complete root set — fix reconciles to it (drop roots
absent from incoming) while PRESERVING each surviving root's lazy-loaded
subtree and refreshing its own fields.

Tests: insertByPosition unloaded-vs-loaded-empty parent; the move reducer
keeps a collapsed destination lazy-loadable instead of partial; mergeRootTrees
drops a ghost root, preserves a surviving subtree, adds new roots, refreshes
fields. The existing "remove when parent not in tree" reducer test still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8f1af676ba fix(mcp): write page body before title to avoid split-brain on failure (#159)
updatePage (markdown) and updatePageJson wrote the title via REST FIRST, then
the body via collab. If the body write failed (e.g. a collab persist timeout),
the page was left with the NEW title over its OLD body — a split-brain the tool
reported as an error but never repaired (red-team finding #10).

Reorder both: write the body first, and only set the title after the body has
persisted. Now a body-write failure leaves the title untouched (no split-brain).
A title write failing after a successful body is rarer (REST is fast) and leaves
correct content under a stale title — the strictly lesser inconsistency — which
is the same trade-off the issue's "atomic, or roll back the title" intends,
without the fragility of a rollback write that could itself fail.

No unit test: both paths require a live collab provider and the suite has no
provider mock; the change is a pure reordering. All 306 mcp tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
34c5b557ef fix(share): SEO route must not leak a restricted page's title (#159)
`ShareSeoController.getShare` resolved the inherited share with the RAW
`getShareForPage`, which does NOT run the restricted-ancestor gate. So for a
page shared with includeSubPages whose descendant is permission-restricted, the
SEO route served that descendant's real title in <title>/og:title/twitter:title
to anonymous visitors and crawlers — even though the content API returns 404 for
it (red-team finding #3).

Funnel the SEO path through the canonical `resolveReadableSharePage` boundary
(the single place that checks `hasRestrictedAncestor`): a non-readable page now
serves the plain SPA index with no meta. Also honour `isSharingAllowed` — a
share whose workspace/space sharing toggle was flipped off after creation no
longer leaks its title via SEO. Title comes from the server-resolved page;
`buildShareMetaHtml` already emits robots=noindex when the share opted out of
indexing.

Tests (controller routing, fs spied at call time so bcrypt's native loader is
untouched): non-readable page => plain index, no title; sharing-disabled =>
plain index; readable+indexing => title + og:title, no noindex; readable+no-
indexing => noindex. Asserts getShareForPage is never called by the SEO path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
59f0c8b22d fix(ai-chat): validate the open page server-side so the agent edits the right one (#159)
The client sends the "current page" as { id, title } in the request body and the
server echoed BOTH verbatim into the system prompt context and the
getCurrentPage tool. id and title are independently attacker/desync-controllable
(two tabs, stale navigation), so openPage.id could point at page B while
openPage.title said "Page A" — the model then reported "updated Page A" while it
actually edited page B (CASL still allowed it; the user has access). Red-team
finding #4.

Resolve the open page ONCE against the DB via a new `resolveOpenPageContext`:
workspace-scoped lookup + access check, returning the AUTHORITATIVE { id, title }
(title from the DB row, never the client) or null (fail-closed) for a missing /
foreign / inaccessible page. That validated value now feeds the system prompt,
the getCurrentPage tool, AND the new-chat history origin (which previously did
this validation inline, for the id only — now shared, and the title is fixed
too).

Tests: resolveOpenPageContext covers no-id, not-found, foreign-workspace,
Forbidden, non-Forbidden-fault (fail-closed), the DB-title-wins-over-client case,
and null-title coercion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
77ccc596ea feat(ai-chat): per-MCP-server instructions in the agent system prompt (#180)
Admins can now give each EXTERNAL MCP server a free-text instruction ("how/
when to use this server's tools") that the agent receives in its SYSTEM
PROMPT next to the tool descriptions — porting the built-in SERVER_INSTRUCTIONS
idea to admin-configured servers. Trusted, admin-authored text (like a system
prompt); NON-secret, so unlike headersEnc it IS returned in views/forms.

- Migration: nullable `instructions text` on ai_mcp_servers (old rows = null =
  no guidance). Table type + repo insert/update (blank/whitespace -> null via
  blankToNull). DTO `@MaxLength(4000)`. Service threads it through
  McpServerView/toView.
- mcp-clients: `McpServerInstruction { serverName, toolPrefix, instructions }`
  threaded through the toolset/cache/lease. Guidance is built ONLY for a server
  that actually connected AND contributed >=1 callable tool (the allowlist may
  filter all of them out) AND has non-blank text — so a guide never appears for
  tools the agent cannot call. Cached with the toolset, so an edit is picked up
  next turn via the existing CRUD cache invalidation.
- System prompt: `buildMcpToolingBlock` renders an <mcp_tooling> block INSIDE
  the safety sandwich (after context, before the trailing SAFETY_FRAMEWORK) so
  it informs tool choice but cannot override the rules; each section is headed
  by the server's `prefix_*` namespace. Empty/blank -> block omitted. The
  caller (ai-chat.service) now builds the external toolset BEFORE the prompt and
  passes external.instructions; client-handle lifecycle (close-once) unchanged.
- Client: instructions field in types + a Textarea (autosize, maxLength 4000)
  in the MCP-server form with a namespace-prefix hint; i18n (en/ru).

Tests across every layer (prompt block placement + both SAFETY copies; view
blank->null; buildEntry includes guidance only for connected+>=1-tool+non-blank;
DTO MaxLength; repo + integration round-trip; service wiring). Delegated impl
reviewed (APPROVE); applied the import-type follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
e536c6f9a9 ci(test): run the server integration suite against real Postgres/Redis (#159)
The only test command in CI was `pnpm -r test` (unit `.spec.ts` on mocks).
`test:int` (`.int-spec.ts`, real Postgres/Redis) ran nowhere in CI — there
were no DB `services:` — so the cost-cap, FK-cascade, jsonb round-trip and
real AI-apply integration tests never gated a PR, and regressions in those
high-severity paths stayed green (red-team finding #7).

Add `services: postgres (pgvector) + redis` and a `pnpm --filter server
test:int` step. The pgvector image is required because migrations create
vector columns and global-setup runs `CREATE EXTENSION vector`. Service
credentials/db match the defaults in apps/server/test/integration (docmost /
docmost_dev_pw, maintenance db `docmost`, redis 6379), so no TEST_*_URL
overrides are needed; global-setup drops/recreates the isolated docmost_test
DB and migrates it.

NOTE: the workflow change itself can only be validated by an actual CI run
(YAML parses locally); the int-spec suite is verified passing locally on this
branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
fdaf20ca7b fix(mcp): refuse ambiguous patch_node/delete_node on duplicated ids (#159)
Docmost duplicates block ids on copy/paste, and copyPageContent writes the
source document verbatim with the same ids. `patchNode`/`deleteNode` address a
block by `attrs.id` via replaceNodeById/deleteNodeById, which act on EVERY node
sharing the id — so a single patch_node/delete_node could silently
replace/remove multiple unrelated blocks with no signal to the model
(red-team finding #6).

Guard both write paths: when more than one node matches the id, skip the write
entirely (the transform returns null -> no mutation) and throw a clear
"ambiguous id — N nodes share it" error so the model re-targets with a more
specific anchor. Only an unambiguous single match is written; the 0-match and
1-match behavior is unchanged.

The duplicate-count basis is covered by node-ops.test.mjs (replaceNodeById /
deleteNodeById report count===2 for a 2-duplicate doc). The end-to-end guard
is not unit-tested because patchNode/deleteNode require a live collab provider
and the test suite has no provider mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
47a2ae420b feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)
After #166 a repeated `[^a]` is one footnote (reuse): one number, one
definition, N forward links. But the definition's ↩ only returned to the
FIRST reference. Now a definition with N references shows ↩ a b c …, each
backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a
single-reference footnote keeps the plain ↩ unchanged.

- editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached
  alongside the number map in the numbering plugin state; `getFootnoteRefCount`
  getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks
  the index-th `sup[data-footnote-ref][data-id]` occurrence (document order),
  falling back to the first.
- client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …)
  per occurrence when refCount > 1; the chrome stays after the contentDOM so
  the #146 caret invariant holds. i18n keys (ru) added.

Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown
id => 0); structure test gains 3 cases (N lettered links render, click jumps
to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the
backlink row needs a real browser to verify (jsdom can't); the structural and
behavioral contract is covered headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
1cfad1f6fb fix(db): jsonb double-encoding follow-ups from PR #172 review (#173)
PR #172 fixed the jsonb double-encoding for `tool_allowlist` but the same
class of bug, and the same re-derived workaround, remained elsewhere.

1. model_config (agent roles): jsonbObject still used the buggy `::jsonb`
   bind, so `ai_agent_roles.model_config` round-tripped as a jsonb STRING
   SCALAR. The read-path `typeof === 'object'` check then failed and the
   model override was SILENTLY dropped (role fell back to the default model).
   Fixed to `::text::jsonb` and added `parseModelConfig` + `normalizeRow` so
   every read self-heals already-corrupted rows (no migration).

2. Centralized the write workaround as `jsonbBind()` in database/utils.ts —
   one implementation with one explanation of the quirk — replacing the
   per-repo `jsonbArray` (mcp) and `jsonbObject` (roles).

3. Integration coverage (the fix is a DB round-trip a unit test cannot see;
   the read-side parser MASKS a write regression): new
   ai-mcp-server-repo.int-spec asserts `jsonb_typeof(tool_allowlist)='array'`
   after insert + heals a seeded string-scalar row; ai-agent-roles-repo
   int-spec gains the same for `model_config` (`'object'` + heal).

4. Updated the stale `ai-mcp-servers.types.ts` comment (the driver returns a
   JSON string for legacy rows; the repo normalizes every read).

5. Fail-open logging: a corrupt tool_allowlist degrades to "no restriction"
   (agent gets ALL tools) — normalizeRow now warns (server id only, never
   contents) so the silent widening leaves a trace.

6. Simplified parseToolAllowlist (normalize the string once, then a single
   array-of-strings check) — identical behaviour, all 12 cases still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
a766672574 fix(mcp): replaceImage no longer yanks the cursor (#164)
`mutateLiveContentUnlocked` — the write path used by `replaceImage` — still
did the pre-#152 destructive write (delete the whole fragment + applyUpdate a
fresh Y.Doc), discarding every Yjs node id. y-prosemirror anchors the editor
selection to those ids, so an open editor's cursor snapped to the document
end on every image swap, exactly the #152 jump that the main write path no
longer causes.

Switch it to the same `applyDocToFragment(ydoc, newDoc)` structural diff
(updateYFragment) as the main path, so unchanged nodes keep their ids and the
live cursor stays put. It runs its own atomic transact, so the old explicit
transact/delete is gone; the now-unused docmostExtensions import is dropped.

Regression tests (cursor-stability suite): a sibling paragraph's
RelativePosition survives a top-level image src/attachmentId swap, and an
image nested in a callout, matching the shapes replaceImage produces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
5e8cb628f0 feat(ai-chat): compact reasoning rendering — collapse blank lines (#181)
The "Thinking" (reasoning) block rendered with large vertical gaps: models
emit reasoning with a blank line (\n\n) between every list item and
paragraph, which `marked` turns into loose lists (each <li> wrapped in a
<p>) and separate <p> paragraphs, each carrying a margin.

- Add `collapseBlankLines(text)`: collapse 2+ newlines to one, EXCEPT inside
  fenced code blocks (``` / ~~~) where blank lines are significant. Applied
  in reasoning-block.tsx before renderChatMarkdown, so loose lists become
  tight (no <li><p>) and paragraphs join; `breaks: true` keeps single \n as
  <br>, preserving line breaks. Reasoning-only — the normal answer is
  untouched.
- Drop `white-space: pre-wrap` from `.reasoningText`: on the rendered
  markdown <div> it turned the newlines between block tags into visible
  blank lines on top of the margins. The plain-text fallback <Text> that
  needs pre-wrap already sets it inline.

Tests: collapseBlankLines unit (collapse, fence preservation incl. tilde and
unclosed fences) + rendered-HTML assertions that a blank-line-separated list
becomes a tight list and still parses as a list after a paragraph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8413185a1d fix(ai-chat): tick the live token counter between agent steps (#163)
The header token badge (and the "Thinking… · N tokens" line) froze between
agent steps and jumped in chunks instead of ticking smoothly. liveTurnTokens
returned the authoritative server `usage` VERBATIM as soon as it appeared, but
the server only attaches usage at a step boundary and it is cumulative over
COMPLETED steps — so during the next (in-flight) step the figure stayed frozen
at the previous boundary and the running text estimate was ignored.

Combine both sources per component via max: always compute the running estimate
(chars/≈4 over the message's reasoning/text parts, which includes the in-flight
step) and take max(authoritativeBase, estimate). Between boundaries the estimate
ticks the number up; at a boundary the authoritative figure snaps it exact; and
because the server usage is cumulative and we only ever take the max, the counter
is monotonic (never drops). Reasoning/output stay split; the #151 reasoning-only
authoritative count is preserved.

Backward compatible: in every existing test the estimate is <= the authoritative
figure, so max returns the same value. +4 tests for the in-flight-step-exceeds-
base case (output + reasoning), the authoritative-wins case, and monotonicity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude_code
8fee6a86c2 fix(ai-chat): style GFM tables in assistant chat markdown
Assistant answers containing GFM tables rendered badly in the narrow AI
side panel: `.markdown` only styled p/pre/code/ul/ol and had no table
rules, so tables used the browser default `table-layout: auto`. Combined
with the inherited `word-break: break-word`, columns collapsed to a
single glyph and headers wrapped mid-word ("Секция" -> "Секци / я").

Add table styling scoped to `.markdown`, in line with the editor's
table.css house style:
- make the table a horizontally scrollable block (display:block +
  overflow-x:auto) so wide tables scroll instead of squishing;
- give cells a 6em min-width and restore word-boundary wrapping
  (word-break:normal + overflow-wrap:break-word);
- add 1px borders, padding and a th background (light-dark for dark
  mode); zero out the default <p> margin inside cells.

CSS-only; no markdown-pipeline change (marked already emits GFM tables,
DOMPurify already allows table tags). Applies to the public share too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:53:58 +03:00
claude code agent 227
ae6faf3abc fix(ai-chat): guard step-update vs finalize race with WHERE status='streaming' (#183 review)
Review caught a real race: onStepFinish fires `updateStreaming()` fire-and-
forget (not awaited), so the FINAL step's streaming UPDATE and the terminal
`finalizeAssistant` UPDATE run as two concurrent statements on different pool
connections — commit order is not guaranteed. If the late streaming update
lands AFTER finalize, the completed row is clobbered back to status='streaming'
with no usage/finishReason, and the next startup sweep then mis-marks the
finished turn 'aborted'. Green unit/integration tests don't reproduce a
cross-connection race.

Fix: scope the per-step update with `onlyIfStreaming` → SQL `WHERE
status='streaming'`. Once finalize has set a terminal status the late update
matches zero rows and no-ops, regardless of commit order; finalize runs
unguarded so it always wins. A cheap `if (finalized) return` short-circuit
avoids most wasted queries, but the SQL guard is the authoritative fix (the
flag can be set after a query is already in flight).

Integration test: finalize to 'completed', then a late onlyIfStreaming update
is a no-op — status/content/usage preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:14:02 +03:00
claude code agent 227
e7b719bbb8 feat(ai-chat): persistent history as source of truth — step durability + server export (#183)
The chat lived in inconsistent paradigms (in-memory stream + client export vs.
DB-as-context), which made export flaky and lost the assistant answer if the
process died mid-turn. Make the DB the single source of truth.

A. STEP-GRANULAR DURABILITY (server)
- ai_chat_messages gains a nullable `status` column (migration; NULL = legacy =
  completed). The assistant row is now INSERTED UPFRONT as `status:'streaming'`
  and UPDATEd on every onStepFinish with all finished steps (text + tool calls +
  tool RESULTS), then finalized once to completed/error/aborted on the terminal
  callback. So a process death mid-turn keeps every finished step; a startup
  sweep (OnModuleInit → sweepStreaming) flips any dangling 'streaming' row to
  'aborted'. The write path no longer depends on a live socket.
- Pure exported `flushAssistant(steps, inProgressText, status, extra?)` builds
  the persist payload (metadata.parts byte-identical to the old builder), so a
  future background worker can call the same path. AiChatMessageRepo gains
  `update`, `sweepStreaming`, and `findAllByChat`.
- consumeStream drain, external-MCP client close-once, SSE heartbeat preserved.

B. SERVER-SIDE EXPORT
- New pure `chat-markdown.util.ts` renders Markdown from DB rows ONLY (server
  port of the client builder). Because A persists the in-progress row, the
  export now includes an interrupted turn up to its last finished step (flagged
  "still generating"). `POST /ai-chat/export` (owner-gated via assertOwnedChat,
  workspace-scoped) returns it; `lang` accepts a full client locale tag
  ('en-US'/'ru-RU') and is normalized server-side (normalizeLang) — a strict
  @IsIn(['en','ru']) DTO rejected the real client's i18n.language with a 400,
  caught in real-browser testing.
- Client: handleCopy calls the endpoint; `canExport = !!activeChatId`. The whole
  liveThreadRef/liveStateRef/onLiveContentChange/hasLiveContent hybrid (and the
  client chat-markdown util + test) is removed — the server is now authoritative.

Tests: flushAssistant unit (status shapes + parts parity), chat-markdown.util
unit (incl. legacy NULL-status + interrupted note + ru + normalizeLang locale
tags), controller export wiring + owner-gate, integration update/sweepStreaming.
Verified: server build + 318 ai-chat unit + 3 integration; client tsc + 157
ai-chat unit; and END-TO-END in a real browser — a chat turn persists mid-stream
and the Copy button exports the DB-sourced markdown (showing the in-progress
row), HTTP 200 after the locale fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:05:26 +03:00
claude_code
27c91e4a69 feat(ai-chat): bound external MCP tool calls with per-call timeouts
External MCP tools (web search, crawl) had no per-call timeout: a hung
tool call was only broken by the 15-min transport silence timeout shared
with the chat provider, and a server that kept the socket warm but never
returned could spin until the user cancelled.

Add two independent, composing bounds for external MCP traffic (the chat
provider path is unchanged):

- Silence 5 min: buildPinnedDispatcher now overrides headersTimeout/
  bodyTimeout with mcpStreamTimeoutMs() (AI_MCP_STREAM_TIMEOUT_MS,
  default 300000) on the external-MCP dispatcher only, so a byte-silent
  upstream is severed in ~5 min instead of 15.
- Total per-call 15 min: wrapToolWithCallTimeout wraps each external
  tool's execute with a fresh AbortController + timer composed with the
  turn signal via AbortSignal.any (AI_MCP_CALL_TIMEOUT_MS, default
  900000). It RACES the call against the abort signal because
  @ai-sdk/mcp does not settle its in-flight promise on abort, so a
  warm-but-stuck call would otherwise hang forever.

On timeout the call surfaces as a tool-error and the agent loop recovers.
Add tests (incl. a never-settling real-client-style stub) and document
both env vars in .env.example.
2026-06-25 04:43:49 +03:00
claude_code
c3596dce68 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-25 03:59:41 +03:00
claude_code
b6787cc542 fix(ai-chat): drain stream on client disconnect to stop heap-OOM leak
The /api/ai-chat/stream and public-share streaming paths piped streamText
output to the client socket via pipeUIMessageStreamToResponse, whose only
reader is that socket. On a client disconnect (pervasive Safari/proxy
ECONNRESET), backpressure stalled the stream: the controller aborted the
turn but nothing drained it, so streamText's onFinish/onError/onAbort never
fired. Cleanup (close leased MCP clients, persist partial) never ran and the
whole per-turn object graph (history, per-request toolset closures, captured
steps, SDK buffers) stayed rooted — accumulating across turns until the
default ~2GB heap saturated and the process crashed with
"Ineffective mark-compacts near heap limit - JavaScript heap out of memory".

Add the AI SDK v6 documented remedy: fire-and-forget
`result.consumeStream({ onError })` right after streamText(), which removes
backpressure and drains the stream independently of the client socket so the
terminal callbacks always fire and the turn's memory is released even when the
client has gone away. Applied to both the authenticated and public-share
stream services.

Also add `--heapsnapshot-near-heap-limit=2` to the prod start script so any
residual leak dumps a heap snapshot near OOM for diagnosis (no effect on
normal operation). Heap size stays ops-tunable via NODE_OPTIONS.

- apps/server/src/core/ai-chat/ai-chat.service.ts
- apps/server/src/core/ai-chat/public-share-chat.service.ts
- apps/server/package.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:59:32 +03:00
176b0f575f Merge pull request 'fix(ai-chat): WYSIWYG Copy chat export + first-turn export (#160, #174)' (#165) from fix/ai-chat-copy-chat-wysiwyg into develop
Reviewed-on: #165
2026-06-25 03:54:34 +03:00
claude code agent 227
df81851eb3 fix(ai-chat): export the first unsaved turn (#174)
The "Copy chat" button was hidden during a brand-new chat's very first
turn: both the `canExport` gate and the `handleCopy` early-return required
an `activeChatId` AND persisted `messageRows`, neither of which exists yet
while the first turn is streaming or after it was interrupted before any
row was persisted.

Decouple the export gate from persisted state. ChatThread now reports a
reactive `onLiveContentChange(messages.length > 0)` signal (the live
snapshot lives in a non-reactive ref, so a separate reactive flag is
needed to re-render the button); the parent keeps it in `hasLiveContent`
and exports whenever there is anything on screen OR persisted. `handleCopy`
passes a `"unsaved"` placeholder chat id when none exists yet, and the
live-first builder serializes the on-screen thread WYSIWYG.

Builds on #160 (WYSIWYG export); covers the first-turn edge case that was
explicitly out of scope there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:52:03 +03:00
claude code agent 227
4597183a1e fix(ai-chat): WYSIWYG Copy chat export keeps the on-screen partial reply (#160)
"Copy chat" built the Markdown from persisted rows plus a live tail that was
only included while isStreaming. When a turn was interrupted (dropped stream /
"Lost connection" banner) isStreaming flipped false, the live tail was dropped,
and the partial assistant reply visible on screen — whose row often never
persisted — vanished from the export, leaving only the user messages.

- buildChatMarkdown is now live-first: the on-screen `live` messages ARE the
  document. Each is matched to a persisted row by id to enrich it with token
  usage / error / timestamp; authoritative usage/error already on the live
  message win over the row. When `live` is empty it falls back to the persisted
  rows (old format preserved). Only the tail assistant is flagged "still
  generating", and only when it is genuinely the streaming tail — so the
  status==="submitted" window (tail is the user message) never mislabels the
  previous, completed answer.
- The on-screen banner (classified error / dropped connection / manual stop) is
  flattened to a string in ChatThread, mirrored into liveStateRef alongside the
  messages/isStreaming snapshot, and appended at the end of the export.
- handleCopy maps the live messages and passes live/rows/isStreaming/banner.

Tests: chat-markdown rewritten for the live/enrichment/fallback/banner paths and
the submitted-window regression (26); full ai-chat suite green (186). tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:42:43 +03:00
claude_code
99d0cb8773 perf(ai-chat): throttle stream + memoize markdown to stop CPU spikes on long runs
On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with
no user interaction: useChat updated state on every streamed token, and
MessageItem/ReasoningBlock re-parsed the whole transcript's markdown (the marked
pipeline + DOMPurify) on every delta. Per-turn work grew quadratically and
saturated the main thread; the SSE stream drove it, so it hung "on its own".

- chat-thread: pass experimental_throttle (50ms) to useChat so the streamed
  messages state re-renders at most ~20 Hz instead of once per token.
- message-item: memoize MessageItem on a cheap per-message content signature
  (the streaming tail still re-renders as it grows; finalized rows are skipped),
  and render each text part via a memoized MarkdownPart so finalized parts are
  not re-parsed. The signature includes usage.reasoningTokens so the
  authoritative "Thinking - N tokens" count still snaps in at finish-step.
- reasoning-block: memoize the markdown render (useMemo on the text) and wrap the
  component in React.memo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:26:44 +03:00
claude_code
5aa199660d fix(ai-chat): keep thinking dots visible between streamed steps
showTypingIndicator hid the standalone thinking dots for any non-empty
trailing text part, so during the pause after the model finished an
intermediate narration and before its next step (e.g. a tool call) the
UI looked frozen. Suppress the dots only while the text part is still
streaming: a finalized ("done") trailing text part on an in-flight turn
now shows the dots again, matching the function's documented intent.

- message-list: guard the text branch with state !== "done" (AI SDK v6
  TextUIPart.state); stateless parts keep their previous behavior
- show-typing-indicator.test: add done -> shown and streaming -> hidden cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:34:22 +03:00
claude_code
bf2ebb9d47 fix(ai-chat): increase bottom margin for typing indicator name
The name label was crowding the bouncing dots when displayed. Adding extra bottom margin (mb={8}) gives the dots room and improves readability. The change only applies when the name is shown.
2026-06-25 00:21:53 +03:00
claude_code
ad90e2290e Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-25 00:11:52 +03:00
e262f1695c Merge pull request 'fix(ai-chat): recycle keep-alive sockets + retry pre-response resets (#175)' (#179) from fix/ai-stream-reset-resilience into develop
Reviewed-on: #179
2026-06-25 00:11:50 +03:00
claude code agent 227
c065e26d14 refactor(ai): retry outside instrumentation + retry-exhaustion test (#179 review)
- Invert the transport layers so the pre-response retry is OUTERMOST and the
  provider-HTTP instrumentation is INNER. Before, the retry lived inside
  createStreamingFetch (under the instrumentation), so a reset the retry
  recovered from logged only a clean "OK status=200" — the
  "PRE-RESPONSE FAILED ... ECONNRESET ... idleSincePrevCall" signal went blind
  exactly when the fix works, and AI_STREAM_KEEPALIVE_MS couldn't be tuned from
  prod data. Now createStreamingFetch is the dispatcher-bound BASE (no retry) and
  a new withPreResponseRetry() wraps it; ai.service composes
  withPreResponseRetry(createInstrumentedFetch('AiService:provider-http',
  createStreamingFetch())), so every attempt — including recovered resets — flows
  through the instrumentation. (Also expresses the keepAlive-config vs retry-
  behavior boundary structurally, per review #3.)
- Add the retry-exhaustion test: a server that resets EVERY connection, asserting
  the call rejects with a retryable connection error AND exactly
  PRE_RESPONSE_CONNECT_RETRIES + 1 (= 3) requests reached the server — pinning the
  bound and that the final error propagates (guards an off-by-one / infinite loop
  / swallowed error). Existing happy-retry + abort tests moved onto
  withPreResponseRetry.

Verified on the stand: a normal turn still streams (reasoning + finish) and the
provider-HTTP telemetry still logs. server tsc + ai/mcp specs green (30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:10:40 +03:00
claude_code
91e7335d54 refactor(ai-chat): drop thinking-token text from typing indicator
The live typing placeholder now shows only the bouncing dots; the
"Thinking… · N tokens" line is removed. Clean up the dead plumbing:

- typing-indicator: remove thinkingTokens prop, thinkingLine and the
  <Text> line; keep the animated dots and the dimmed name label
- message-list: remove tailThinkingTokens helper, the thinkingTokens
  prop pass-through, and the now-unused liveTurnTokens import
- delete tail-thinking-tokens.test.ts (tested the removed helper)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:02:44 +03:00
claude code agent 227
b0faa2fe32 fix(ai-chat): recycle keep-alive sockets + retry pre-response resets (#175)
The real cause of the long-task "Lost connection to the AI provider" — the
earlier 300s-timeout fix (#176) was the wrong layer. The provider-HTTP telemetry
on the user's deploy shows the failures are PRE-RESPONSE `read ECONNRESET` ~500ms
in (not a 300s/15min timeout), correlated with idleSincePrevCall ~42s and large
bodies; and crucially a retry of the SAME request often succeeds. A direct probe
to the real z.ai endpoint does NOT reset (113KB bodies and a 45s-idle keep-alive
reuse both succeed), and another agent (opencode) runs fine from the same infra —
so the provider is healthy and the egress network is usable. The difference is
the transport: undici's keep-alive pool REUSES a socket that the deployment's
egress (NAT / firewall / conntrack) silently dropped during a long idle gap, so
the next request resets pre-response.

Fix (brings gitmost in line with clients that don't reuse stale sockets):
- Keep-alive recycling: the streaming dispatcher (chat fetch AND the external-MCP
  dispatcher, via the shared streamingDispatcherOptions) now sets
  keepAliveTimeout + keepAliveMaxTimeout to a 10s recycle window
  (AI_STREAM_KEEPALIVE_MS), so a connection idle longer than that is closed
  instead of reused — a long-gap step opens a fresh connection. keepAliveMaxTimeout
  also caps a server-advertised keep-alive so the provider can't widen the window.
- Pre-response connection retry: createStreamingFetch retries a connection-level
  reset (ECONNRESET / UND_ERR_SOCKET / ECONNREFUSED / EPIPE / *_TIMEOUT) on a
  fresh connection up to 2 times. This is SAFE because fetch() only rejects before
  the Response resolves — a started stream is never replayed; an abort (client
  disconnect) is never retried.

Tests: ai-streaming-fetch.spec — keep-alive options, streamKeepAliveMs env,
isRetryableConnectError, and a server that resets the first connection so the
retry must land on a fresh one (+ aborted requests are not retried). Verified on
the stand that a normal turn still streams (reasoning + text + finish) through the
new transport. server tsc + ai/mcp specs green.

Note: root cause is the deployment's egress dropping idle connections (Traefik is
inbound-only); this makes the app resilient to it. AI_STREAM_KEEPALIVE_MS can be
lowered if the egress drops faster than ~10s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:51:17 +03:00
claude_code
d1fbcc1bfa Merge pull request 'feat(ai-chat): surface reasoning from openai-compatible providers (z.ai/GLM) (#175)' (#177) from feat/reasoning-openai-compatible into develop 2026-06-24 23:19:15 +03:00
claude code agent 227
6edbbab43b refactor(ai): unify provider-settings allowlist + stronger chatApiStyle tests (#177 review)
Addresses the second #177 review:

- Architecture (the silent allowlist drift): the writable provider-setting keys
  were maintained by hand in two TS-uncheckable places — the key-loop in
  ai-settings.service and the SQL ALLOWED list in the generic workspace repo (a
  miss there silently dropped a field on persist, exactly what bit chatApiStyle).
  Introduce one typed source of truth PROVIDER_SETTINGS_KEYS in ai.types
  (`satisfies readonly (keyof AiProviderSettings)[]`), have the service consume
  it, and keep the repo's own copy (it can't import AI types) guarded by a parity
  test so any future drift fails in CI.
- Tests:
  - ai.service.include-usage.spec: mocks @ai-sdk/openai-compatible and asserts the
    factory is called with { includeUsage: true, baseURL, apiKey, fetch, name } —
    `.provider` alone could not catch a dropped includeUsage (the token-usage
    zeroing regression); also asserts the 'openai' style does NOT use it.
  - ai-provider-settings-keys.spec: the allowlist parity check + DTO validation
    for chatApiStyle (@IsIn accepts both values, rejects garbage, optional).
- CHANGELOG: [Unreleased] entries for the new "Protocol" / chatApiStyle setting
  and the default provider change (openai -> openai-compatible). (#175, #177)

server + client tsc clean; 42 ai/settings specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:18:31 +03:00
claude code agent 227
59190148db feat(ai-chat): explicit chatApiStyle selector to surface reasoning (#175)
Rebuilt on develop (after #176) and reworked per review: instead of inferring the
provider from baseUrl (`if (baseUrl)`), the admin picks the chat provider
EXPLICITLY via a new `chatApiStyle` ('openai-compatible' | 'openai'), mirroring
the existing sttApiStyle. A custom baseURL can front real OpenAI too, so the
heuristic was fragile.

Why reasoning was missing: glm-5.2 (and DeepSeek etc.) stream their thinking as
`reasoning_content`, but the official @ai-sdk/openai provider does not map that
field. 'openai-compatible' uses @ai-sdk/openai-compatible, which does — so
reasoning parts now stream (verified live: reasoning-start/delta/end appear, and
disappear when set to 'openai').

- Default (unset) = 'openai-compatible', so existing openai+baseUrl workspaces
  surface reasoning with no admin action. No DB migration (field lives in the
  settings.ai.provider JSON blob).
- includeUsage: true on the openai-compatible model — without it the provider
  omits streamed usage, zeroing the live token counter / reasoning-token
  metadata. The official provider always sent it; this keeps parity. (Confirmed
  live: usage.totalTokens present.)
- openai-compatible has no default endpoint, so with no baseURL (real OpenAI, or
  a role's cross-driver override that cleared it) it falls back to the official
  provider.

Plumbing: ai.types (ChatApiStyle / CHAT_API_STYLES + AiProviderSettings /
MaskedAiSettings), update DTO (@IsIn), ai-settings.service (resolve / getMasked /
update allowlist), workspace.repo updateAiProviderSettings ALLOWED (the second,
SQL-level allowlist the review missed — without it the field never persisted),
ai.service selector. Client: ai-settings-service types + a Protocol <Select> in
the chat section + i18n (en/ru). Scope is chat-only (embeddings don't stream
reasoning; STT already has sttApiStyle).

Tests: ai.service.spec — 4 cases (openai-compatible+baseURL, openai+baseURL,
default-unset, openai-compatible-without-baseURL fallback). Verified on the stand:
default streams reasoning + usage; 'openai' drops reasoning; the setting
round-trips. server + client tsc clean; 36 ai/settings specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:58:15 +03:00
80a4b5a1b0 Merge pull request 'fix(ai-chat): don't sever long agent turns at undici's 300s stream timeout (#175)' (#176) from fix/ai-stream-undici-timeout into develop
Reviewed-on: #176
2026-06-24 22:34:18 +03:00
claude code agent 227
da15b55786 refactor(ai): address PR #176 review — finite-timeout wording, env doc, tests, permanent provider-http module
- Wording: every comment now says the stream timeouts are RAISED to a
  generous-but-finite ~15-min silence timeout, not "disabled (0)" (the stale
  comments contradicted the code, which uses AI_STREAM_TIMEOUT_MS, default
  900000ms).
- Architecture (the load-bearing-temporary trap): the streaming fetch reached
  the chat provider only by riding the "temporary DIAGNOSTIC" telemetry, so
  deleting the telemetry by its own label would silently revert the timeout fix.
  Legitimize it: rename ai-http-diagnostics.ts -> ai-provider-http.ts,
  createDiagnosticFetch -> createInstrumentedFetch, field aiDiagnosticFetch ->
  aiProviderFetch, drop the "temporary" labels, and document the chat transport
  (streaming fetch + instrumentation) as one intentional construct.
- Docs: AI_STREAM_TIMEOUT_MS added to .env.example next to AI_EMBEDDING_TIMEOUT_MS.
- Tests:
  - ai-provider-http.spec: createInstrumentedFetch delegates to the injected
    baseFetch with the same input/init, returns the Response untouched, rethrows
    the error, and defaults to global fetch — covering the baseFetch seam.
  - ai-streaming-fetch.spec: the delayed-server test is now LOAD-BEARING — with
    AI_STREAM_TIMEOUT_MS set below the 1.5s server delay the call actually rejects
    (a lost dispatcher -> global 300s default would NOT), proving the configured
    dispatcher is wired; plus the default-timeout happy path.

server tsc clean; ai-streaming-fetch / ai-provider-http / ai.service / mcp-servers
/ ai-error specs green (41).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:31:58 +03:00
claude code agent 227
a14560c7c9 fix(ai-chat): raise undici's 300s stream timeout for long agent turns (#175)
Long research turns failed mid-task with "Lost connection to the AI provider".
Node's global fetch (undici) defaults BOTH headersTimeout and bodyTimeout to
300_000ms, and the chat provider + the external-MCP dispatcher both ran on it
with no override, so:
  - the z.ai chat stream dropped when a late step's huge accumulated context
    pushed the model's time-to-first-token past 5 min (the model reasons
    server-side with NO streamed reasoning, so the connection is silent until the
    first answer token — reproduced: even a trivial glm-5.2 query has a ~4-8s
    first-chunk gap; a long run reaches 400k+-token steps), or a reasoning model
    paused >5 min between chunks (bodyTimeout);
  - the crawl4ai SSE transport, held open across the whole turn, dropped when it
    idled >5 min between tool calls.

Fix: a dedicated undici dispatcher whose stream timeouts are raised to a
generous-but-FINITE silence timeout (default 15 min, AI_STREAM_TIMEOUT_MS) on
each path. NOT disabled (0): that would let a genuinely hung provider — with the
client still connected — hang forever, since the turn's abortSignal only fires on
client disconnect. The timeout bounds SILENCE (time-to-first-byte and the gap
BETWEEN chunks), NOT total turn duration, so an arbitrarily long turn that keeps
streaming is never cut; only a stream quiet for >15 min is treated as a hang.
  - ai-streaming-fetch.ts: createStreamingFetch() + streamTimeoutMs() /
    streamingDispatcherOptions() (the shared, configurable timeout).
  - ai.service: the chat provider fetch is createStreamingFetch(), wrapped by the
    existing passive ECONNRESET telemetry (createDiagnosticFetch gained an
    optional baseFetch) so the telemetry observes the SAME transport.
  - mcp-clients: the SSRF-pinned Agent uses streamingDispatcherOptions().

Investigation: reproduced the transport mechanism against the real z.ai endpoint
(a 1ms headersTimeout throws UND_ERR_HEADERS_TIMEOUT — the exact drop) and ran
the actual research agent to a ~428k-token context. Verified the fixed path
streams cleanly live (glm-5.2 turns finish; telemetry confirms the streaming
fetch is in use).

Tests: ai-streaming-fetch.spec (default 15m + env override + invalid fallback +
both-timeouts + streams a delayed response); ai-http-diagnostics + ai/mcp specs
green. server tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:09:10 +03:00
claude_code
4cc8df836f chore(ai): passive z.ai provider HTTP telemetry (#175)
Investigate the intermittent (~20-30%) long-turn failure
"Lost connection to the AI provider" = AI_RetryError / read ECONNRESET
on the gitmost->z.ai link (browser-agnostic, mid-turn). Pure
instrumentation, no behavior change:

- ai-http-diagnostics.ts: a passive fetch wrapper injected into the
  OpenAI-compatible (z.ai) client. Per provider HTTP call it logs
  time-to-headers/status on success, and on a pre-response rejection the
  latency, error code/cause, request-body size and idle-gap since the
  previous call. The Response is returned untouched (streaming intact),
  errors rethrown unchanged; no retry/timeout/dispatcher.
- ai.service.ts: wire the instrumented fetch into the openai case only.

Lets us classify the reset as connection-phase vs mid-stream before
choosing a fix, without repeating the reverted RetryAgent (#140).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:24:05 +03:00
claude_code
04a418e1a6 Merge pull request 'fix(mcp): tool allowlist stored/read as jsonb string, not array (edit-page crash + allowlist not enforced)' (#172) from fix/mcp-tool-allowlist-jsonb-shape into develop 2026-06-24 17:14:56 +03:00
claude code agent 227
255bc06883 fix(mcp): tool allowlist stored/read as jsonb string, not array
Opening the edit form for an MCP server that has a saved tool allowlist crashed
the whole settings page (`TypeError: Ke.map is not a function` in Mantine) — and,
worse, the allowlist was silently NOT enforced. Both stem from one root cause:
the `tool_allowlist` jsonb column round-trips as a JSON STRING, not an array.

Root cause: `jsonbArray` bound `JSON.stringify(value)` (already a JSON string)
straight to a `::jsonb` cast. node-postgres infers the param type as jsonb and
JSON-stringifies it a SECOND time, so the column stored a jsonb STRING SCALAR
(`"[\"a\"]"`, jsonb_typeof = string) instead of an array. On read the driver
hands back the JS string `'["a"]'`. Then:
  - the edit form's TagsInput called `.map` on a string -> page crash;
  - mcp-clients did `Array.isArray(allow)` -> false for a string -> fell through
    to "no restriction" and exposed ALL of the server's tools.

Fix (both verified on the stand):
- Write: `jsonbArray` casts `::text::jsonb` so the param is bound as text (sent
  verbatim) and parsed into a real jsonb array. New rows now store
  jsonb_typeof=array.
- Read: `normalizeRow` runs every fetched row through `parseToolAllowlist`, which
  returns `string[] | null` for both shapes (already-array passes through; a JSON
  string is parsed; null/invalid -> null). This REPAIRS existing double-encoded
  rows on read, so the UI and the allowlist enforcement work without a data
  migration. Applied in findById / listByWorkspace / listEnabled.
- Client: defensive `Array.isArray(...) ? ... : []` guard in the form so a bad
  shape can never take the settings page down again.

Tests: ai-mcp-server.repo.spec (8 cases for parseToolAllowlist — array, the
JSON-string read, null, empty, non-array json, unparseable, non-string elements,
non-string primitive). mcp-servers-to-view + mcp-namespacing still green.
Verified live: an old double-encoded row now reads as an array; a newly created
server stores jsonb_typeof=array.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:11:50 +03:00
8c06553b49 Merge pull request 'test(footnotes): cover footnoteWarnings import plumbing + doc fixes (#169 second review)' (#171) from fix/footnote-review-1227-followup into develop
Reviewed-on: #171
2026-06-24 16:46:23 +03:00
claude code agent 227
0e8af13122 test(footnotes): cover footnoteWarnings import plumbing + doc fixes (#169 second review)
Follow-up to the merged #166/#169. Addresses the second review pass (comment
1227):

- footnoteWarnings plumbing: extract a single `footnoteWarningsField(markdown)`
  helper (footnote-analyze) and use it at all three call sites (create_page,
  update_page, import_page_markdown) so the field is attached identically.
- New unit test footnote-warnings-import.test.mjs pins the contract that was
  uncovered: the field is present on problems / omitted on clean input, and the
  IMPORT path analyzes the BODY after the docmost:meta / docmost:comments blocks
  (a footnote-like token inside those JSON blocks must NOT warn; a real body
  marker must). Tested via the same pure composition the importer uses
  (footnoteWarningsField(parseDocmostMarkdown(full).body)) — no collab socket
  needed; a regression that analyzed fullMarkdown or skipped the body split would
  now go red.
- footnote.marked.ts: correct the stale module header — it claimed "only
  definitions that have a matching reference are emitted", which was never true
  (orphan defs are emitted; the editor sync plugin reconciles). Now describes
  first-wins + reuse + sync reconciliation.
- derive-id golden test: rename the describe from "(cross-package drift guard)"
  to "(deterministic-scheme pin)" — there is no second package to drift against.

editor-ext 129, MCP 304 (+3), client+server tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:44:53 +03:00
claude_code
b9056e2bee Merge pull request 'feat(footnotes): reuse semantics + import diagnostics (#166)' (#169) from feat/footnote-reuse-and-warnings into develop 2026-06-24 16:38:59 +03:00
claude code agent 227
a0cc625dfe refactor(footnotes): address PR #169 review
- footnote-sync: remove the now-dead `refReids` (CollisionPlan field, local,
  return, the 6a consumer loop) — references are never re-id'd under reuse, so it
  was dead structure on the hot reconciliation path. Rewrite the stale comments
  (plugin header, step 0, refOccurrences field) that still described the old
  "duplicates re-id'd so both survive" model to the reuse model.
- Shared footnote lexer: new packages/mcp/src/lib/footnote-lex.ts
  (lexFootnoteLines + forEachFootnoteReference). extractFootnotes (collaboration)
  and analyzeFootnotes now consume the SAME fence-aware lexer, so "the analyzer
  sees exactly what the importer keeps/strips" is structural, not comment-kept.
  Removed the duplicated DEF_RE/fence machine from both consumers.
- Tests: new mock test for the footnoteWarnings plumbing on createPage (problems
  -> field present; clean -> omitted); new paste-reuse case for TWO colliding
  pasted definitions (reservation -> distinct ids). Updated the derive-id golden
  test header (no MCP copy / parity test anymore).
- CHANGELOG: [Unreleased] entries for footnote reuse (Changed, supersedes 0.93.0)
  and footnoteWarnings (Added).

editor-ext 129, MCP 301, server roundtrip 2; client+server tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:16:30 +03:00
claude code agent 227
17e683a311 feat(footnotes): reuse semantics + import diagnostics (#166)
Footnotes were strict 1:1: a repeated `[^a]` reference was treated as a
collision and re-id'd to `a__2`, and a reference with no definition synthesized
its own empty one — so an agent-authored article with reused labels produced
dozens of empty `kowiki__N` footnotes. Move to Pandoc REUSE semantics and add
non-fatal import diagnostics.

Reuse (core):
- resolveCollisions (footnote-sync): repeated references sharing an id are REUSE
  (recorded once in document order, never re-id'd) — one number, one shared
  definition. Only a duplicate DEFINITION is re-id'd deterministically and, with
  no matching reference, dropped by the existing orphan policy (first-wins).
  CollisionPlan.refReids is now always empty (harmless no-op downstream).
- extractFootnoteDefinitions (marked) and extractFootnotes (MCP): duplicate
  definition ids are FIRST-WINS (keep first, drop rest); reference markers are
  never rewritten. Removed the marker-rewriting and the now-dead deriveFootnoteId
  mirror + helpers from the MCP path.

Import diagnostics:
- New analyzeFootnotes() (MCP): fence-aware pure scan reporting dangling
  references, empty/duplicate definitions and `[^id]` markers inside table rows.
- createPage / updatePage / importPageMarkdown now attach `footnoteWarnings`
  (only when non-empty) so an agent can fix its markup; the page is still created.

Paste-reuse:
- footnotePastePlugin remaps only ids the pasted slice DEFINES (a colliding
  definition); a pasted lone reference to an existing id keeps it (reuse).

Tests: reuse/first-wins rewrites of footnote.test, footnote-markdown.test,
footnote.marked.orphan.test and the MCP footnotes.test; new footnote-paste.test
(editor-ext) and footnote-analyze.test (MCP). Deleted derive-id-parity.test.mjs
(the MCP no longer derives ids; editor-ext's deriveFootnoteId keeps its own
golden test). editor-ext 128, MCP 299, server roundtrip 2, client views 3,
client+server tsc clean.

Two review suggestions applied: corrected a stale "duplicated in MCP" comment and
the dangling-reference warning wording.

Note: the multi-backlink editor UI (a reused definition linking back to each of
its references) is deferred to a follow-up — this PR delivers the data-integrity
core (reuse + warnings + paste-reuse). Forward links and numbering already reuse
correctly; the backlink currently targets the first reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:34:41 +03:00
claude_code
13cac155c1 chore(ai-chat): add temporary Safari stream-drop diagnostics
Investigate the Safari-only "Lost connection to the AI provider" mid-stream
disconnect (Chrome unaffected). Pure instrumentation, no behavior change:
the 15s heartbeat interval and all stream callbacks are unchanged.

- sse-resilience.ts: startSseHeartbeat() gains an optional onBeat hook fired
  after each successfully written ping (beat counter).
- ai-chat.service.ts: track stream start, first-chunk latency, model-silent
  gap and heartbeat count; log them on finish/error/abort to classify the
  drop (idle-gap vs hard wall-clock cap vs slow first chunk).
- ai-chat.controller.ts: append elapsed-since-request to the disconnect warn.

All blocks tagged "DIAGNOSTIC ... temporary" for easy removal once the Safari
failure mode is identified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:14:29 +03:00
claude_code
6566d2153c Merge pull request 'fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)' (#154) from fix/mcp-comment-cursor-jump into develop 2026-06-24 14:49:46 +03:00
claude code agent 227
aca075108c refactor(mcp): accurate encode-failure labels + diff edge-case tests (#154 review)
Addresses the approve-with-comments review on PR #154:

- applyDocToFragment: hydrate PMNode.fromJSON in its OWN try so a hydration
  failure (e.g. an unknown node type) is labelled "fromJSON" — the stage that
  actually threw — instead of the misleading "updateYFragment". The diagnostic
  comment on unstorableYjsError ("label names the stage that failed") is now
  truthful.
- assertYjsEncodable: also rehearse PMNode.fromJSON(docmostSchema, …) so a doc
  that would only fail in apply's hydration step is rejected at preview time too,
  narrowing the preview/apply gap (review suggestion B). Still cheap — no live
  fragment, no updateYFragment.
- Tests: relabel the diagnostic test to (fromJSON); add structural-diff edge
  cases — neighbour deletion keeps the unchanged node's cursor anchor, doc->empty
  clears the fragment without throwing, top-level node-type change diffs in
  place — plus a preview-gate test for the new fromJSON rehearsal. 297/297 green.

build/ rebuilt for the changed lib module only (build/client.js left untouched
to avoid pulling in pre-existing unrelated src/build drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:48:59 +03:00
claude_code
c9b012894b Merge pull request 'feat(editor): recursive tree mode for the subpages node (#150)' (#155) from feat/subpages-recursive-tree into develop 2026-06-24 14:35:17 +03:00
claude code agent 227
623c89554a refactor(subpages): address PR #155 review
- Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into
  subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts)
  covering nesting, position order, missing/unreachable parent, self-parent
  guard, empty input, countNodes and mapSharedNodes remap.
- Replace the manual useState + editor.on("transaction") subscription in
  subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus
  use), so the mode icon/tooltip track the live recursive attribute without
  re-rendering on every keystroke.
- i18n: add the 6 menu/tree strings and a pluralized
  "Showing {{count}} subpages" key to en-US and ru-RU.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:33:25 +03:00
claude code agent 227
f7b99f9fb3 feat(editor): recursive tree mode for the subpages node (#150)
The `subpages` node showed only one level of direct children. Add a `recursive`
attribute that renders the FULL descendant tree of the current page — fully
expanded, unlimited depth. Default `false`, so every previously-inserted node
stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the
`getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]`
(recursive CTE, permission-filtered); the nested tree is built on the client by
`parentPageId`.

- editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`),
  shared by client + server so the collab ProseMirror schema keeps the attribute.
- `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts
  either); new `useGetPageTreeQuery(pageId)` react-query hook.
- `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and
  `RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent
  guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode`
  renders it (16px indent per depth, soft "showing N" note past 300 — data never
  capped). Shared/public context reads the already-nested shared tree, no
  `/pages/tree` request.
- toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree".

Review follow-ups folded in: invalidate `["page-tree"]` from the create / update /
move / delete cache helpers so an open recursive tree refreshes (no stale data);
mode icon made reactive on editor transactions; `t` threaded into `TreeNode`
(no per-node useTranslation); shared-subtree hook deduped to a thin alias.

editor-ext build + client `tsc --noEmit` both clean. Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:33:07 +03:00
e97024343a Merge pull request 'feat(editor): float image with text wrap (#145)' (#157) from feat/float-image into develop
Reviewed-on: #157
2026-06-24 14:04:03 +03:00
claude code agent 227
43cf1913e0 style(editor): scope the float responsive :global to .container (#145 review)
Per review: the file's other :global is locally scoped (.container:global(...)),
but the new float-reset media rule was fully global in a *.module.css. Scope it to
.container — the image node-view container carries BOTH the .container class and
the data-image-align attribute (same element), so behavior is unchanged while the
selector no longer leaks globally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:08:44 +03:00
9225eeeeed Merge pull request 'feat(ai-chat): realtime token counter + reasoning tokens (#151)' (#158) from feat/ai-chat-realtime-tokens into develop
Reviewed-on: #158
2026-06-24 13:07:51 +03:00
claude code agent 227
044e3f7e6a fix(ai-chat): plural token strings + cover reasoning UI + cleanups (#151 review)
Review of #158 (Request changes) — core logic verified correct; addressed the
test-coverage + localization items:

1. i18n pluralization: the token-count keys were called with {count} but had one
   form, so ru-RU always rendered the genitive ("1 токенов"). Added _one/_other
   (en) and _one/_few/_many (ru: токен/токена/токенов) for both "Thinking… ·
   {{count}} tokens" and "Thinking · {{count}} tokens"; de-duped the PR-added
   duplicate "Thinking" key. Call sites unchanged.
2. ReasoningBlock: new reasoning-block.test.tsx (4 branches: authoritative count
   wins / estimate fallback / header-only when count-but-no-text / body render).
3. Reasoning-token attribution: extracted the #151 anti-double-count rule into a
   pure `reasoningTokensForPart(message)` (single reasoning part -> authoritative
   turn total; multiple/none -> undefined so each estimates). message-item uses
   it; removed the now-dead lastReasoningIndex reduce (review #5). Unit-tested.
6. adopt-chat-id.ts: refreshed 3 stale `chatStreamStartMetadata` ->
   `chatStreamMetadata` comment references.
7. chat-markdown.test.ts: assert the export footer's `reasoning: N` line appears
   when reasoningTokens>0 and is absent at 0/undefined.

Skipped optional #4 (mantine useThrottledCallback): the manual throttle has two
distinct exit paths (turn-end revert-to-null + the captured-total trailing emit)
with no guarding test; remapping risks the streaming behavior — non-blocking.

Client tsc clean; ai-chat suite green (171 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:05:07 +03:00
claude code agent 227
c7c0c28e38 refactor(mcp): single docmostSchema + shared encode-error helper + catch test (#152 review)
Review of #154 (Request changes) — all clean follow-ups, no defect in the fix:

1. Single source of the ProseMirror schema: export `docmostSchema` from
   docmost-schema.ts (next to docmostExtensions); diff.ts and collaboration.ts
   import it instead of each calling getSchema(docmostExtensions) — the schema
   can no longer drift between call sites. Removed both local builds + the now
   unused getSchema imports.
2. Doc fix: assertYjsEncodable's docstring and the client.ts comment no longer
   claim "the same encoder as apply" — apply uses updateYFragment, the dry-run
   uses toYdoc; both reject the same unstorable attrs but are NOT byte-identical.
   Reworded to "independent encodability gate".
3+4+5. Extracted `unstorableYjsError(safe, label, e)` — buildYDoc and
   applyDocToFragment now share one message template (label kept for diagnostics:
   toYdoc vs updateYFragment), so the wording can't drift between dry-run/apply.
6. Test for applyDocToFragment's catch branch: an unknown node type makes the
   schema-validated PMNode.fromJSON throw, and the function must re-throw it
   wrapped with the (updateYFragment) diagnostic.

build/ rebuilt for the three changed lib modules; 293 package tests green.
(Left build/client.js untouched: rebuilding it would pull in a pre-existing,
unrelated src/build drift — a listSidebarPages slugId fix never rebuilt on
develop — and my client.ts change there is comment-only.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:56:23 +03:00
claude code agent 227
99359fa0fa fix(editor): load the float responsive rule + test applyAlignment (#145 review)
Review of #157 (Request changes) caught two blockers:

1. DEAD responsive CSS: the `@media (max-width:600px)` float-reset was added to
   `image-resize.module.css`, which is imported NOWHERE — the image container's
   classes come from `common/node-resize.module.css` (via buildResizeClasses).
   So on mobile a floated image kept its px width + float and crushed the text,
   exactly the failure the rule promised to prevent. Moved the rule to
   `common/node-resize.module.css` (the module actually imported by the resize
   node views); its `:global([data-image-align=...])` selectors are data-attr
   based, so they work unchanged. Reverted the dead addition from the (pre-existing,
   orphaned) image-resize.module.css.

2. `applyAlignment` was untested. Exported it and added `image.spec.ts` (vitest/
   jsdom) covering all five align values, the data-image-align mirror, and the
   floatLeft -> left reset-then-apply (the guard against a leaked float).
   Switched the float writes to the canonical CSSOM `cssFloat` property (portable:
   browsers + jsdom; behavior identical to the `.float` alias).

editor-ext build + client tsc clean; 6 image.spec tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:49:33 +03:00
claude_code
7325eeac19 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-24 12:47:36 +03:00
b321bbafc4 Merge pull request 'feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149)' (#156) from feat/ai-role-autostart into develop
Reviewed-on: #156
2026-06-24 12:43:42 +03:00
claude code agent 227
5519f4b23b test(ai-chat): cover role-pick autoStart logic + the rolePickedNoSend reset (#149 review)
Review of #156 (Request changes) flagged the new CLIENT logic as untested. Extract
the decision logic from chat-thread.tsx into pure, unit-testable helpers and cover
both branches the reviewer called out:

- `roleLaunchMessage(role, default)` — the three-way handleRolePick behavior:
  autoStart=false -> null (send nothing); autoStart=true + custom -> trimmed
  message; autoStart=true + empty/null/whitespace -> default fallback.
- `shouldResetRolePicked(chatId, roleId, flag)` — the #149 render-phase reset; the
  regression test asserts the stuck-flag case (New chat after an autoStart=false
  pick -> cards return) that the pre-fix code never handled, and that a still-bound
  role keeps the cards hidden.

chat-thread.tsx now calls these helpers (behavior unchanged). 9 new pure tests.

Also folded the review's cosmetic suggestion: `x ? x : null` -> `x || null` in
ai-agent-roles.repo.ts (identical for string|null|undefined).

Client tsc clean; role-launch + role-cards green; repo spec green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:42:22 +03:00
claude code agent 227
0ebb1adce8 feat(ai-chat): realtime token counter + reasoning tokens, Claude-Code style (#151)
Tokens were only counted post-hoc (onFinish) and the header badge updated only on
chat open/switch; reasoning wasn't requested or shown. Now a counter ticks LIVE
during generation and surfaces reasoning ("thinking") tokens separately, like
Claude Code's `Thinking… · N tokens`.

Architecture (AI SDK v6): no provider gives exact per-token usage mid-stream, so
the live number is a cheap client estimate (chars/≈4) reconciled to AUTHORITATIVE
provider usage at step boundaries and turn end. The useChat per-delta re-render is
the existing realtime engine.

- server: `chatStreamMetadata` now also forwards usage on `finish-step` + `finish`;
  `sendReasoning: true`; persisted `metadata.usage` carries `reasoningTokens`
  (normalized from `outputTokenDetails` or the deprecated field).
- client: pure `count-stream-tokens` (estimateTokens / liveTurnTokens, prefers
  authoritative usage else estimate); `Thinking… · N tokens` in the typing
  indicator; collapsible "Thinking" reasoning block; throttled (~8 Hz) live
  turn-token header badge; `reasoningTokens` in types + Markdown export.

Review fixes folded in:
- v6 `finish-step.usage` is PER-STEP, not cumulative — the server now ACCUMULATES
  a running sum (new pure `accumulateStepUsage`) and sends the cumulative, which
  converges to `finish.totalUsage`, so the live counter never jumps DOWN on a
  multi-step agent turn.
- reasoning double-count: the authoritative turn-total is attributed to a block
  ONLY for a single-reasoning-part (one-step) turn; multi-step blocks each show
  their own estimate (the authoritative total stays in the header).
- no "0" badge flash at turn start (require live > 0, else show context size).
- comment refreshed (finish-step trigger).

Tests: server `accumulateStepUsage` + updated `chatStreamMetadata` (34 in the
suite); client pure-fn tests. Both tsc clean; 162 client ai-chat + the ai-chat
server suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:56:14 +03:00
claude code agent 227
8ef66ba712 feat(editor): float image with text wrap (#145, port from Forkmost)
Adds floatLeft / floatRight image alignment so text wraps beside the image,
beyond the existing block left/center/right. Ported from Forkmost PR #7 /
upstream Docmost PR #1132 (fuscodev), adapted to gitmost's imperative image
node-view (the upstream uses a React styled component; ours styles the node-view
container directly via applyAlignment).

- editor-ext image.ts: `setImageAlign` accepts `floatLeft`/`floatRight`;
  `applyAlignment` resets float/padding then, for a float mode, sets
  `float:left|right` + side padding on the (shrink-to-fit) container so text
  flows beside it (the inner <img> already has max-width:100%). The resolved
  align is mirrored onto the container as `data-image-align` for the responsive
  rule. `data-align` already round-trips the value through parse/renderHTML, so
  float survives serialization / collab / history with no schema change.
- image-menu.tsx: Float-left / Float-right bubble-menu buttons (IconFloatLeft/
  Right) with active state.
- image-resize.module.css: on narrow screens (<=600px) a floated image collapses
  to full width and drops the float (`!important`, keyed on data-image-align) —
  the upstream "100% width on small screen" follow-up.
- i18n: en-US + ru-RU strings.

editor-ext build + client tsc --noEmit clean. Visual wrap behavior is best
confirmed in-browser (logic/serialization verified by build + types).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:35:34 +03:00
claude code agent 227
0ec0af405a feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149)
Agent role cards always auto-sent a hardcoded "Take a look at the current
document" on pick. Make it configurable per role:
- autoStart (bool, default true): whether picking the role auto-sends a message.
- launchMessage (nullable text): the text sent on auto-start; empty -> the
  built-in default. autoStart=false -> bind the role and send nothing (the user
  types the first message, which still carries the roleId).
Existing roles default to autoStart=true / launchMessage=null => identical old
behavior.

Full-stack:
- migration 20260624T120000 adds `auto_start boolean NOT NULL DEFAULT true` +
  `launch_message text` (additive; down drops both); db.d.ts updated by hand.
- DTO: autoStart (@IsBoolean) + launchMessage (trim @Transform, @MaxLength 2000).
- repo/service: thread + normalize (undefined=unchanged, ""=>null, autoStart??true).
  Both fields exposed in the picker-view for ordinary members (they decide
  whether/what to auto-send); instructions/modelConfig stay ADMIN-ONLY.
- client: IAiRole types, role form (Switch + Textarea, re-hydrated on edit),
  handleRolePick branches on autoStart; i18n en-US + ru-RU.

Review follow-ups folded in: reset the `rolePickedNoSend` flag when the thread
returns to an empty role-less state (the "New chat after autoStart=false pick"
stuck-UI bug — render-phase one-shot reset); made create/update launchMessage
normalization symmetric (raw value, server normalizes ""→null).

Server: 68 role tests pass, tsc clean. Client: tsc clean, role tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:28:41 +03:00
claude code agent 227
f86b8b69a0 fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)
mutatePageContent wrote agent edits back by DELETING the whole Yjs fragment and
re-applying a fresh Y.Doc. Yjs is a CRDT — the editor anchors its selection to
node ids — so wiping every id made an open editor's cursor lose its anchor and
snap to the end of the document on every agent write. It was most visible on
comment anchoring (issue #152): a comment changes no text, yet the cursor jumped.
(Before commit 4201f0a3 the anchoring silently no-op'd, so the destructive write
never ran for comments — hence the regression.)

Fix: write via `updateYFragment` (y-prosemirror) — the same routine the editor
uses to sync its own edits into Yjs. It structurally diffs the new doc against
the live fragment and touches only changed nodes, preserving the ids of unchanged
ones, so the cursor stays put. This improves ALL agent write tools (text edits,
node ops, comments, replace) — minimal diff instead of full replace: less collab
noise, stable block-ids, other users' cursors no longer disrupted.

- collaboration.ts: new `applyDocToFragment` (sanitize -> PMNode.fromJSON against
  a memoized docmost schema -> updateYFragment in one transact), keeping the
  `findUnstorableAttr` encode diagnostic; swap the destructive write-back for it.
- package.json: `y-prosemirror` promoted to a direct dependency (was transitive).
- test: comment-cursor-stability.test.mjs — a Yjs RelativePosition (the cursor
  anchor) survives both a sibling edit and a comment-mark anchoring (the old
  full-replace tombstoned it -> null). 292 package tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:56:32 +03:00
claude_code
3662d21c99 docs(agents): add Gitea tea CLI usage for creating issues
Add a new “Creating issues (Gitea `tea` CLI)” section to AGENTS.md that documents how to file issues using the `tea` command‑line tool, including the correct flag for the issue body and a gotcha note about the `--description` flag.
2026-06-24 05:15:52 +03:00
claude_code
acf6d85b07 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-24 04:41:07 +03:00
8a6ee78c44 Merge pull request 'fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)' (#147) from fix/146-nodeview-content-order into develop
Reviewed-on: #147
2026-06-24 04:40:59 +03:00
claude_code
554da63b0e fix(ai-chat): keep typing-indicator name gap stable mid-stream
The standalone "Thinking…" indicator's agent-name label switched owners a couple seconds into a turn: at first TypingIndicator rendered name+dots (4px gap), then once useChat materialized the empty/reasoning-only assistant message the name moved to the empty MessageItem row (16px messageRow margin), pushing the dots ~20px away — a visible layout jump.

Introduce a shared pure helper assistantMessageHasVisibleContent() as the single source of truth mirroring MessageItem's render decisions. MessageItem now returns null for an assistant message with no visible content, and typingIndicatorShowsName keeps the name on the indicator until the assistant row has visible content. Exactly one element owns the name throughout the pre-content gap, so the layout no longer reflows.

- new utils/message-content.ts + unit tests (12 cases)
- message-list.tsx: typingIndicatorShowsName uses the helper
- message-item.tsx: early return null when no visible content
- typing-indicator-shows-name.test.ts: updated expectations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:36:43 +03:00
claude_code
8bb441870a test(editor): address PR #147 review — reflow tests, code-block guard, a11y
Resolve the pre-merge review items for the #146 NodeView content-first fix:

- Export collectScrollAncestors/reflowAfterPaste and add editor-paste-handler
  unit tests covering ancestor selection (overlay included, non-overflowing
  auto excluded, X axis), the scrollHeight>clientHeight gate, scrollingElement
  dedup, the docEl==null branch, and the double-rAF nudge.
- Extend the structural guard with CodeBlockView and merge the two it.each
  blocks into one document-order assertion (handles the <pre> nesting where the
  contentDOM is not the literal first child).
- Simplify the post-paste nudge to a single scrollTo(scrollLeft, scrollTop).
- Document that the post-paste reflow runs on every paste path intentionally,
  and cross-reference the two #146 mitigations in both fixes.
- a11y: aria-hidden the decorative footnotes heading and number marker, and
  label the footnotes list via role="group" + aria-label so the visual reorder
  does not break screen-reader reading order (WCAG 1.3.2).
- CHANGELOG: add a Fixed entry noting the caret fix is macOS-verified manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:36:39 +03:00
f11c8d7bf1 Merge pull request 'feat(comments): attribute MCP agent comments as AI (unspoofable provenance)' (#143) from feat/mcp-comments-ai-attribution into develop
Reviewed-on: #143
2026-06-24 02:05:06 +03:00
claude_code
cbaa120037 chore(backlog): remove completed mcp-comments-ai-attribution plan (#143)
Per AGENTS.md §5, a task's backlog plan is deleted once implemented; this PR
ships that design, so the plan leaves the work queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:04:23 +03:00
claude_code
683b9d5de2 fix(provenance): address #143 review — page-stamp tests, confine is_agent, doc fixes
Resolves the open items from the latest PR #143 code review:

- test(page): cover the four agentSourceFields stamp sites (create, update,
  movePage, movePageToSpace) with agent + normal-user payload assertions;
  add findById({ includeIsAgent: true }) wiring guards to the JWT and collab
  auth-seam specs so a future drop of the option is caught.
- fix(privacy): drop `isAgent` from UserRepo.baseFields and gate it behind a
  new opt-in `findById({ includeIsAgent })`, requested only by the two auth
  seams that derive provenance — stops the flag leaking via the workspace
  member list and generic user payloads.
- docs: correct the agentSourceFields JSDoc and the two UPDATE-site comments
  to distinguish INSERT (omitted column → DB default 'user') from UPDATE
  (omitted column → existing value kept, Kysely writes only present keys).
- style(page): collapse three stray double blank lines left by an earlier edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:04:23 +03:00
claude code agent 227
67057de214 test(editor): guard #146 contentDOM-first invariant + fix code-block comment (PR #147 review)
Addresses the #147 review (Approve with comments):
- Add footnote-views.structure.test.tsx: a structural regression guard asserting
  the editable NodeViewContent is the FIRST child of FootnotesListView and
  FootnoteDefinitionView, with no contenteditable=false chrome before it. The
  whole #146 fix rests on this DOM-order invariant; the macOS caret symptom needs
  a real browser, but the order proxy is testable in jsdom. Stubs @tiptap/react
  so the views render as plain DOM — the test passes on the fixed order and fails
  on the pre-fix chrome-first order.
- Reword the code-block-view comment: it claimed a "top-right overlay (the
  transclusion pattern)", but the menu stays fully in flow as a full-width row
  lifted via flex `order: -1` (the .codeBlock wrapper is a flex column). No
  overlay/absolute positioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:47:29 +03:00
claude code agent 227
1c39a45bc5 fix(editor): reflow scroll containers after paste to refresh click hit-testing (#146)
Pasting markdown/code inserts React NodeViews that mount asynchronously; until
the next reflow the browser's hit-test geometry is stale, so ProseMirror's
posAtCoords/caretRangeFromPoint maps a click to the wrong (offset) line — which
users reported clears itself on any scroll. Reproduce that scroll's side effect
with a ZERO-delta nudge (re-assign scrollTop/scrollLeft to their current value)
on every scrollable ancestor + the document scrolling element, run across two
animation frames so it lands after the pasted content + NodeViews commit. The
nudge does not move the viewport.

Wired into editor-paste-handler's handlePaste, which ProseMirror's someProp runs
(as an editorProps handler) before the MarkdownClipboard plugin that performs the
markdown/code insert — so the nudge is scheduled on exactly the paste path that
triggers the bug. Complements the structural NodeViewContent-order fix in this
branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:37:56 +03:00
claude code agent 227
7705d44fc6 fix(provenance): address #143 re-review — shared resolver + decoupled badge
Architecture & design:
- Arch A: introduce resolveProvenance() as the single source of truth for
  deriving a write's actor/aiChatId from the SIGNED identity, and wire it into
  BOTH transport seams — the REST jwt.strategy and the collab
  authentication.extension. Previously the collab seam derived actor from the
  token claim alone and ignored user.isAgent, so a flagged service account's
  page-content edits over the websocket persisted as lastUpdatedSource='user',
  drifting from REST. The seams now share one resolver and can't diverge.
- Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no
  longer imports historyAtoms; it exposes an onActivate callback fired after the
  deep-link, and the history row passes onActivate to close its own modal.

Suggestions/warnings:
- S1: soften the jwt.strategy provenance comment (applies to every REST write).
- S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test
  (covered by ai-agent-badge.test.tsx).
- S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half
  duplicated test #2); keep only the signed actor='agent' claim assertion.
- W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key).
- W3: flip the design doc status to "реализовано (#143)".

Tests:
- new auth-provenance.decorator.spec.ts unit-tests resolveProvenance +
  agentSourceFields.
- new collab-seam test: is_agent user with no claim → actor='agent'
  (Arch A regression guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:27:03 +03:00
claude code agent 227
1d54f8ed1c refactor(provenance): extract agentSourceFields write-stamp helper (#143 review #5)
The agent write-stamp idiom — `...(isAgent ? { <source>: 'agent', <chat>: aiChatId } : {})`
— was hand-reimplemented at every REST write site, so each new path risked a
wrong literal or a forgotten aiChatId. Extract a single
`agentSourceFields(provenance, sourceKey, chatKey)` next to AuthProvenanceData and
call it at the 5 uniform spread sites:

- comment.service create  -> createdSource / aiChatId
- page.service create/update/orphan-move/move -> lastUpdatedSource / lastUpdatedAiChatId

Sites that must CLEAR the source on a non-agent action keep their own conditional
(comment un-resolve writes an explicit null), and the collab persistence path keeps
its sticky-window logic — both noted in the helper's doc.

Behavior-preserving (the helper returns the identical object/`{}`). Typecheck
clean; server comment/page/auth/collab suites 246 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:05:54 +03:00
claude code agent 227
0647faefcd chore(comments): address PR #143 review (operator doc, badge test, dedup, type)
- [warn 1] Document the is_agent operator setup so it survives plan deletion:
  added an AI-agent block to .env.example (use a DEDICATED account, set is_agent
  via SQL, never flag a human/shared account) + a CHANGELOG "Added" entry.
- [warn 2] Test the badge deep-link side effects: ai-agent-badge.test.tsx now
  renders inside an explicit jotai store, clicks the badge, and asserts the
  active chat id, window-open, cleared draft, closed history modal, AND that
  stopPropagation keeps a parent onClick from firing.
- [suggestion 3] Hoist the window.matchMedia stub into vitest.setup.ts and drop
  the duplicated beforeAll block from the three test files (ai-agent-badge,
  comment-list-item, role-cards).
- [suggestion 4] Merge the two near-duplicate "non-clickable" cases via it.each.
- [follow-up 6] Introduce a single ProvenanceSource = 'user' | 'agent' type in
  jwt-payload.ts and reference it from AuthProvenanceData, JwtPayload/
  JwtCollabPayload, and resolveSource() — so a typo can't slip through as a bare
  string. (Server auth chain; client IComment mirroring left as a follow-up.)

Follow-up 5 (shared agentSourceFields write-stamp helper) is deferred as the
review marked it — the 6 REST sites use varied shapes (create-spread vs
resolve-conditional-null vs page move), so it's a separate focused refactor.

Tests: client badge/comment/role-cards suites 11/11 pass; server auth+comment
suites 62 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:56:26 +03:00
claude code agent 227
38544e2ddc fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)
Three editable NodeViews rendered a contentEditable=false "chrome" element IN
FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing
(posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the
caret to the previous node — the caret/selection lands a line (code block) or
several lines (footnotes, into the body) above where the user clicked.

Fix (the transclusion pattern / issue #146 plan): make the editable
NodeViewContent the FIRST child in the DOM and move the non-editable chrome
AFTER it, restoring its visual position with CSS:

- code-block-view: <pre><NodeViewContent/></pre> first; the language/copy menu
  follows and is lifted above via flex `order` (.codeBlock is now a flex column).
- footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and
  is lifted above via flex `order` (.list is a flex column; the separator border
  stays on the container).
- footnote-definition-view: NodeViewContent first; the "N." marker follows with
  `order:-1` to stay on the left; the ↩ back-link stays on the right.

Layout is visually unchanged. Verified in a real browser (Chromium): the
contentDOM is now the first child of every editable NodeView wrapper (no
contentEditable=false element precedes it), and the menu/heading/marker still
render in their original positions.

NOTE: the caret-offset itself is macOS-specific text hit-testing and does not
reproduce in headless Chromium/WebKit on Linux (verified extensively), so the
visible fix is best confirmed on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:21 +03:00
claude_code
aeea315618 fix(ai-chat): stop duplicating the assistant name in the typing indicator
While a multi-step agent turn is "thinking between tool steps", the
assistant identity name (e.g. the role name) was rendered twice, stacked:
once by the assistant message row (MessageItem) and once by the standalone
TypingIndicator below it. The indicator's name label only makes sense when
it stands in for a not-yet-started assistant row; between steps the row
above already shows the same name.

Render the indicator's dimmed name label only when it is standalone (no
assistant row at the tail yet); otherwise show just the "Thinking…" dots.

- typing-indicator.tsx: optional showName prop (default true); the name
  label renders only when showName !== false
- message-list.tsx: exported typingIndicatorShowsName(messages) helper;
  pass showName to the indicator at the render site
- typing-indicator-shows-name.test.ts: unit-cover the four cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:32:20 +03:00
claude_code
4704c3b7f9 chore(vscode): add push task for github and gitea
Provides a VSCode task to push the develop branch to both GitHub and Gitea with a single command.
2026-06-23 18:54:24 +03:00
claude_code
5161de8ba9 revert(ai-http): drop resilient fetch/RetryAgent layer (#140)
The custom undici RetryAgent + aiFetch transport added for issue #140
did not actually heal mid-stream provider drops: undici's retry path is
a Range-based download-resume that SSE/chat-completions endpoints cannot
satisfy, so a reset after the first byte only swapped ECONNRESET for a
"server does not support the range header" error. Its only real effect
was reconnecting a poisoned keep-alive socket before the first byte, and
PR #141 on top of it turned the 60s headers timeout into deterministic
~61s failures (plus CONTENT_LENGTH_MISMATCH from retrying a POST body
after a timeout abort). The root cause is the z.ai coding endpoint, not
our transport.

Remove the whole layer and return all AI provider calls to Node's
default global fetch.

- delete integrations/ai/ai-http.ts and its spec
- ai.service.ts: drop the aiFetch import, the AI_BYPASS_RESILIENT_FETCH
  diagnostic toggle, and fetch:aiFetch from every chat/embedding/STT
  factory; raw STT call back to global fetch
- ai-chat.controller.ts: drop the stream-timing START log + startedAt
- ai-chat.service.ts: drop the first-chunk/FINISHED/ERROR timing logs
- .env.example: drop AI_BYPASS_RESILIENT_FETCH

Reverts: 1af5d34a, 7c308728, b7abb7ea, 35fc58ea, d6cd2754, 6efb8656.
Preserved (not part of the rollback): client-disconnect abort, title
generation in onFinish, partial-answer persistence, Safari SSE heartbeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:48:33 +03:00
claude code agent 227
989f99abae feat(comments): attribute MCP agent comments as AI (unspoofable provenance)
Mark comments (and, via existing page provenance, pages) created under an
is_agent service account as authored by AI, derived from the SIGNED server
identity rather than any client field, and render the existing AI badge in
the comments sidebar.

Backend (B1):
- Add additive users.is_agent boolean (default false) migration; reflect in
  the Users Kysely type, the user repo baseFields, and (via Selectable) the
  User entity.
- jwt.strategy: derive req.raw.actor from user.isAgent (an is_agent account
  stamps every write 'agent'); external MCP has no internal ai_chats row so
  aiChatId stays null. Non-spoofable: a plain user cannot obtain
  created_source='agent'.
- Loosen the provenance aiChatId type to string|null across token.service and
  the JwtPayload/JwtCollabPayload claims (type-level only; the internal AI-chat
  path still passes a real aiChatId).

Frontend (B2):
- Extend IComment with createdSource/aiChatId/resolvedSource (backend already
  returns them via selectAll).
- Extract the local AiAgentBadge from history-item into a shared
  components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present,
  plain label when null/absent); reuse it in history-item and render it in
  comment-list-item next to the author name when createdSource==='agent'.

Tests: comment.service agent/null-aiChatId provenance, jwt.strategy provenance
derivation + anti-spoof, AiAgentBadge clickable/non-clickable branches, and
comment-list-item badge render/no-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:29:12 +03:00
0fabaa5bfb Merge pull request 'test: unit tests for the 10 candidates (#139)' (#142) from test/unit-tests-139 into develop
Reviewed-on: #142
2026-06-23 04:16:50 +03:00
6efb865625 Merge pull request 'fix(ai-http): fail fast + retry on provider header stall (#140)' (#141) from fix/ai-stream-headers-timeout into develop
Reviewed-on: #141
2026-06-23 04:16:37 +03:00
claude code agent 227
7c48bab1f2 test: add unit tests for 10 candidates from issue #139
Adds co-located unit tests for ten targets (client → vitest *.test.ts(x),
server → jest *.spec.ts), plus minimal behavior-preserving extractions/exports
where the issue required a pure function to test:

- encode-wav: WAV header + PCM16 clamping
- editor-ext embed-provider / utils (sanitizeUrl, isInternalFileUrl) / indent
  (export clampIndent)
- label.dto @Matches regex
- move-page.dto vs generateJitteredKeyBetween parity (bug locked via test.failing)
- new-note-button canCreatePage (extracted to can-create-page.ts)
- history-editor diff (extracted pure computeHistoryDiff into history-diff.ts)
- notification getTypesForTab + repo contract (direct-tab divergence locked via
  test.failing)
- search buildTsQuery (extracted + sanitizes operator inputs so adversarial
  queries no longer risk a to_tsquery 500)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude code agent 227
d6cd275469 test(ai-http): cover header-stall fail-fast + retry (#140)
Extend ai-http.spec with two loopback-server tests: a provider that stalls
without sending headers triggers the (lowered) headersTimeout and is retried on a
fresh connection, recovering; a healthy fast response passes through in one
attempt. No external network calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude code agent 227
35fc58eaaa fix(ai-http): fail fast + retry on provider header stall (#140)
The z.ai GLM coding endpoint intermittently accepts the chat request but never
sends response headers; undici's default 300s headersTimeout then hung the user
for five minutes before failing, and UND_ERR_HEADERS_TIMEOUT was not in the
RetryAgent's retried error set, so there was no recovery.

headersTimeout only bounds time-to-FIRST-headers (before any body) — it is NOT
the streaming budget, so lowering it does not truncate live SSE streams. Cap it
(env AI_HTTP_HEADERS_TIMEOUT_MS, default 60s) so a header stall fails fast, and
add UND_ERR_HEADERS_TIMEOUT to the retried error codes so the stalled request is
retried on a fresh connection (which usually responds in seconds). bodyTimeout
kept generous (env AI_HTTP_BODY_TIMEOUT_MS, default 300s) so slow streams with
sparse chunks survive. UND_ERR_BODY_TIMEOUT is deliberately NOT retried (mid-body,
partial SSE already delivered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude_code
7884dc2e1a feat(editor): add gitmost bridge for listing spaces/pages and creating a page with a recording
Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:

- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
  -> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
  filename, mimeType}): validate/decode the audio first (so a bad payload
  leaves no junk page), resolve the space slug via getSpaceById (no-space
  probe), createPage(), navigate via the router (no reload), wait for the
  new page's editor to be mounted+editable+Yjs-connected, then run the same
  uploadAudioAction path as insertRecording. Resolve-only error contract:
  no-space | create-failed | editor-timeout | insert-failed.

DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).

Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:51:17 +03:00
97002f318a Merge pull request 'fix(ai-chat): adopt the server-returned chat id (two-tab adoption race #137)' (#138) from fix/ai-chat-chatid-adoption into develop
Reviewed-on: #138
2026-06-23 03:35:03 +03:00
claude code agent 227
870df458ed test(ai-chat): cover double onTurnFinished; name hook option/result types
Closes the 4th PR #138 review (3 suggestions, no blockers).

- Double-call safety: a failed turn fires both useChat onFinish AND onError, so
  onTurnFinished can run twice in one turn (streamed id, then no id) before a
  re-render. onTurnFinished now reads the live id from a ref (set imperatively on
  primary adoption) instead of the stale closure, so the 2nd no-id call cannot
  re-arm the error-path fallback at the source; the render-phase reconciler is the
  second layer. Added a hook test for the sequence — verified it fails only if
  BOTH layers are removed (non-tautological).
- Conventions: extracted named UseChatSessionOptions / UseChatSessionResult
  interfaces (was an inline param literal + ChatSession); the test derives its
  driver props from them.
- Simplification: extracted the chatIdSnapshot(chats) projection used at both the
  fallback arm site and the resolver effect.

Architecture notes from the review (caller-driven disarm contract; cross-process
{chatId} type) intentionally left as Variant A per the reviewer's recommendation.

tsc clean; 128 ai-chat tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:33:15 +03:00
claude code agent 227
8f9a218c68 fix(ai-chat): disarm pending adoption on New chat/select; drop dead helper
Closes the 3rd PR #138 review.

Warning fix: the render-phase reconciler only disarms the error-path adoption
fallback when activeChatId actually changes. Pressing 'New chat' while ALREADY
in a new chat keeps activeChatId === null (a no-op atom write), so the reconciler
never fired and a stale armed fallback could adopt the just-failed chat from a
late refetch, yanking the user out of their fresh chat. useChatSession now
returns cancelPendingAdoption(); the window calls it from startNewChat AND
selectChat. (The hook call moved above those callbacks so they can reference it.)
Added a hook test that fails without the explicit disarm, plus a test for the
existing-chat onTurnFinished branch (no adoption + per-chat invalidation).

Cleanups: removed the dead pickNewlyCreatedChatId (the fallback effect uses
newlyAddedChatIds directly with the 0/1/>1 decision inline) and its tests/doc
mention; inlined the two invalidation closures (onTurnFinished is read live by
useChat's onFinish, never in an effect dep array, so memoizing them was needless
ceremony).

Verified: tsc clean, 127 ai-chat tests green; live (z.ai glm-5.2) new chat + 2nd
turn recalled the number in the SAME row (1 chat / 4 messages), no page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:04:40 +03:00
claude_code
b7abb7ea01 feat(ai-http): log detailed fetch error cause chain
Node's fetch returns a generic "fetch failed" error, hiding the actual
reason (e.g., ECONNRESET, timeout) in the error's cause chain. This
change extracts up to three levels of the cause, formats each with its
code and message, and includes the chain in the warning log, making
failures more actionable.
2026-06-23 03:01:10 +03:00
claude_code
fd66ee6cce fix(ai-chat): stop title generation racing the chat stream (provider stall)
A new-chat turn fired the chat stream (streamText) and title generation
(generateText) concurrently to the same z.ai coding endpoint. That plan
stalls one of two concurrent requests, so the chat stream black-holed for
~300s (undici headers timeout) and the turn hung forever in every browser;
the AI SDK then retried 3x. Server logs showed two concurrent POSTs to
/chat/completions per turn — one 200 in ~8s, the other "fetch failed after
301209ms". Bypassing the custom undici transport did not help, confirming
the cause is the concurrency, not the transport.

Move generateTitle from before the response pipe into onFinish, so it runs
solo AFTER the stream's provider call completes. A first turn that errors or
aborts no longer auto-titles (fallback "Untitled chat" already handles a
null title) — acceptable, and it removes the request that was stalling.
2026-06-23 02:41:14 +03:00
claude_code
a8d1caf039 Merge branch 'feat/ai-settings-per-card-save' into develop
Add a per-card "Save and test" button to the AI provider settings (save the
whole form, then probe the endpoint on success).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:21 +03:00
claude_code
6946ee4415 feat(ai-settings): add per-card "Save and test" button
The single global "Save endpoints" button sat far below the fold and the
per-card "Test endpoint" button probed the server-stored settings, so it
ignored unsaved form edits. Replace each endpoint card's "Test endpoint"
button with a combined "Save and test" button that persists the whole form
first and only runs the card's connection probe on a successful save; the
global "Save endpoints" button is kept for save-only.

- Add handleSaveAndTest: save (rethrows on failure) then probe; skip the
  test if the save fails (the mutation already surfaces the error).
- Add savingTestCapability state so only the clicked card spins during the
  shared save while all save controls stay disabled (no concurrent saves).
- Reset the previous probe result when a new save+test starts.
- Add the "Save and test" en-US translation key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:13 +03:00
claude code agent 227
f59ca3cb0d refactor(ai-chat): extract useChatSession hook + lock the id lifecycle with tests
Addresses the 2nd PR #138 review (test debt + the Variant-B architecture ask).

The new→persisted chat id lifecycle (mount key, both adoption paths, the
history-load latch, the render-phase reconciler, onTurnFinished) is moved out of
the 768-line window into a new useChatSession hook driven by a pure
threadSessionReducer (reconcile/adopt), so adopt-vs-switch is one explicit
dispatch point and the scattering the review flagged is gone (window: 768→~620).

Tests (the blockers):
- use-chat-session.test.tsx — hook-level locks incl. the #137 regression
  (adopts the authoritative streamed id 'A', NOT chats.items[0]='B' — fails on
  the old heuristic), the error-path fallback (arm/adopt/ambiguous/add+delete),
  the disarm-on-reconcile lock (a fallback armed then switched away must not be
  adopted by a late refetch), in-place-adopt-keeps-key vs external-switch-remount,
  and the waitingForHistory latch.
- extractServerChatId (reading message.metadata.chatId) and newlyAddedChatIds
  extracted as pure helpers with unit tests; threadSessionReducer tested.

Cleanups: single canonical #137 explanation in adopt-chat-id.ts (other sites
reference it); fallback effect computes the set diff once; invalidate callbacks
memoized; redundant invariant tests folded.

Behavior preserved — re-verified live (z.ai glm-5.2): new-chat adopt + 2nd turn
in the same row, no mid-conversation remount, two-tab race leak-free, switch to
an existing chat reseeds full history, reload restores history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:25:52 +03:00
claude_code
1efc016cd9 Merge branch 'feature/gitmost-audio-bridge' into develop
Expose window.gitmost native bridge for inserting recorded audio as audio blocks.
2026-06-23 02:24:39 +03:00
claude_code
10fe605159 feat(editor): expose window.gitmost bridge to insert recordings as audio blocks
Add a stable native-host JS-API on window.gitmost so the gitmost.app
WKWebView wrapper can hand a recorded audio file to the current page as an
audio block without depending on editor internals (atoms/Tiptap/Yjs).

- page-editor.tsx: register/tear down window.gitmost only while an editable
  page editor is mounted (ready=true, version=1). insertRecording validates
  mime/size, decodes base64 in 4-char-aligned chunks, rejects oversized
  payloads before decoding (too-large), reuses the existing uploadAudioAction
  pipeline, resolves machine-readable error codes
  (no-editor/bad-type/too-large/insert-failed) and never throws. Cleanup is
  identity-guarded so an unmounting PageEditor cannot disable a newer live
  registration.
- editor-ext audio-upload: return the uploaded attachment from the upload fn
  so the bridge can report success + attachmentId. Backward compatible:
  existing fire-and-forget callers ignore the return value; the error path
  still swallows and returns undefined (no re-throw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:24:28 +03:00
claude_code
7c308728de chore(ai-chat): add stream timing logs + env-gated aiFetch bypass (diagnostics)
The streaming chat turn hangs in all browsers while the non-streaming test
endpoint works — both use the same model/transport (createOpenAI + aiFetch),
so the suspect is the streaming path / custom undici RetryAgent transport.

- ai-http.ts: wrap aiFetch with per-request timing logs (start, ms-to-headers
  on success, elapsed ms + cause on failure). Chat at info, embeddings at
  debug. Only host+path logged.
- ai-chat.controller.ts / ai-chat.service.ts: log turn START, first-chunk
  latency, FINISHED duration, and elapsed ms on disconnect/error/abort.
- ai.service.ts: AI_BYPASS_RESILIENT_FETCH=true makes the CHAT model omit
  fetch:aiFetch and use the default global fetch — isolates transport vs
  request-shape. Chat-only; embeddings/STT untouched; reversible via env.
- .env.example: document the flag.

No timeout/retry change. tsc clean; ai-chat + ai suites pass (292).
2026-06-23 02:13:54 +03:00
claude_code
da058bb6a0 feat(editor): transcribe embedded audio blocks
Add a gated "Transcribe" action to the audio block's bubble menu so an
already-embedded audio file can be transcribed (previously only live
microphone dictation was supported). The button fetches the embedded
file, normalizes its MIME type to the STT whitelist, reuses the existing
POST /ai-chat/transcribe endpoint, and inserts the result as a paragraph
right below the audio block.

- Mount the previously-unwired AudioMenu in page-editor (edit mode only),
  which also surfaces the existing Download/Delete actions for audio.
- Gate the Transcribe button on settings.ai.dictation; show a spinner and
  block double-submits while transcribing; map errors like the mic hook.
- Disambiguate duplicate-src blocks by re-scanning the doc and inserting
  after the audio node closest to the originally selected one.
- Add i18n keys (en-US, ru-RU): Transcribe, Transcribing…, No speech
  detected, plus ru-RU translations for the transcription error messages.
2026-06-23 01:47:34 +03:00
claude code agent 227
ba90147749 refactor(ai-chat): consolidate thread id lifecycle into one atomic identity
Addresses the PR #138 review's architecture note (the deferred 'non-blocking'
item). The brand-new -> persisted chat identity was spread across two separate
useState slots (threadKey + liveThreadChatId) plus a render-phase guard, so the
mount key and the live thread's chat id could in principle diverge.

Consolidate them into ONE atomic state object { key, chatId } with pure,
unit-tested transitions in thread-identity.ts:
- newThread(newKey)       — a brand-new id-less chat (fresh session key);
- switchThread(chatId)    — switch to an existing chat (key := chat id) -> remount;
- adoptThread(prev, id)   — a new chat learns its real id IN PLACE (key unchanged
                            -> no remount, live useChat store preserved).
The 'key vs chatId diverged' state is now unrepresentable. The render-phase
reconciliation stays (the atom is also set externally, e.g. page-history's
history-item opening a referenced chat), but adoption vs switch is now explicit.

Behavior is unchanged; verified live: new-chat adopt + 2nd turn in the same row,
no mid-conversation remount, the two-tab race stays leak-free, switch-to-existing
remounts + reseeds, and reload restores full history. Adds thread-identity.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:46:15 +03:00
claude_code
93c1e6e3e4 refactor(ui): replace comments icon with button
Switched the comments ActionIcon to a Button component, using a text label instead of an icon. This improves clarity and aligns the header menu with the current design guidelines.
2026-06-23 01:31:01 +03:00
claude_code
d21e1430b0 refactor(ui): replace star icon with message icon in app header 2026-06-23 01:25:48 +03:00
claude code agent 227
580f3442b8 fix(ai-chat): prevent duplicate chat row on first-turn error; add adoption tests
Addresses the PR #138 review.

Blocker 1 — duplicate chat row: a brand-new chat whose first turn errors BEFORE
the SSE 'start' chunk never receives the authoritative chatId, so metadata
adoption can't run; a retry then sent chatId:null and the server inserted a
SECOND chat row, orphaning the first turn. Keep metadata adoption as the primary
path (resolveAdoptedChatId) and add a bounded, unambiguous fallback: on a
new-chat finish with no server id, snapshot the known chat ids and, once the
list refetch lands, adopt the SINGLE newly-appeared id (pickNewlyCreatedChatId).
Zero or >1 new ids (e.g. two tabs racing) → no adoption — no items[0] guessing,
so #137 stays fixed. The wait-for-refetch guard compares set membership (robust
to a concurrent delete), and the diff dedupes so a repeated id from a paginated
list never reads as ambiguous.

Blocker 2 — tests: new adopt-chat-id.test.ts covers both pure helpers (adopt
decision + newly-created-id diff incl. dedupe/reorder); the server
messageMetadata callback is extracted to chatStreamStartMetadata and unit-tested
(start -> {chatId}, otherwise undefined).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:17:30 +03:00
claude_code
1b4de2b420 fix(ai-chat): keep SSE stream alive in Safari (heartbeat + strip hop-by-hop headers)
Safari/WebKit dropped the AI chat answer stream mid-turn ("Load failed",
shown as "Lost connection to the server") while Chrome/Firefox were fine.
Two Safari-specific causes: (1) during model think/tool gaps the UI-message
SSE stream emits no bytes and WebKit aborts a non-progressing fetch far more
aggressively than Chrome; (2) the AI SDK sets a hop-by-hop `Connection:
keep-alive` header which is illegal on HTTP/2 — Chrome/Firefox ignore it,
Safari rejects the whole response. Earlier commits only improved the error
text, never the drop itself.

Add apps/server/src/core/ai-chat/sse-resilience.ts with two helpers wired into
both stream paths (authenticated + public share):
- startSseHeartbeat: writes a `: ping` SSE comment every 15s (ignored by the
  client's EventSourceParserStream) so bytes keep flowing; unref'd timer,
  guarded writes, auto-clear on finish/close.
- stripStreamingHopByHopHeaders: wraps writeHead once to drop Connection/
  Keep-Alive before the head is sent, so they can never leak into an HTTP/2
  response.
Add sse-resilience.spec.ts (7 tests). tsc + eslint clean.
2026-06-23 01:02:55 +03:00
claude_code
544355a3c8 Merge branch 'feat/dictation-streaming-toggle' into develop
Gate streaming (silence-cut) dictation behind the per-workspace
settings.ai.dictationStreaming flag (default off); batch dictation stays the
default and fallback. Removes the implemented backlog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:14:28 +03:00
claude_code
44a1b5b003 feat(dictation): gate streaming dictation behind a workspace toggle
Streaming (silence-cut) dictation was hardcoded on. Put it behind a per-workspace
flag settings.ai.dictationStreaming, default off, with batch dictation as the
default and fallback. Mirrors the existing settings.ai.dictation flag end to end:

- server: aiDictationStreaming on UpdateWorkspaceDto + workspace.service writes
  settings.ai.dictationStreaming via updateAiSettings (jsonb merge keeps siblings)
- client: IWorkspaceAiSettings.dictationStreaming, an optimistic "Streaming
  dictation" sub-toggle under "Voice dictation" (disabled when dictation is off)
- gate the MicButton streaming prop in the editor toolbar and chat composer on
  the flag instead of a literal true

When the flag is absent/false both call sites pass streaming=false, so the VAD
model/wasm are never fetched and behavior is unchanged. Reuses the existing STT
model and /ai-chat/transcribe — no new provider/model/endpoint fields.

Removes the backlog entry now that it is implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:59:35 +03:00
claude code agent 227
0edc5aeda8 docs(agents): clarify where DB migrations auto-apply (prod) vs not (dev)
Migrations auto-run on boot only in production (the built image / start:prod);
the local dev stand (pnpm dev / nest start --watch) does NOT auto-run them, so
after pulling or switching branches you must apply them with
'pnpm --filter server migration:latest' or endpoints touching new columns 500
(e.g. a freshly-added ai_chats.page_id blanket-500s all of AI chat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:46:50 +03:00
claude code agent 227
1858a5800d fix(ai-chat): adopt the server-returned chat id, not the newest in the list
A brand-new chat (activeChatId === null) had no way to learn the id of the row
the server created: the SSE stream never returned it, so the client adopted the
NEWEST chat in the per-user list (chats.items[0]). With two tabs open, a second
tab creating a chat at ~the same time made its row the newest, so the first tab
adopted the wrong id — its later turns persisted into the other chat and the
agent rebuilt history from it (commands leaked between chats), while the live UI
still showed the original conversation. (#137)

The server now attaches the authoritative chatId to the streamed assistant
message via the AI SDK messageMetadata on the 'start' part, so it reaches the
client on the first chunk. The client reads message.metadata.chatId in useChat's
onFinish and adopts that id in place (no remount, so the live turn and the
thread's chatIdRef follow the real id and the next turn targets the right chat).
The chats.items[0] guess and the adoptNewChat ref are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:46:50 +03:00
claude_code
ee25d52965 docs(backlog): gate streaming dictation behind a feature toggle (default off)
Design entry: hide the silence-cut streaming dictation path behind a per-
workspace settings.ai.dictationStreaming flag, default false, with batch
dictation as the default and fallback. Reuses the existing STT model and
/ai-chat/transcribe — no new provider/model/endpoint fields. Lists the server
+ client touch points, acceptance criteria, and edge cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:40:53 +03:00
claude_code
2d7f85fccb Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-22 21:14:05 +03:00
86bb2742c7 Merge pull request 'fix(qa): resolve QA-pass issues #122–#134' (#135) from fix/qa-issues-122-134 into develop
Reviewed-on: #135
2026-06-22 21:07:19 +03:00
claude code agent 227
8915a875a2 fix(qa): address PR #135 review notes
- Add the two new strings to en-US locale ('Go to login page', 'Move to
  space') so they aren't missing from the base locale (review note 1).
- Avatar upload: accept any image/* MIME instead of a hardcoded png/jpeg/jpg
  list, so webp/gif/etc. are no longer wrongly rejected client-side while
  genuine non-images still surface the error (review note 2).
- Reindex polling: align the deadline-clearing effect with the refetchInterval
  stop condition (indexed >= total, empty workspace included) so the deadline
  clears promptly instead of waiting out the cap (review note 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:05:45 +03:00
claude_code
ebc3b01dc2 feat(ai-chat): mark interrupted turns with a "stopped" notice
A turn that ends without a clean finish now shows a neutral marker, so an
interrupted answer is visible instead of trailing off silently. Errors keep
their existing red banner; this covers the aborted case.

- chat-stopped-notice.tsx: new neutral (gray) notice component
- chat-thread.tsx: live marker driven by useChat onFinish flags — distinguishes
  a manual Stop (isAbort) from a dropped connection (isDisconnect); cleared when
  the next turn streams; flushNext still runs only on a clean finish
- message-item.tsx: per-message marker in reopened history for finishReason
  'aborted' with no error (combined wording, since the server can't tell a
  manual Stop from a dropped connection)
- ai-chat.types.ts: add metadata.finishReason; rowToUiMessage now carries it
- en-US: three new strings

Frontend only — the server already persists partial work and finishReason and
replays it to the model on the next turn (continue, not restart).
2026-06-22 20:56:30 +03:00
claude code agent 227
9e1d057878 fix(qa): resolve QA-pass issues #122–#134
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.

Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
  skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
  reconnected; read the token via a ref, guard the decode (incl. missing exp),
  and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
  enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
  computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
  also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
  size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
  polls coverage (bounded) so the counter climbs live; misleading server comment
  reworded.

UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
  space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
  placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
  style the invalid-invitation page with a CTA; hide Share for read-only users;
  align the dictation "not configured" message; "Go to login page" typo.

Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:47:40 +03:00
claude_code
c53ce35312 feat(ui): swap AI-chat and comments icons to avoid confusion
The AI chat button used IconSparkles and the page comments button used
IconMessage, which read as visually similar speech bubbles. Replace the
AI icon with IconMessageCircleStar (chat bubble + star) and the comments
icon with IconMessages (overlapping bubbles) so the two are clearly
distinct.

- app-header.tsx: IconSparkles -> IconMessageCircleStar
- page-header-menu.tsx: IconMessage -> IconMessages
2026-06-22 20:34:39 +03:00
claude_code
fc262636ab fix(ai-chat): persist partial answer when a turn errors mid-stream
A provider error (e.g. read ECONNRESET) routed the turn through the
streamText onError callback, which persisted an EMPTY assistant record
(buildErrorAssistantRecord -> text:'', parts:[]). The answer text already
streamed to and shown by the client was therefore lost from the persisted
row, the chat export, and reopened history — leaving only the error line.

The AI SDK v6 onError callback receives only { error } (no steps/text),
and the visible final answer streams in the last, not-yet-finished step,
so it is absent from every finished step.text. Accumulate it ourselves:
onChunk folds each 'text-delta' into inProgressText; onStepFinish moves a
finished step into capturedSteps and resets inProgressText. onError and
onAbort now persist the partial answer (finished steps' text + tool parts
via assistantParts, then the in-progress text appended last) through a new
shared pure helper buildPartialAssistantRecord, recording the cause in
metadata.error on the error path. Replaces buildErrorAssistantRecord; its
empty-turn shape is preserved when nothing streamed.

Complementary to the resilient-fetch reconnect: that reduces how often a
turn dies; this preserves what was produced when it dies anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:30:59 +03:00
claude_code
aebdb6c00c Merge branch 'fix/ai-econnreset-resilience' into develop 2026-06-22 20:26:58 +03:00
claude_code
1af5d34ae3 fix(ai-chat): reconnect on provider ECONNRESET via a resilient fetch
Outbound LLM calls used Node's default global undici agent (default
keep-alive pooling, no transport-level reconnect), so a TCP RST on a
reused/poisoned keep-alive socket surfaced as
"Cannot connect to API: read ECONNRESET" and failed the chat stream and
title generation after the AI SDK's own retries were exhausted.

Add a dedicated resilient outbound HTTP layer (ai-http.ts): a shared
undici RetryAgent over a tuned Agent, exposed as `aiFetch` and injected
into every AI provider factory (createOpenAI chat/embeddings/STT,
createGoogleGenerativeAI, createOllama) plus the raw JSON STT fetch. The
RetryAgent reconnects on connection-level errors (ECONNRESET, ...) on a
FRESH socket, opts POST into the retry methods (undici's default list
excludes POST), and leaves HTTP-status retries (429/5xx + Retry-After) to
the AI SDK to avoid double-retry.

- ai-http.ts: shared RetryAgent(Agent) + aiFetch (maxRetries 2,
  conservative keep-alive, connect timeout, streaming-safe timeouts)
- ai.service.ts: inject fetch: aiFetch into every provider factory
- ai-http.spec.ts: regression test that aiFetch injects the RetryAgent
  dispatcher into the underlying fetch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:23:35 +03:00
claude_code
83c61641c9 fix(ai-chat): prevent error banner from clipping its text
The error banner is a flex child of the chat panel column. Mantine's
Alert root is `overflow: hidden`, which (per the CSS flexbox spec) drops
its automatic min-height to 0, so when the message history fills the
panel the flexbox compressed the banner below its content height and the
overflow:hidden clipped the detail text (e.g. "Please try again.").

Set flex-shrink: 0 on the banner so it always shows its full content; the
scrollable message list absorbs the height pressure instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:22:53 +03:00
claude_code
7ddd0cba05 feat(ai-chat): include in-progress streaming turn in chat export
The "Copy chat" export read only persisted DB rows (messageRows), so an
assistant reply that was still streaming — and the user message that
triggered it — were absent from the export until the turn finished and
the messages query was refetched.

ChatThread now mirrors its live useChat snapshot ({ messages,
isStreaming }) into a parent-owned ref; the effect clears the ref on
unmount so a thread switch can't leak its tail into the next chat.
AiChatWindow.handleCopy computes the not-yet-persisted live tail
(messages whose id is absent from messageRows, only while streaming) and
passes it to buildChatMarkdown as `pending`. buildChatMarkdown appends
pending messages after the persisted rows (continuing the heading
numbering), flags the streaming assistant message with an
"still being generated" note, and reuses an extracted renderMessageParts
helper so persisted and pending rendering stay identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:00:35 +03:00
claude_code
11d5a75c79 fix(ai-chat): improve error banner layout
The AI chat error Alert stranded the warning icon in the top-left while
the detail text hung indented under the heading, wrapping to 3 narrow
lines with empty space below. Switch to a "full-width detail" layout
(icon + bold heading on the first row, detail spanning full width below)
and extract the markup, previously duplicated in ChatThread and
MessageItem, into a single shared ChatErrorAlert component.

- add apps/client/src/features/ai-chat/components/chat-error-alert.tsx
- use it for the live stream error in chat-thread.tsx (mb="xs")
- use it for the persisted history error in message-item.tsx (mt={4})
- heading/icon use the adaptive --mantine-color-red-light-color so the
  banner stays correct in dark mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:54:17 +03:00
445 changed files with 35304 additions and 6240 deletions

View File

@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Comma-separated list of additional origins allowed to call the API via CORS.
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
# Leave empty for a same-origin (web-only) deployment.
CORS_ALLOWED_ORIGINS=
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
SWAGGER_ENABLED=false
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
# Leave empty for Android bundled mode / local development.
CAP_SERVER_URL=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
@@ -123,11 +136,45 @@ MCP_DOCMOST_PASSWORD=
# expose the port publicly).
# MCP_TOKEN=
# MCP_SESSION_IDLE_MS=1800000
#
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
# attribution is driven by a per-user `is_agent` flag on the users row. There is
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
# account for the MCP fallback above and flag ONLY that account, e.g.:
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
# provider is eventually broken instead of leaking forever. Default 900000 (15 min).
# AI_STREAM_TIMEOUT_MS=900000
# 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`. 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
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
# single tool call (a slow crawl that emits nothing until done) and an SSE
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
# AI_MCP_STREAM_TIMEOUT_MS=300000
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
# but never returns a result — which the silence timeout above never breaks.
# Default 900000 (15 min).
# AI_MCP_CALL_TIMEOUT_MS=900000
# --- 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
@@ -153,3 +200,11 @@ MCP_DOCMOST_PASSWORD=
# Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value.
# SHARE_AI_MAX_OUTPUT_TOKENS=512
#
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
# (input re-sent per step + output, summed across every accepted turn). The
# hourly request cap above bounds how MANY calls run, not how expensive each is,
# so this caps the owner's actual provider bill directly. Like the request cap it
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000

View File

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

View File

@@ -15,6 +15,38 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
# only ran locally, so regressions in those paths stayed green in CI.
# Postgres uses the pgvector image because migrations create vector columns
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
# defaults in apps/server/test/integration/db.ts + global-setup.ts
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
# TEST_*_URL overrides are needed.
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost_dev_pw
POSTGRES_DB: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,5 +68,12 @@ jobs:
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run tests
- name: Run unit tests
run: pnpm -r test
# Integration suite against the real Postgres/Redis services above. Runs
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
# unit run (mocks only) cannot cover. global-setup drops/recreates the
# isolated `docmost_test` DB and migrates it to latest.
- name: Run server integration tests
run: pnpm --filter server test:int

6
.gitignore vendored
View File

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

14
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
// VSCode tasks for this repo.
"version": "2.0.0",
"tasks": [
{
"label": "git push (github + gitea)",
"type": "shell",
"command": "git push github develop && git push gitea develop",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
"problemMatcher": []
}
]
}

View File

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

View File

@@ -10,8 +10,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
the database step by step and exported server-side, the desktop app no longer
freezes at 100% CPU on long agent runs, and MCP writes are badged with
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
per-reference back-links), hardens page moves and duplication against cycles
and lost edits, and caps the anonymous public-share assistant with a
per-workspace rolling-day token budget.
### Added
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
any publicly shared page a short, memorable, workspace-scoped vanity address
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
(never `301`, since the target is retargetable) to the canonical
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
serves the plain SPA index so that the existence of a name never leaks. An
alias can be moved to another page (with a confirm-reassign guard) and the
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
finalized once to `completed`/`error`/`aborted`. A process that dies mid-turn
keeps every finished step, and a startup sweep flips any dangling `streaming`
row (untouched for 10 minutes) to `aborted`. Chat "Copy" now exports
server-side from these rows (`POST /ai-chat/export`) rather than from live
client state, so the export is identical whether a chat is freshly streaming,
just switched to, or reloaded — and is available from the first turn of a new
chat. (#183, #174)
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
the MCP endpoint by a dedicated agent account are now badged as "AI", with
unspoofable provenance derived from a per-user `is_agent` flag (not from the
request body). **Operator setup:** use a _dedicated_ service account for the
MCP fallback and set the flag with SQL —
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
human or shared account, or its normal edits get mis-attributed as AI. See the
AI-agent block in `.env.example`. (#143)
- **Footnote import diagnostics.** The MCP page-write tools (`create_page`,
`update_page`, `import_page_markdown`) now return a `footnoteWarnings` array
flagging dangling references, empty or duplicate definitions, and `[^id]`
markers inside table rows, so an agent can fix its own markup. The page is
still created; the field is omitted when there are no problems. (#166)
- **AI chat "Protocol" setting (`chatApiStyle`).** A new admin choice in AI
settings for the `openai` driver: `openai-compatible` (default) routes chat
through `@ai-sdk/openai-compatible`, which surfaces a provider's streamed
reasoning (`reasoning_content` → reasoning parts) for z.ai/GLM, DeepSeek,
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
reasoning-model request shaping). Chosen explicitly rather than inferred from
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
now has an admin-authored `instructions` field ("how/when to use this server's
tools") that is injected into the agent's system prompt next to that server's
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
shown only for a server that actually connected and contributed ≥1 callable
tool. (#180)
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
### Changed
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
For the `openai` driver the chat provider defaults to the openai-compatible
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
model's reasoning out of the box. An endpoint that is real OpenAI behind a
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
same id are ONE footnote — one number, one definition, several back-references
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
first-wins on import (the rest are dropped and reported via `footnoteWarnings`),
and a reference with no definition yields a single empty footnote rather than
one per occurrence. This supersedes the 0.93.0 "survive duplicate-id
definitions" behavior for the import path. (#166)
- **Public share AI: default per-workspace hourly assistant cap lowered
300 → 100.** The limiter falls back to this default whenever
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
@@ -19,6 +96,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
keep the previous limit. (#62)
### Fixed
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
per-turn work grew quadratically and saturated the main thread. The stream is now
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
markdown part / reasoning block is memoized, so a long turn no longer re-parses
already-finished content. (#182)
- **Editor: caret/selection landed on the wrong line when clicking inside code
blocks and footnotes.** The affected NodeViews rendered their non-editable
chrome (language menu, footnotes heading, footnote number marker) before the
editable content, so the browser's click hit-testing missed the contentDOM and
snapped the caret to a previous node. Content now renders first in the DOM
(chrome is lifted back into place via CSS flex `order`), and scroll containers
are nudged after a paste to refresh stale hit-testing geometry. The caret
symptom is macOS-specific and was confirmed manually on macOS; the automated
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
- **AI chat: the live token counter now ticks between agent steps.** During a
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
no longer froze on the previous step's authoritative usage; the current step's
estimate is combined per-component with `max`, so the count rises smoothly and
never jumps backwards. (#163)
- **AI chat: "New chat" during a streaming first turn now resets the whole
chat, not just the role badge.** Starting a new chat mid-stream cleared the
header but left the in-flight turn's messages behind, so the fresh chat opened
pre-populated with the previous conversation; it now fully resets. (#161)
- **AI chat: a dropped tool argument now yields an actionable error.** When the
model omitted a required parameter (typically `pageId`) in a parallel/batch
tool call, the assistant forwarded zod's raw "expected string, received
undefined" text; tool inputs now return a message naming each missing/invalid
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
(#190)
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
under one of its own descendants is rejected in the same transaction as the
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
so a pre-existing cycle can no longer spin a query. (#207)
- **Page/editor robustness batch.** Duplicating a page now copies shared
attachments for every referencing page (not just the first); colliding block
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
wrong node; transient collab store failures are retried so autosave edits
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
(#206)
### Security
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
share assistant now caps a workspace's actual token spend (input + output,
summed across every accepted turn) over a trailing day, on top of the hourly
request cap — so a caller who evades the per-IP throttle still cannot run up
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
## [0.93.0] - 2026-06-21
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
@@ -61,6 +193,18 @@ embeds — plus a large batch of security hardening and test coverage.
injected into the `<head>` of public share pages only (for analytics such as
Google Analytics or Yandex.Metrika), kept separate from the member-facing
HTML-embed feature.
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
children, and comments are cached in IndexedDB (TanStack Query persister plus
`y-indexeddb` for the page's Yjs document), and a PWA service worker
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
offline. The offline cache (persisted query cache, Yjs page documents, and the
service-worker API cache) is cleared on logout so a previous user's private
data does not remain in the browser.
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
can request the access JWT in the response body (`data.authToken`) in addition
to the httpOnly cookie (the web client stays cookie-only); an optional
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
embedded `/mcp` endpoint.
- **Page tree**: Expand all / Collapse all for the space tree, and
@@ -76,6 +220,12 @@ embeds — plus a large batch of security hardening and test coverage.
### Changed
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
`app.enableCors()`). The same-origin web client is unaffected, but any
separately-hosted cross-domain client must now be listed in
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
allowed automatically). Requests with no `Origin` header (server-to-server)
are still allowed.
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
when the workspace HTML-embed toggle is on, can be inserted by any member
(previously admin-only). Turning the toggle off hides existing embeds and
@@ -101,8 +251,7 @@ embeds — plus a large batch of security hardening and test coverage.
- Page templates: import `ThrottleModule` so collab boots, never strand an
in-flight page-embed id, and add defense-in-depth workspace checks.
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
- Import: surface the real error cause from `/pages/import` instead of a generic
400.
- Import: surface the real error cause from `/pages/import` instead of a generic 400.
### Security

View File

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

View File

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

View File

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

View File

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

View File

@@ -258,6 +258,7 @@
"Copy to space": "Copy to space",
"Copy chat": "Copy chat",
"Copied": "Copied",
"Failed to export chat": "Failed to export chat",
"Duplicate": "Duplicate",
"Select a user": "Select a user",
"Select a group": "Select a group",
@@ -420,6 +421,8 @@
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"{{count}} result found_one": "{{count}} result found",
"{{count}} result found_other": "{{count}} results found",
"Equal columns": "Equal columns",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -461,6 +464,15 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -708,9 +720,12 @@
"Authorization header": "Authorization header",
"Tool allowlist": "Tool allowlist",
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Failed": "Failed",
"OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Clear": "Clear",
@@ -1075,6 +1090,8 @@
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Back to references": "Back to references",
"Back to reference {{label}}": "Back to reference {{label}}",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
@@ -1127,15 +1144,32 @@
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Label added": "Label added",
"Label removed": "Label removed",
"Image updated": "Image updated",
"Unsupported image type": "Unsupported image type",
"Member deactivated": "Member deactivated",
"Member activated": "Member activated",
"Name is required": "Name is required",
"Name must be 40 characters or fewer": "Name must be 40 characters or fewer",
"Group name must be at least 2 characters": "Group name must be at least 2 characters",
"Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer",
"Description must be 500 characters or fewer": "Description must be 500 characters or fewer",
"Invalid invitation link": "Invalid invitation link",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"AI chat": "AI chat",
"Ask a question about this documentation.": "Ask a question about this documentation.",
"Ask a question…": "Ask a question…",
"Thinking…": "Thinking…",
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant",
"Enabled": "Enabled",
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
"Public assistant model": "Public assistant model",
"Defaults to the chat model": "Defaults to the chat model",
@@ -1144,7 +1178,9 @@
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
"Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Context size / model limit": "Context size / model limit",
"Context window (tokens)": "Context window (tokens)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
@@ -1154,6 +1190,9 @@
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
"Response stopped (manually or the connection dropped).": "Response stopped (manually or the connection dropped).",
"Chat menu": "Chat menu",
"No chats yet.": "No chats yet.",
"Delete this chat?": "Delete this chat?",
@@ -1185,8 +1224,11 @@
"Semantic search": "Semantic search",
"Voice / STT": "Voice / STT",
"Voice dictation": "Voice dictation",
"Streaming dictation": "Streaming dictation",
"Transcribe as you speak, cutting on pauses": "Transcribe as you speak, cutting on pauses",
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
"Test endpoint": "Test endpoint",
"Save and test": "Save and test",
"Save endpoints": "Save endpoints",
"Configured and enabled": "Configured and enabled",
"Configured but disabled": "Configured but disabled",
@@ -1219,6 +1261,8 @@
"No microphone found": "No microphone found",
"Could not start recording": "Could not start recording",
"Transcription failed": "Transcription failed",
"Transcribe": "Transcribe",
"No speech detected": "No speech detected",
"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",
@@ -1245,6 +1289,10 @@
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
"Start automatically": "Start automatically",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
"Launch message": "Launch message",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
"Agent roles": "Agent roles",
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
"No roles configured": "No roles configured",
@@ -1264,5 +1312,30 @@
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
"Analytics / tracker": "Analytics / tracker",
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
"Showing {{count}} subpages_other": "Showing {{count}} subpages",
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)",
"Custom address": "Custom address",
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address"
}

View File

@@ -257,6 +257,7 @@
"Copy": "Копировать",
"Copy to space": "Копировать в пространство",
"Copied": "Скопировано",
"Failed to export chat": "Не удалось экспортировать чат",
"Duplicate": "Дублировать",
"Select a user": "Выберите пользователя",
"Select a group": "Выберите группу",
@@ -385,6 +386,11 @@
"Quote": "Цитата",
"Image": "Изображение",
"Audio": "Аудио",
"Transcribe": "Транскрибировать",
"Transcribing…": "Транскрибация…",
"No speech detected": "Речь не распознана",
"Transcription failed": "Не удалось распознать речь",
"Voice dictation is not configured": "Голосовой ввод не настроен",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
@@ -400,6 +406,8 @@
"Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке",
"Back to references": "Вернуться к ссылкам",
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
"Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
@@ -466,6 +474,15 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -672,9 +689,21 @@
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"Start automatically": "Запускать автоматически",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
"Launch message": "Стартовое сообщение",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…",
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
@@ -684,12 +713,19 @@
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
"OK · {{n}}": "OK · {{n}}",
"Test": "Тест",
"No tools available": "Инструменты недоступны",
"Available tools": "Доступные инструменты",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
@@ -731,6 +767,8 @@
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
"Instructions": "Инструкции",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
"Sources": "Источники",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен",
@@ -1132,5 +1170,29 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)",
"Custom address": "Пользовательский адрес",
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
}

View File

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

View File

@@ -42,6 +42,23 @@ export default function AvatarUploader({
return;
}
// Validate file type. The `accept` attribute only filters the dialog;
// a user can still select a non-image file, which previously failed
// silently. Surface a visible error instead (issue #133). Accept any
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
// what the server accepts; only genuinely non-image files are rejected.
if (!file.type.startsWith("image/")) {
notifications.show({
message: t("Unsupported image type"),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
@@ -58,6 +75,8 @@ export default function AvatarUploader({
try {
await onUpload(file);
// Notify on success so the upload gives visible feedback (issue #128)
notifications.show({ message: t("Image updated") });
} catch (error) {
console.error(error);
notifications.show({
@@ -117,7 +136,7 @@ export default function AvatarUploader({
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
accept="image/*"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}

View File

@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -5,7 +5,7 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
@@ -107,7 +107,7 @@ export function AppHeader() {
aria-label={t("AI chat")}
onClick={() => setAiChatWindowOpen((v) => !v)}
>
<IconSparkles size={20} />
<IconMessage size={20} />
</ActionIcon>
</Tooltip>
)}

View File

@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
import classes from "./app-shell.module.css";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
@@ -157,6 +158,10 @@ export default function GlobalAppShell({
{/* Floating AI chat window. Mounted once globally; it is position: fixed
and self-hides when closed, so its place in the tree is not critical. */}
<AiChatWindow />
{/* Global gitmost native bridge: registers listSpaces / listPages /
createPageWithRecording on window.gitmost so the native host can
create a page with a recording even when no page editor is open. */}
<GitmostGlobalBridge />
</>
);
}

View File

@@ -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();
},
);
});

View File

@@ -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;

View File

@@ -1,4 +1,22 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Persisted floating AI chat window geometry (position + size). Held in
* localStorage so a drag/resize survives a full page reload. `null` means
* "never placed yet" — the window then computes an initial top-right placement.
* On restore the value is clamped to the current viewport (see AiChatWindow).
*/
export type AiChatWindowGeom = {
left: number;
top: number;
width: number;
height: number;
};
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
"ai-chat-window-geom",
null,
);
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:

View File

@@ -6,7 +6,6 @@ import {
useRef,
useState,
} from "react";
import { generateId } from "ai";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
@@ -25,6 +24,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
@@ -39,11 +39,13 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.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,
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -78,17 +80,31 @@ function computeInitialGeom() {
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
);
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
const maxTop = Math.max(
EDGE_MARGIN,
window.innerHeight - height - EDGE_MARGIN,
);
const top = Math.min(60, maxTop);
return { left, top, width, height };
}
// Clamp a geometry so the window stays within the current viewport.
function clampGeom(g: { left: number; top: number; width: number; height: number }) {
function clampGeom(g: {
left: number;
top: number;
width: number;
height: number;
}) {
const effWidth = Math.max(g.width, MIN_WIDTH);
const effHeight = Math.max(g.height, MIN_HEIGHT);
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN);
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN);
const maxLeft = Math.max(
EDGE_MARGIN,
window.innerWidth - effWidth - EDGE_MARGIN,
);
const maxTop = Math.max(
EDGE_MARGIN,
window.innerHeight - effHeight - EDGE_MARGIN,
);
return {
...g,
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
@@ -99,12 +115,13 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
/**
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
* chat, new chat, adopt-new-chat, open-page context, token sum) and wraps the
* chat, new chat, in-place id adoption from streamed metadata, open-page
* context, token sum) and wraps the
* reused inner components (ConversationList + ChatThread) in window chrome
* ported from the GitmostAgent.jsx design.
*/
export default function AiChatWindow() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const queryClient = useQueryClient();
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
@@ -122,39 +139,13 @@ export default function AiChatWindow() {
minimizedRef.current = minimized;
const winRef = useRef<HTMLDivElement>(null);
// Live window geometry (position + size); initialized lazily on first open so
// it is anchored to the current viewport (top-right corner). Kept in state so
// a user resize survives close/reopen and can be re-clamped to the viewport.
const [geom, setGeom] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
// Track whether we are awaiting the id of a just-created (new) chat, so we
// can adopt it once the chat list refreshes after the first turn finishes.
const adoptNewChat = useRef(false);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// Mount key for ChatThread + the chat the currently-mounted thread represents.
// `threadKey` normally tracks the active chat, so selecting a different chat
// (incl. from page history) remounts and re-seeds. The ONE exception is
// in-place adoption of a brand-new chat's server id: the adopt effect moves
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
// check below does not fire and the SAME thread stays mounted (its useChat
// already holds the just-finished turn) instead of being re-seeded from
// not-yet-persisted history.
const [threadKey, setThreadKey] = useState<string>(
() => activeChatId ?? `new-${generateId()}`,
);
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
activeChatId,
);
// Live window geometry (position + size); persisted to localStorage so a
// drag/resize survives a full page reload (and close/reopen). `null` means
// "never placed yet" — the layout effect below then computes an initial
// top-right placement anchored to the current viewport, and on restore it is
// re-clamped to the viewport (so a placement saved on a larger screen is not
// left partly off-screen).
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
const { data: chats } = useAiChatsQuery();
// Roles for the new-chat picker (any member may list them). Only fetched while
@@ -188,21 +179,61 @@ export default function AiChatWindow() {
? { id: openPageData.id, title: openPageData.title }
: null;
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
// paths, the history-loaded latch, the render-phase reconciler) lives in this
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
// The invalidate closures are passed inline: `onTurnFinished` is read live by
// useChat's onFinish (never in an effect dep array), so their identity does not
// matter — no memoization ceremony needed.
const {
threadKey,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
} = useChatSession({
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList: () =>
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
onInvalidateChatMessages: (id) =>
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
});
// startNewChat/selectChat set the public atom; the hook's render-phase
// reconciler handles the remount when activeChatId actually CHANGES. But
// pressing "New chat" while already in a new chat leaves activeChatId === null
// (a no-op for the atom), so the reconciler never fires — explicitly disarm any
// armed error-path fallback here so a late refetch can't yank the user into a
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
// Cancel any pending adoption so a just-finished new chat can't yank the user
// back here after they explicitly started a fresh one.
adoptNewChat.current = false;
cancelPendingAdoption();
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
// while a brand-new chat's first turn is still streaming leaves activeChatId
// null (the real id is adopted only at turn end), so setActiveChatId(null)
// alone is a no-op and the reconciler never remounts — the chat/stream/history
// would persist and only the role badge would drop. This always remounts the
// thread into a clean new chat.
startFreshThread();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
}, [
cancelPendingAdoption,
startFreshThread,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
const selectChat = useCallback(
(chatId: string): void => {
// Cancel any pending adoption so it can't override an explicit selection.
adoptNewChat.current = false;
cancelPendingAdoption();
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
@@ -210,43 +241,31 @@ export default function AiChatWindow() {
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft, setSelectedRoleId],
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
// yet), the server has just created the row; adopt the newest chat id so the
// thread switches from "new" to the persisted chat (and loads its history on
// later opens).
const onTurnFinished = useCallback(() => {
if (activeChatId === null) adoptNewChat.current = true;
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
// Re-sync the persisted message rows for the active chat so the Markdown
// export and the token counters reflect the turn that just finished. The
// live thread renders from its own useChat store (stable threadKey / store
// id), so refetching these rows never re-seeds or tears down the open
// thread. For a brand-new chat activeChatId is still null here; that chat's
// first row load happens right after id adoption, and every later turn hits
// this invalidation with the adopted id.
if (activeChatId) {
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
});
}
}, [activeChatId, queryClient]);
// The active chat object (for its title) and an export gate: only enable the
// export button when an existing chat with loaded persisted rows is active.
// The active chat object (for its title) and an export gate. The export is now
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
// row is persisted upfront + per step, so even a brand-new chat whose first
// turn is streaming/interrupted has a server row to render. Enable the button
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
// chat that id is adopted EARLY — at the stream's `start` chunk via
// onServerChatId (#174) — so the Copy button is available during the first
// turn's stream, not only after it terminates.
const activeChat = useMemo(
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
[chats, activeChatId],
);
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
const canExport = !!activeChatId;
// The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
const currentRole = useMemo<{
name: string;
emoji: string | null;
} | null>(() => {
if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
}
@@ -254,76 +273,21 @@ export default function AiChatWindow() {
return picked ? { name: picked.name, emoji: picked.emoji } : null;
}, [activeChat, enabledRoles, selectedRoleId]);
// Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the
// feedback.
const handleCopy = useCallback(() => {
if (!activeChatId || !messageRows || messageRows.length === 0) return;
const markdown = buildChatMarkdown({
title: activeChat?.title ?? null,
chatId: activeChatId,
rows: messageRows,
t,
});
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
}, [activeChatId, messageRows, activeChat, clipboard, t]);
// When awaiting a new chat's id, adopt the most-recent chat (the list is
// ordered newest-first) once it appears.
useEffect(() => {
if (!adoptNewChat.current) return;
const newest = chats?.items?.[0];
if (newest) {
adoptNewChat.current = false;
// In-place adoption: move the active chat AND the live-thread marker to the
// new id together, so the threadKey derivation below sees no "switch" and
// keeps the SAME mounted thread (its useChat already holds the finished
// turn) instead of remounting and re-seeding from not-yet-persisted history.
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
// render so the render-phase guard never observes the new activeChatId with
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
// batching inside this effect callback guarantees that; if the store/atom
// mechanism ever changes, gate adoption on an explicit flag instead.
setLiveThreadChatId(newest.id);
setActiveChatId(newest.id);
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
// server is the single source of truth (#183): it renders the transcript from
// the persisted rows — including an interrupted turn's in-progress row — so the
// export is identical whether the chat is freshly streaming, just switched to,
// or reloaded. The `lang` of the active i18n drives the few localized labels.
const handleCopy = useCallback(async () => {
if (!activeChatId) return;
try {
const markdown = await exportAiChat(activeChatId, i18n.language);
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
} catch {
notifications.show({ message: t("Failed to export chat"), color: "red" });
}
}, [chats, setActiveChatId]);
// Adjust the derived thread state during render when the active chat genuinely
// changes — the React-sanctioned alternative to an effect (it re-renders before
// paint, no extra commit, and converges since the next render finds them equal).
// In-place adoption of a new chat's id never reaches here because the adopt
// effect moves liveThreadChatId in lockstep with activeChatId.
if (activeChatId !== liveThreadChatId) {
setLiveThreadChatId(activeChatId);
setThreadKey(activeChatId ?? `new-${generateId()}`);
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages
// invalidation, which can transiently flip hasNextPage for a chat whose
// message count is an exact multiple of the server page size) does not tear
// the live thread down to a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
threadKey === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so we keep showing the live thread instead of unmounting it behind a
// loader; and once a chat's history has loaded, a later background refetch no
// longer tears the thread back down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
threadKey === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
}, [activeChatId, clipboard, t, i18n.language]);
// Current context size for the active chat: how much the conversation now
// occupies in the model's context window — NOT the cumulative tokens spent.
@@ -332,24 +296,19 @@ export default function AiChatWindow() {
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
// PERSISTED rows (updates on chat open/switch); it does not tick live
// mid-stream — acceptable for v1.
const contextTokens = useMemo(() => {
if (!activeChatId || !messageRows) return 0;
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
return meta.contextTokens;
}
const usage = meta.usage;
if (usage) {
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) return fallback;
}
}
return 0;
}, [activeChatId, messageRows]);
//
// The denominator `maxContextTokens` (the model's configured max window) is
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
// on a completed turn, but the numerator and denominator are taken from the
// most recent row carrying EACH value independently — they may land on
// different rows (e.g. a fresh error row can carry contextTokens but not
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
// no row has it (older rows, or no admin-configured limit) — the badge then
// shows just the current size with no denominator.
const { contextTokens, maxContextTokens } = useMemo(
() => selectContextBadge(activeChatId ? messageRows : undefined),
[activeChatId, messageRows],
);
// On (re)open, settle the geometry before paint (useLayoutEffect → no
// first-frame jump): compute an initial top-right placement the first time,
@@ -390,18 +349,23 @@ export default function AiChatWindow() {
useEffect(() => {
if (!windowOpen || minimized) return;
const el = winRef.current;
// `geom` is in the deps so this re-runs once geometry is settled and the
// window is actually rendered (on the first open `geom` is still null on the
// render that flips windowOpen, so winRef.current is null then — without the
// geom dep the observer would never attach and resizes would not persist).
if (!el) return;
const ro = new ResizeObserver(() => {
const width = el.offsetWidth;
const height = el.offsetHeight;
setGeom((prev) => {
if (!prev || (prev.width === width && prev.height === height)) return prev;
if (!prev || (prev.width === width && prev.height === height))
return prev;
return { ...prev, width, height };
});
});
ro.observe(el);
return () => ro.disconnect();
}, [windowOpen, minimized]);
}, [windowOpen, minimized, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
// Ignore drags that originate on a button (minimize/close/new chat).
@@ -535,11 +499,20 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
<Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
{/* Always show the persisted "current / max" context. The denominator
(the admin-configured model limit) is appended only when known;
not clamped when current > max (shown as-is, e.g. "210k / 200k").
Hidden entirely until a turn has recorded a context figure. */}
{contextTokens > 0 ? (
<Tooltip label={t("Context size / model limit")} withArrow>
<span className={classes.badge}>
{formatTokens(contextTokens)}
{maxContextTokens > 0
? ` / ${formatTokens(maxContextTokens)}`
: ""}
</span>
</Tooltip>
)}
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -551,7 +524,11 @@ export default function AiChatWindow() {
aria-label={t("Copy chat")}
onClick={handleCopy}
>
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
{clipboard.copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)}
</button>
)}
<button
@@ -646,6 +623,7 @@ export default function AiChatWindow() {
) : (
<ChatThread
key={threadKey}
threadKey={threadKey}
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
@@ -657,6 +635,7 @@ export default function AiChatWindow() {
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
/>
)}
</div>

View File

@@ -55,6 +55,45 @@
padding-inline-start: 1.4em;
}
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
wide LLM table must scroll horizontally instead of collapsing its columns:
`.markdown` sets `word-break: break-word`, which (with the default table
layout) shrinks columns to a single glyph and wraps headers mid-word
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
give cells a readable minimum width, and restore word-boundary wrapping. */
.markdown table {
display: block;
/* lets the table scroll horizontally on its own */
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin-block-end: 0.5em;
}
.markdown th,
.markdown td {
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding: 3px 8px;
/* readable floor; the block scrolls when the row exceeds the panel */
min-width: 6em;
text-align: left;
vertical-align: top;
/* cancel the inherited break-word so words don't split mid-glyph */
word-break: normal;
/* still wrap genuinely long words / URLs at the cell edge */
overflow-wrap: break-word;
}
.markdown th {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
font-weight: 600;
}
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
.markdown table p {
margin: 0;
}
/* Animated three-dot "typing" indicator shown while the agent is thinking but
has not yet produced any visible text/tool parts. */
.typingDots {
@@ -111,6 +150,28 @@
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
/* Collapsible "Thinking" (reasoning) block: a subtle left rule, dimmer than the
answer so it reads as secondary thinking context above the real answer. */
.reasoningBlock {
border-left: 2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: 8px;
}
.reasoningText {
margin-top: 4px;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
/* 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 plain-text fallback <Text> that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */
}
.reasoningText p {
margin: 0 0 4px;
}
.inputWrapper {
flex: 0 0 auto;
padding-top: var(--mantine-spacing-xs);

View File

@@ -0,0 +1,49 @@
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
/**
* A classified AI chat error banner: a warning icon + bold heading on the first
* row, with the detail text spanning the full width below. Rendered for BOTH the
* live stream error (ChatThread) and a persisted assistant error (MessageItem),
* so this markup lives in one place. The detail is full-width (no hanging indent
* under the heading) so it wraps less and leaves no stranded icon / empty gap.
* The heading reuses Mantine's adaptive red "light" colour so it stays correct
* in dark mode. Layout-only props (mb/mt/...) are forwarded to the Alert root.
*/
interface ChatErrorAlertProps extends Omit<AlertProps, "title" | "children"> {
title: string;
detail: string;
}
export default function ChatErrorAlert({
title,
detail,
style,
...alertProps
}: ChatErrorAlertProps) {
// Mantine's own "light" alert colour, adaptive across light/dark schemes.
const accent = "var(--mantine-color-red-light-color)";
return (
// flexShrink: 0 keeps the banner fully visible. Mantine's Alert root is
// `overflow: hidden`, so as a flex child of the chat panel it can otherwise
// be compressed below its content height and clip the detail text; the
// scrollable message list absorbs the height pressure instead.
<Alert
{...alertProps}
variant="light"
color="red"
p="xs"
style={[{ flexShrink: 0 }, style]}
>
<Group gap={8} wrap="nowrap" align="center" mb={4}>
<IconAlertTriangle size={18} style={{ flex: "none", color: accent }} />
<Text fw={700} size="sm" lh={1.2} style={{ color: accent }}>
{title}
</Text>
</Group>
<Text size="sm" lh={1.4}>
{detail}
</Text>
</Alert>
);
}

View File

@@ -35,6 +35,10 @@ export default function ChatInput({
const [value, setValue] = useAtom(aiChatDraftAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
// keeps the stable batch path.
const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true;
const submit = (): void => {
const text = value.trim();
@@ -71,7 +75,7 @@ export default function ChatInput({
{isDictationEnabled && (
<MicButton
size="lg"
streaming
streaming={streamingDictation}
disabled={isStreaming || disabled}
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
/>

View File

@@ -0,0 +1,41 @@
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
import { IconPlayerStopFilled } from "@tabler/icons-react";
/**
* A neutral "turn was interrupted" notice (NOT an error). Rendered for an
* aborted turn — a manual Stop or a dropped connection — both live (ChatThread)
* and in reopened history (MessageItem). Deliberately gray/subtle so it reads as
* an informational marker, distinct from the red ChatErrorAlert. Layout-only
* props (mt/mb/...) are forwarded to the Alert root.
*/
interface ChatStoppedNoticeProps extends Omit<AlertProps, "title" | "children"> {
text: string;
}
export default function ChatStoppedNotice({
text,
style,
...alertProps
}: ChatStoppedNoticeProps) {
return (
<Alert
{...alertProps}
variant="light"
color="gray"
p="xs"
// flexShrink: 0 mirrors ChatErrorAlert so the notice is not compressed as a
// flex child of the chat panel.
style={[{ flexShrink: 0 }, style]}
>
<Group gap={8} wrap="nowrap" align="center">
<IconPlayerStopFilled
size={16}
style={{ flex: "none", color: "var(--mantine-color-dimmed)" }}
/>
<Text size="sm" lh={1.3} c="dimmed">
{text}
</Text>
</Group>
</Alert>
);
}

View File

@@ -1,18 +1,25 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import {
roleLaunchMessage,
shouldResetRolePicked,
} 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 {
dequeue,
enqueueMessage,
@@ -21,6 +28,14 @@ import {
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
// Throttle how often the streamed `messages` state triggers a re-render. Without
// it, useChat updates state on EVERY token, so the whole transcript's markdown
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
// into a quadratic CPU storm that pins the main thread and freezes the UI.
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
// from the token rate.
const STREAM_THROTTLE_MS = 50;
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -30,6 +45,11 @@ export interface OpenPageContext {
interface ChatThreadProps {
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
chatId: string | null;
/** This thread's mount key (the same value the parent uses as React `key`).
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
threadKey?: string;
/** Persisted rows to seed initial messages (existing chats only). */
initialRows?: IAiChatMessageRow[];
/** The page currently open in the workspace, or null on a non-page route.
@@ -48,9 +68,19 @@ interface ChatThreadProps {
/** Display name for the assistant label / typing line (the role name);
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
/** Called when a turn finishes; the parent refreshes the chat list and, for a
* new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
* finishing on a thread already abandoned by New chat mid-stream (#161). */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
}
/**
@@ -65,13 +95,18 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
? row.metadata.parts
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
const error = row.metadata?.error;
const finishReason = row.metadata?.finishReason;
const metadata: Record<string, unknown> = {};
if (error) metadata.error = error;
if (finishReason) metadata.finishReason = finishReason;
return {
id: row.id,
role,
parts,
// Carry a persisted turn error so MessageItem can render it after a remount
// (e.g. when a new chat adopts its id) and in reopened chat history.
...(error ? { metadata: { error } } : {}),
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
// can render the error banner / "stopped" marker after a remount and in
// reopened history.
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
} as UIMessage;
}
@@ -82,6 +117,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
*/
export default function ChatThread({
chatId,
threadKey,
initialRows,
openPage,
roleId,
@@ -89,6 +125,7 @@ export default function ChatThread({
onRolePicked,
assistantName,
onTurnFinished,
onServerChatId,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -217,6 +254,8 @@ export default function ChatThread({
id: chatStoreId,
messages: initialMessages,
transport,
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
experimental_throttle: STREAM_THROTTLE_MS,
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
@@ -225,44 +264,121 @@ export default function ChatThread({
// sending after the user hit Stop — or blindly retrying after a failure —
// would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
// session ignore this finish if it belongs to a thread abandoned by New chat
// mid-stream (#161).
onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
if (isAbort || isDisconnect || isError) return;
flushNext();
},
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
// Log the raw failure here for devtools; the UI shows a friendly classified
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
// the onFinish call) so a brand-new chat that fails its first turn is adopted
// and the chat list refreshes immediately rather than after a manual refresh.
// banner via `error` below. We still call `onTurnFinished()` with NO server id
// (idempotent with the onFinish call): for a brand-new chat that ARMS the
// bounded list-refetch fallback (adopt the single newly-appeared chat once the
// refetch lands); for an existing chat it just refreshes the chat list
// immediately rather than after a manual refresh.
onError: (streamError) => {
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
console.error("AI chat stream error:", streamError);
onTurnFinished();
onTurnFinished(undefined, threadKey);
},
});
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
// WHILE the first turn is still streaming and activeChatId-gated affordances
// (the Copy/export button) light up immediately, instead of only at onFinish.
// Keyed by the last-seen id so we forward each distinct id exactly once. The
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!onServerChatId) return;
const tail = messages[messages.length - 1];
if (tail?.role !== "assistant") return;
const serverChatId = extractServerChatId(tail);
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
return;
lastForwardedChatIdRef.current = serverChatId;
onServerChatId(serverChatId);
}, [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
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
// (`isDisconnect`) — a distinction only available live (the server persists
// both as finishReason 'aborted'). Cleared when the next turn starts.
const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
null,
);
const isStreaming = status === "submitted" || status === "streaming";
// Clear the stopped marker as soon as a new turn begins streaming.
useEffect(() => {
if (isStreaming) setStopNotice(null);
}, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong".
// of a generic "Something went wrong". Computed here (not only in the JSX) so
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)
// so the user can type the first message themselves. roleIdRef is already set,
// so that first manual message carries the roleId.
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
// Clicking a role card always binds the role to THIS new chat. Whether it also
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
// synchronously here because the parent's selectedRoleId state update would
// only reach roleIdRef on the next render — after this synchronous sendMessage
// has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
const launch = roleLaunchMessage(
role,
t("Take a look at the current document"),
);
if (launch !== null) {
sendMessage({ text: launch });
} else {
// autoStart=false -> bind only: hide the cards, show the composer.
setRolePickedNoSend(true);
}
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
// chatId null, so the thread never remounts and the flag would stay set, hiding
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
// change"): one-shot — it re-renders with the flag false and the guard no longer
// matches, so it cannot loop. (Review of #149.)
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
setRolePickedNoSend(false);
}
const showRoleCards =
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
@@ -276,17 +392,22 @@ export default function ChatThread({
assistantName={assistantName}
/>
{errorView && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mb="xs"
{errorView ? (
<ChatErrorAlert
title={errorView.title}
>
{errorView.detail}
</Alert>
)}
detail={errorView.detail}
mb="xs"
/>
) : stopNotice ? (
<ChatStoppedNotice
text={
stopNotice === "manual"
? t("Response stopped.")
: t("Connection lost — the answer was interrupted.")
}
mb="xs"
/>
) : null}
<Stack gap={0} className={classes.inputWrapper}>
{queued.length > 0 && (

View File

@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
// OTHER named export of markdown.ts intact via `importActual`, and override only
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
// still renders. This is the seam that proves the MarkdownPart memo works: a
// finalized text part must NOT be re-parsed on a later streamed delta.
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
import MessageItem from "./message-item";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} />
</MantineProvider>,
);
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
const callsFor = (text: string) =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
describe("MessageItem markdown memoization", () => {
it("does not re-parse finalized text parts when only a tail part grows", () => {
renderChatMarkdownSpy.mockClear();
// Two finalized text parts.
const first = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
const { rerender } = renderRow(first);
// Both finalized parts parsed exactly once on the initial render.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
// A streamed delta: a NEW message object where only a third tail part grows;
// the first two parts' text is byte-identical.
const next = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
{ type: "text", text: "gamm" },
]);
rerender(
<MantineProvider>
<MessageItem message={next} />
</MantineProvider>,
);
// The finalized parts hit the MarkdownPart memo: still parsed at most once
// each across BOTH renders (the resilient invariant). The only new parse is
// for the changed/added tail part.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next: importing the component module pulls in `useTranslation`,
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
// minimal `t` that echoes the key is enough. Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { arePropsEqual } from "./message-item";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
...over,
});
describe("arePropsEqual", () => {
it("returns false when showCitations differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { showCitations: false })),
).toBe(false);
});
it("returns false when neutralizeInternalLinks differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
).toBe(false);
});
it("returns false when assistantName differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
).toBe(false);
});
it("returns true on the identity fast path (same message object, equal props)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
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" }]);
expect(a).not.toBe(b);
expect(arePropsEqual(props(a), props(b))).toBe(true);
});
it("returns false when content changed in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
});

View File

@@ -1,11 +1,17 @@
import { Alert, Box, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { memo } from "react";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -30,6 +36,39 @@ interface MessageItemProps {
assistantName?: string;
}
/**
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
* primitives, so React.memo's default shallow compare is exactly right (the
* `text` string is compared by value).
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
<div
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{text}
</Text>
);
});
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.
@@ -37,12 +76,13 @@ interface MessageItemProps {
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
* User messages render their text as a right-aligned plain bubble.
*
* This component is intentionally NOT memoized: `useChat` replaces the streaming
* assistant message with a freshly cloned object on every streamed delta, so the
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
* per-message content signature: the streaming TAIL message's signature changes
* on each delta so it still re-renders and streams in, while finalized rows are
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
* long turn no longer re-parses the whole transcript on every token.
*/
export default function MessageItem({
function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
@@ -65,35 +105,52 @@ export default function MessageItem({
);
}
// An assistant message with nothing visible to render yet (an empty streaming
// text part, or a reasoning/step-start part while the model is still thinking)
// renders nothing here. The standalone TypingIndicator stands in for the nascent
// bubble (name + dots) until real content arrives, so exactly one element owns
// the agent name during the pre-content gap and the layout never jumps. Persisted
// errored/aborted turns DO have visible content per the helper (metadata.error /
// finishReason === "aborted"), so their banners below still render — this early
// return won't fire for them.
if (!assistantMessageHasVisibleContent(message)) return null;
// Authoritative reasoning token count to attribute to a reasoning block, or
// undefined when the block must estimate on its own. See reasoningTokensForPart
// for the #151 anti-double-count rule (only a single reasoning part may carry
// the turn total). The authoritative turn total is still surfaced live in the
// header badge regardless.
const reasoningTokens = reasoningTokensForPart(message);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{resolveAssistantName(assistantName) ?? t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "reasoning") {
// Reasoning ("thinking") -> a collapsible block with its own token
// count. Empty/whitespace reasoning with no authoritative count carries
// nothing to show, so skip it (avoids an empty 0-token block).
const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
return (
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
);
}
if (part.type === "text") {
// Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
key={index}
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{part.text}
</Text>
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
);
}
@@ -118,17 +175,52 @@ export default function MessageItem({
// cause plus a one-line detail.
const errorView = describeChatError(errorText, t);
return (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mt={4}
<ChatErrorAlert
title={errorView.title}
>
{errorView.detail}
</Alert>
detail={errorView.detail}
mt={4}
/>
);
})()}
{/* A persisted turn that was aborted (manual Stop or a dropped connection)
with no error banner. The server cannot tell a manual Stop from a
connection drop (both persist as finishReason 'aborted'), so reopened
history uses a combined wording. */}
{(() => {
const meta = message.metadata as
| { error?: string; finishReason?: string }
| undefined;
if (meta?.error || meta?.finishReason !== "aborted") return null;
return (
<ChatStoppedNotice
text={t("Response stopped (manually or the connection dropped).")}
mt={4}
/>
);
})()}
</Box>
);
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -5,6 +5,7 @@ import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -49,7 +50,9 @@ const BOTTOM_THRESHOLD = 40;
* assistant message's LAST part is not live output:
* - the last message is still the user's (assistant hasn't started a row), or
* - the assistant row has no parts yet, or
* - its last part is an empty/whitespace text part, or
* - its last part is an empty/whitespace text part, or a finished ("done")
* text part while the turn continues (the model paused after some narration
* and is thinking about its next step), or
* - its last part is a finished/errored tool (the model is thinking about the
* next step between tool calls).
* It hides only while output is actively rendering: a non-empty streaming text
@@ -63,7 +66,19 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
const lastPart = last.parts[last.parts.length - 1];
if (!lastPart) return true; // assistant row exists but has no parts yet.
// The answer text is actively streaming in -> MessageItem renders it; no dots.
if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false;
// Only while it is STILL streaming, though: once a non-empty text part is
// finalized ("done") but the turn is still in flight, the model has paused
// after some narration and is working on its next step (e.g. about to call a
// tool) — nothing is visibly progressing, so the dots must show. A text part
// without a `state` is treated as still-rendering (kept suppressed); this
// branch only runs while streaming, where live parts always carry a state.
if (
lastPart.type === "text" &&
lastPart.text.trim().length > 0 &&
(lastPart as { state?: "streaming" | "done" }).state !== "done"
) {
return false;
}
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
if (
isToolPart(lastPart.type) &&
@@ -77,6 +92,22 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
return true;
}
/**
* Whether the standalone typing indicator should render its own assistant-name
* label. The indicator OWNS the name while the tail assistant row has no visible
* content yet (an empty streaming text part, or reasoning/step-start while the
* model is still thinking): in that gap the assistant MessageItem renders nothing,
* so the indicator stands in for the nascent bubble (name + dots) at a constant
* gap. It hides the name only once that row shows visible content, because then
* MessageItem draws the same name — avoids a duplicate stacked label and the
* layout jump that switching owners mid-stream used to cause.
*/
export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return true;
return !assistantMessageHasVisibleContent(last);
}
/**
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
* but only while the user is pinned to the bottom — if they scrolled up to read
@@ -173,7 +204,12 @@ export default function MessageList({
assistantName={assistantName}
/>
))}
{typing && <TypingIndicator assistantName={assistantName} />}
{typing && (
<TypingIndicator
assistantName={assistantName}
showName={typingIndicatorShowsName(messages)}
/>
)}
</Stack>
</ScrollArea>
);

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// 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
// other component tests in the repo.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts && typeof opts.count === "number"
? key.replace("{{count}}", String(opts.count))
: key,
}),
}));
import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBlock(props: { text: string; tokens?: number }) {
return render(
<MantineProvider>
<ReasoningBlock {...props} />
</MantineProvider>,
);
}
describe("ReasoningBlock", () => {
it("shows the authoritative count in the header when tokens > 0", () => {
// Text "thinking…" estimates to ceil(9/4) = 3, but the authoritative 42
// must win, so the header shows 42 (and NOT the 3-token estimate).
renderBlock({ text: "thinking…", tokens: 42 });
expect(screen.getByText("Thinking · 42 tokens")).toBeDefined();
expect(screen.queryByText("Thinking · 3 tokens")).toBeNull();
});
it("falls back to the text-length estimate when no authoritative tokens", () => {
const text = "some reasoning prose that streams in";
const estimate = estimateTokens(text);
renderBlock({ text });
expect(estimate).toBeGreaterThan(0);
expect(screen.getByText(new RegExp(`${estimate} tokens`))).toBeDefined();
});
it("header-only when text is empty but an authoritative count is present", () => {
renderBlock({ text: "", tokens: 17 });
expect(screen.getByText(/17 tokens/)).toBeDefined();
// No disclosure body to expand: the toggle button is disabled.
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(true);
});
it("renders the reasoning body (markdown or raw-text fallback)", () => {
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// The toggle is enabled because there IS body text to expand.
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(false);
// The body prose renders (markdown -> sanitized html, or raw-text fallback);
// either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined();
});
});

View File

@@ -0,0 +1,98 @@
import { memo, useMemo, useState } from "react";
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
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 classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ReasoningBlockProps {
/** The streamed/persisted reasoning (thinking) text. May be empty when the
* provider reports only a reasoning token COUNT without the text. */
text: string;
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
* 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;
}
/**
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
* Code's surfacing of the model's thinking: a header that shows the thinking
* token count (authoritative when the step has reported usage, else a live
* estimate from the streamed text) and an expandable body with the reasoning
* prose. Collapsed by default so it never crowds out the answer.
*
* 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 }: 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();
// 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(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
<Box className={classes.reasoningBlock} mb={6}>
<UnstyledButton
onClick={() => setOpen((o) => !o)}
// No body to expand when the provider reported only a token count.
disabled={!trimmed}
aria-expanded={open}
>
<Group gap={6} wrap="nowrap" align="center">
<IconChevronDown
size={12}
style={{
transform: open ? "none" : "rotate(-90deg)",
transition: "transform 150ms ease",
opacity: trimmed ? 1 : 0.4,
}}
/>
<Text size="xs" c="dimmed">
{count > 0
? t("Thinking · {{count}} tokens", { count })
: t("Thinking")}
</Text>
</Group>
</UnstyledButton>
{trimmed && (
<Collapse in={open}>
{html ? (
<div
className={classes.reasoningText}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<Text
className={classes.reasoningText}
style={{ whiteSpace: "pre-wrap" }}
>
{trimmed}
</Text>
)}
</Collapse>
)}
</Box>
);
}
// 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);

View File

@@ -1,26 +1,10 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RoleCards from "./role-cards";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
// does not implement. Provide a minimal stub so the provider can render.
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
});
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const roles: IAiRole[] = [
{
@@ -29,6 +13,8 @@ const roles: IAiRole[] = [
emoji: "🏴‍☠️",
description: "Talks like a pirate",
enabled: true,
autoStart: true,
launchMessage: null,
},
{
id: "r2",
@@ -36,6 +22,8 @@ const roles: IAiRole[] = [
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
},
];

View File

@@ -82,4 +82,14 @@ describe("showTypingIndicator", () => {
showTypingIndicator([msg("assistant", [doneTool, text])], true),
).toBe(false);
});
it("shows while streaming after a text part is finalized (paused before the next step)", () => {
const doneText = { type: "text", text: "Now creating the page in", state: "done" } as unknown as UIMessage["parts"][number];
expect(showTypingIndicator([msg("assistant", [doneText])], true)).toBe(true);
});
it("hides while a text part is actively streaming (state: streaming)", () => {
const streamingText = { type: "text", text: "Now writ", state: "streaming" } as unknown as UIMessage["parts"][number];
expect(showTypingIndicator([msg("assistant", [streamingText])], true)).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { typingIndicatorShowsName } from "@/features/ai-chat/components/message-list.tsx";
/**
* Pure-helper tests for whether the standalone "Thinking…" indicator renders its
* own dimmed assistant-name label. The indicator OWNS the name while the tail
* assistant row has no visible content yet (an empty streaming text part, or
* reasoning/step-start while the model is still thinking) — in that gap the
* assistant MessageItem renders nothing, so the indicator stands in for the
* nascent bubble (name + dots). It hides the name only once the tail assistant
* row shows visible content, because then MessageItem draws the same name — this
* avoids a duplicate stacked label and the layout jump that switching owners
* mid-stream used to cause.
*/
const msg = (
role: "user" | "assistant",
parts: UIMessage["parts"],
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
describe("typingIndicatorShowsName", () => {
it("shows the name with no messages yet (standalone, just submitted)", () => {
expect(typingIndicatorShowsName([])).toBe(true);
});
it("shows the name when the last message is still the user's", () => {
expect(
typingIndicatorShowsName([msg("user", [{ type: "text", text: "q" }])]),
).toBe(true);
});
it("shows the name when the tail assistant row has no visible content yet (empty text part)", () => {
// The empty streaming text part has no visible content, so MessageItem renders
// nothing and the indicator owns the name (the nascent bubble).
expect(
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "" }])]),
).toBe(true);
});
it("hides the name once the tail assistant row shows content (a tool part)", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
typingIndicatorShowsName([msg("assistant", [doneTool])]),
).toBe(false);
});
it("hides the name once the tail assistant row shows content (non-empty text)", () => {
expect(
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "answer" }])]),
).toBe(false);
});
});

View File

@@ -10,6 +10,12 @@ interface TypingIndicatorProps {
* (agent role) name.
*/
assistantName?: string;
/**
* Whether to render the dimmed assistant-name label. Defaults to true
* (standalone behavior preserved). Set false between agent steps where the
* assistant row above already shows the same name, to avoid a duplicate label.
*/
showName?: boolean;
}
/**
@@ -20,28 +26,29 @@ interface TypingIndicatorProps {
*
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
* as the assistant's bubble taking shape. The dimmed label uses the configured
* identity name when provided (otherwise the generic "AI agent"), while the
* typing line is always the generic "Thinking…" (it never includes the
* role/identity name).
* identity name when provided (otherwise the generic "AI agent"); below it the
* animated dots stand in for the nascent bubble until content arrives.
*/
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
const { t } = useTranslation();
const name = resolveAssistantName(assistantName);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
</Text>
{showName !== false && (
// Extra bottom gap (vs MessageItem's mb={4}) gives the small bouncing
// dots room below the name label; without it they crowd the label. Only
// applies when the name is shown — the nameless case spaces fine on its own.
<Text size="xs" c="dimmed" mb={8}>
{name ?? t("AI agent")}
</Text>
)}
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">
<span />
<span />
<span />
</span>
<Text size="sm" c="dimmed">
{t("Thinking…")}
</Text>
</Group>
</Box>
);

View File

@@ -0,0 +1,290 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useChatSession } from "./use-chat-session";
import type { UseChatSessionOptions } from "./use-chat-session";
// The props the test drives: the parent-owned subset of UseChatSessionOptions
// (the spies are injected by setup, not per-render). messagesLoading is optional
// here (defaulted to false in setup) for terser test call sites.
type DriverProps = Pick<UseChatSessionOptions, "activeChatId" | "chats"> & {
messagesLoading?: boolean;
};
// Drive the hook the way the window does: the parent owns `activeChatId` and
// passes it back in. `setActiveChatId` is a spy so we can assert the EXACT id the
// hook adopts (the #137 regression: it must be the authoritative streamed id, not
// the newest chat in the list).
function setup(initial: DriverProps) {
const setActiveChatId = vi.fn();
const onInvalidateChatList = vi.fn();
const onInvalidateChatMessages = vi.fn();
const { result, rerender } = renderHook(
(props: DriverProps) =>
useChatSession({
activeChatId: props.activeChatId,
setActiveChatId,
chats: props.chats,
messagesLoading: props.messagesLoading ?? false,
onInvalidateChatList,
onInvalidateChatMessages,
}),
{ initialProps: initial },
);
return {
result,
rerender,
setActiveChatId,
onInvalidateChatList,
onInvalidateChatMessages,
};
}
describe("useChatSession", () => {
beforeEach(() => vi.clearAllMocks());
it("#137 REGRESSION LOCK: adopts the authoritative streamed id, NOT items[0]", () => {
// Brand-new chat, list already holds a SIBLING chat B as items[0] (a second
// tab just created it). The server streams the real id "A" for THIS chat.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "B" }] },
});
result.current.onTurnFinished("A");
// Must adopt the authoritative id, not the newest-in-list guess.
expect(setActiveChatId).toHaveBeenCalledWith("A");
expect(setActiveChatId).not.toHaveBeenCalledWith("B");
});
it("fallback adopt: arms on a server-id-less finish, adopts the single new id after refetch", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
// No server id => arm the fallback (no adoption yet).
result.current.onTurnFinished(undefined);
expect(setActiveChatId).not.toHaveBeenCalled();
// The refetch lands with the new row => adopt it.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
it("fallback ambiguous: two new ids appear => no adoption", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined);
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "n1" }, { id: "n2" }] },
});
expect(setActiveChatId).not.toHaveBeenCalled();
});
it("fallback add+delete in one window: adopts the new id (membership compare)", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "a" }, { id: "b" }] },
});
result.current.onTurnFinished(undefined);
// a was deleted, new was added — same length, but membership changed.
rerender({
activeChatId: null,
chats: { items: [{ id: "b" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
it("disarm on reconcile: a fallback armed then switched away is NOT adopted by a late refetch", () => {
// Arm the error-path fallback on a brand-new chat (snapshot before=["x"]).
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined);
// The user switches to an existing chat C BEFORE the refetch lands; the
// render-phase reconciler must DISARM the pending fallback.
rerender({ activeChatId: "C", chats: { items: [{ id: "x" }] } });
// ...then starts a fresh new chat again (back to null), without re-arming.
rerender({ activeChatId: null, chats: { items: [{ id: "x" }] } });
// A late refetch now brings a new row. Because the earlier fallback was
// disarmed on the switch (not left armed with the stale ["x"] snapshot), it
// must NOT be adopted. (Without the disarm this would wrongly adopt "new".)
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "new" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
});
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
// The Warning path the render-phase reconciler can't catch: pressing "New
// chat" while already in a new chat keeps activeChatId === null (a no-op for
// the atom), so only the explicit cancelPendingAdoption() disarms.
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
result.current.cancelPendingAdoption(); // window calls this from startNewChat
// The just-failed row lands in a late refetch; it must NOT be adopted.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "failed" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
});
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
const {
result,
setActiveChatId,
onInvalidateChatList,
onInvalidateChatMessages,
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
result.current.onTurnFinished("chat-1");
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
expect(onInvalidateChatList).toHaveBeenCalled();
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
});
it("double onTurnFinished on a failed-after-start turn: primary adopt, 2nd no-id call does NOT re-arm the fallback", () => {
// ai@6 fires onFinish AND onError on a failed turn. If the failure happened
// AFTER the `start` chunk, onFinish carries the streamed id and onError does
// not — so onTurnFinished runs twice in one turn (id, then no-id) before any
// re-render. The 2nd call must NOT re-arm the fallback off the still-null
// closure; otherwise a late refetch (parent hasn't reflected the adoption yet)
// would wrongly adopt a sibling row.
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished("A"); // onFinish: primary adoption
expect(setActiveChatId).toHaveBeenCalledWith("A");
result.current.onTurnFinished(undefined); // onError: same turn, no id
// Even in the worst case (the parent has NOT yet reflected activeChatId="A"
// and a late refetch lands a new row), the just-failed sibling must NOT be
// adopted. Two layers guarantee this: the ref guard keeps the 2nd call from
// re-arming at the source, and the render-phase reconciler disarms anything
// stale once thread.chatId ("A") diverges from the still-null activeChatId.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "late" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
});
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
// fires at the terminal outcome). The hook must adopt it immediately so the
// window's activeChatId-gated Copy/export button lights up during the stream.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
result.current.onServerChatId("A");
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
const chats = { items: [] };
const { result, rerender } = setup({ activeChatId: null, chats });
const keyBefore = result.current.threadKey;
result.current.onServerChatId("A");
// Parent reflects the adopted id back in; the SAME mount key is kept so the
// in-flight useChat store (the streaming turn) is preserved.
rerender({ activeChatId: "A", chats });
expect(result.current.threadKey).toBe(keyBefore);
});
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
const { result, setActiveChatId } = setup({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
});
result.current.onServerChatId("chat-1"); // already has an id
result.current.onServerChatId(undefined); // no streamed id
expect(setActiveChatId).not.toHaveBeenCalled();
});
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
const chats = { items: [{ id: "B" }] };
const { result, rerender } = setup({ activeChatId: null, chats });
const keyBefore = result.current.threadKey;
// Adopt the streamed id; the PARENT then reflects activeChatId="A" back in.
result.current.onTurnFinished("A");
rerender({ activeChatId: "A", chats });
// In-place adoption: SAME mount key (the live useChat store is preserved).
expect(result.current.threadKey).toBe(keyBefore);
// An EXTERNAL switch (not via adopt) to a different chat must remount: the
// key becomes the chat id.
rerender({ activeChatId: "C", chats });
expect(result.current.threadKey).toBe("C");
});
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
// Brand-new chat whose first turn is still streaming: the id is adopted only
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
// chat" must still remount to a clean thread even though the atom is unchanged
// — the render-phase reconciler (null === null) would otherwise do nothing,
// leaving the old chat/stream/history in place (the bug: only the role badge
// dropped).
const { result } = setup({ activeChatId: null, chats: { items: [] } });
const keyBefore = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(keyBefore);
});
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
// abort the abandoned stream on unmount: its onFinish still fires later with
// the real server id, tagged with the OLD (abandoned) mount key. That must not
// adopt — it would yank the user back into the chat they just left.
const { result, setActiveChatId, onInvalidateChatList } = setup({
activeChatId: null,
chats: { items: [] },
});
const abandonedKey = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(abandonedKey);
// The abandoned turn finishes in the background, streaming its real id "A".
result.current.onTurnFinished("A", abandonedKey);
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
// It still refreshes the chat list so the left-behind chat shows in history.
expect(onInvalidateChatList).toHaveBeenCalled();
});
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
// The happy path must keep working: onTurnFinished tagged with the mounted
// thread's own key adopts in place as before.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
const currentKey = result.current.threadKey;
result.current.onTurnFinished("A", currentKey);
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
// Open an existing chat whose history is still loading => loader on.
const { result, rerender } = setup({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
messagesLoading: true,
});
expect(result.current.waitingForHistory).toBe(true);
// Once loading finishes, the latch flips and the loader is off.
rerender({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
messagesLoading: false,
});
expect(result.current.waitingForHistory).toBe(false);
});
});

View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { generateId } from "ai";
import {
resolveAdoptedChatId,
newlyAddedChatIds,
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
import {
newThread,
switchThread,
threadSessionReducer,
} from "@/features/ai-chat/utils/thread-identity.ts";
/** Inputs to {@link useChatSession}. `activeChatId`/`setActiveChatId` are the
* public selection atom (also written from outside the window, e.g. page
* history); the rest is read-only context the hook needs. */
export interface UseChatSessionOptions {
activeChatId: string | null;
setActiveChatId: (id: string | null) => void;
chats: { items?: { id: string }[] } | undefined;
messagesLoading: boolean;
/** Wraps queryClient.invalidateQueries(AI_CHATS_RQ_KEY). */
onInvalidateChatList: () => void;
/** Wraps the per-chat messages invalidation. */
onInvalidateChatMessages: (chatId: string) => void;
}
/** What the window needs from a chat session: the ChatThread mount key, the
* history-loader gate, and the turn-finished callback. */
export interface UseChatSessionResult {
/** ChatThread mount key (was `thread.key`). */
threadKey: string;
/** Show the history loader instead of the live thread. */
waitingForHistory: boolean;
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
* even when `activeChatId` is unchanged. The window calls this from
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
* still streaming (activeChatId still null, nothing to diverge) actually
* resets the chat instead of only dropping the role badge (#161). */
startFreshThread: () => void;
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
* thread that produced the turn (omit => "current thread", back-compatible):
* a turn ABANDONED by New chat mid-stream still fires this after its thread
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
* new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
* button, #174) available immediately. In-place adoption only (same mount key,
* no list/messages invalidation — that is left to onTurnFinished at the end).
* Idempotent and a no-op once the chat already has an id. */
onServerChatId: (serverChatId?: string) => void;
/** Disarm any pending error-path new-chat fallback. The window calls this from
* startNewChat/selectChat so a late refetch can't yank the user back into a
* just-failed chat after they explicitly moved on. */
cancelPendingAdoption: () => void;
}
/** Project a chat list to its id array (the before/after snapshot for the
* error-path fallback). */
function chatIdSnapshot(
chats: { items?: { id: string }[] } | undefined,
): string[] {
return chats?.items?.map((c) => c.id) ?? [];
}
/**
* Owns the AI-chat thread-identity lifecycle: the single atomic thread identity,
* both new-chat id adoption paths (primary streamed-metadata + bounded error-path
* fallback), the history-loaded latch, and the render-phase reconciler that keeps
* the thread's mount key in sync with the public `activeChatId` atom.
*
* This is the twice-bugged area for the #137 two-tab adoption race; the canonical
* explanation of the adoption design lives in adopt-chat-id.ts.
*/
export function useChatSession(
params: UseChatSessionOptions,
): UseChatSessionResult {
const {
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList,
onInvalidateChatMessages,
} = params;
// Live mirror of `activeChatId`, read by onTurnFinished. ai@6 fires both
// onFinish AND onError on a failed turn, so onTurnFinished can run twice in one
// turn (once with the streamed id, once without) BEFORE a re-render. Reading
// the ref — which the primary-adoption branch updates imperatively — makes that
// second call see the just-adopted id, so it cannot re-arm the fallback. (A
// plain closure over `activeChatId` would still read null on the second call.)
const activeChatIdRef = useRef(activeChatId);
activeChatIdRef.current = activeChatId;
// The mounted thread's identity: ONE atomic value tying ChatThread's mount key
// (`thread.key`) to the chat id that mounted thread holds (`thread.chatId`).
// Consolidating these makes the "key vs chat id diverged" state unrepresentable
// — every change goes through an explicit transition (see thread-identity.ts):
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
// session key with no chat id yet.
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
);
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
// does not abort a stream on unmount and proxies callbacks through a ref, so an
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
// matching its key against this ref keeps that late finish from adopting the
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
const threadKeyRef = useRef(thread.key);
threadKeyRef.current = thread.key;
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
// reaches the client, so the primary metadata adoption cannot run. We then ARM
// this ref with a snapshot of the currently-known chat ids; once the list
// refetch lands with the just-created row, the fallback effect below adopts the
// SINGLE newly-appeared id. `null` = not armed. See adopt-chat-id.ts (#137).
const pendingNewChatRef = useRef<string[] | null>(null);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
// list, which races a second tab — #137; see adopt-chat-id.ts).
const onTurnFinished = useCallback(
(serverChatId?: string, finishingThreadKey?: string) => {
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
// not abort on unmount and proxies callbacks through a ref). If that late
// finish ran the adoption path it would set activeChatId to the abandoned
// chat's real id and yank the user out of the fresh chat they just opened.
// So adopt / arm the fallback ONLY for the still-mounted thread; an
// abandoned one merely refreshes the chat list (so the left-behind chat
// surfaces in history) and does nothing else. A missing key (undefined)
// means "current thread" — keeps old call sites/tests working.
if (
finishingThreadKey !== undefined &&
finishingThreadKey !== threadKeyRef.current
) {
onInvalidateChatList();
return;
}
// Read the live id from the ref, not the closure: on a failed turn this can
// run twice in one turn (onFinish + onError) before any re-render, and the
// primary branch below updates the ref so the second call sees the adopted id.
const current = activeChatIdRef.current;
const adopted = resolveAdoptedChatId(current, serverChatId);
if (adopted) {
// PRIMARY path. In-place adoption: set the public selection and the
// thread identity to the real id together. `adopt` keeps the SAME mount
// key, so the render-phase reconciler sees `activeChatId === thread.chatId`
// and keeps the SAME mounted thread (its useChat already holds the
// just-finished turn) instead of remounting + re-seeding from
// not-yet-persisted history.
activeChatIdRef.current = adopted; // a same-turn 2nd call now sees the id
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
// Primary adoption won — disarm any previously-armed fallback.
pendingNewChatRef.current = null;
} else if (current === null) {
// FALLBACK path: a brand-new chat finished with NO server id (the first
// turn errored before the `start` chunk). Arm the bounded list-refetch
// fallback by snapshotting the currently-known chat ids. `chats` is still
// the pre-refetch list here, so the just-created row is NOT yet in it; the
// effect below adopts the single id that newly appears after the refetch.
pendingNewChatRef.current = chatIdSnapshot(chats);
}
onInvalidateChatList();
// Re-sync the persisted message rows for the active chat so the Markdown
// export and token counters reflect the just-finished turn. The live thread
// renders from its own useChat store (stable thread.key), so this never
// re-seeds or tears down the open thread. For a brand-new chat `current` is
// still null here; later turns hit this with the adopted id.
if (current) {
onInvalidateChatMessages(current);
}
},
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
);
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
// the server emits it on the `start` chunk, so a brand-new chat gets its real
// `activeChatId` WHILE its first turn streams — not only at terminal
// onTurnFinished. This makes the activeChatId-gated Copy/export button
// available during the first turn. Pure in-place adoption (same mount key, like
// the primary path) with NO invalidation: the list/messages refresh stays on
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
// still-new chat).
const onServerChatId = useCallback(
(serverChatId?: string) => {
const adopted = resolveAdoptedChatId(
activeChatIdRef.current,
serverChatId,
);
if (!adopted) return;
activeChatIdRef.current = adopted;
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
// Early adoption beat the error-path fallback to it — disarm.
pendingNewChatRef.current = null;
},
[setActiveChatId],
);
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
// turn errored before the `start` chunk (no authoritative id streamed). Once
// the per-user list refetch lands with the just-created row, adopt the SINGLE
// id that newly appeared relative to the pre-refetch snapshot. Adoption is IN
// PLACE (set activeChatId + `adopt` together) like the primary path, so the
// render-phase reconciler does not remount.
useEffect(() => {
const before = pendingNewChatRef.current;
if (before === null || activeChatId !== null) return; // not armed / already adopted
const after = chatIdSnapshot(chats);
const added = newlyAddedChatIds(before, after);
// Keep waiting until a genuinely-new id appears. Set-based, so it is robust
// to an add+delete in the same window (a length compare would miss it), and
// it deliberately keeps waiting through an unrelated deletion (no new id yet)
// until the just-created row actually lands, rather than giving up early.
if (added.size === 0) return; // list not refetched yet — keep waiting
pendingNewChatRef.current = null; // resolved — disarm
if (added.size === 1) {
// single unambiguous new id; >1 = ambiguous → give up
const adopted = [...added][0];
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
}
}, [chats, activeChatId, setActiveChatId]);
// Reconcile the thread identity against the active-chat atom during render when
// they diverge — the React-sanctioned alternative to an effect (re-renders
// before paint, no extra commit, and converges since the next render finds them
// equal). This reconciliation MUST remain: `activeChatId` is the public
// selection and is ALSO set from OUTSIDE this component (e.g. page-history opens
// a referenced chat via setActiveChatId). A divergence here is a genuine SWITCH
// (external atom change OR user switch via selectChat/startNewChat), so
// `reconcile` remounts + reseeds. In-place adoption never reaches this branch:
// it set activeChatId and thread.chatId to the same value.
if (activeChatId !== thread.chatId) {
// A genuine switch makes any pending error-path new-chat fallback moot.
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: activeChatId,
newKey: `new-${generateId()}`,
});
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages invalidation,
// which can transiently flip hasNextPage for a chat whose message count is an
// exact multiple of the server page size) does not tear the live thread down to
// a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
thread.key === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so the live thread keeps rendering; and once a chat's history has loaded,
// a later background refetch no longer tears it down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
thread.key === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
// Explicit disarm for startNewChat/selectChat. The render-phase reconciler only
// disarms when activeChatId actually changes, but "New chat" pressed while the
// user is ALREADY in a new chat is a no-op for the atom (activeChatId stays
// null), so the reconciler never fires — without this an armed fallback could
// adopt the just-failed chat from a late refetch and yank the user out of their
// fresh chat. Stable identity (writes a ref).
const cancelPendingAdoption = useCallback(() => {
pendingNewChatRef.current = null;
}, []);
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
// so "New chat" pressed while a brand-new chat's first turn is still streaming
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
// end of the turn) is a no-op for it and the abandoned thread/stream/history
// would persist. Dispatching reconcile with a fresh key and chatId:null here
// always produces a new mount key, so React remounts ChatThread (a clean useChat
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
// keeps the reconciler from interfering. Also disarms any pending fallback.
const startFreshThread = useCallback(() => {
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: null,
newKey: `new-${generateId()}`,
});
}, []);
return {
threadKey: thread.key,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
};
}

View File

@@ -50,6 +50,24 @@ export async function deleteAiChat(chatId: string): Promise<void> {
await api.post("/ai-chat/delete", { chatId });
}
/**
* Export a chat to Markdown (#183). The server renders the transcript from the
* persisted rows (the DB is the single source of truth — including an
* interrupted turn's in-progress row, persisted upfront + per step), so the
* client just copies the returned string. `lang` localizes the few fixed
* role/tool labels; defaults to English server-side when omitted.
*/
export async function exportAiChat(
chatId: string,
lang?: string,
): Promise<string> {
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
chatId,
lang,
});
return req.data.markdown;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only
@@ -76,6 +94,8 @@ export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
/** Soft-delete a role (admin). */
export async function deleteAiRole(id: string): Promise<{ success: true }> {
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
id,
});
return req.data;
}

View File

@@ -53,6 +53,10 @@ export interface IAiRole {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled: boolean;
// Whether picking the role auto-sends a launch message and starts the chat.
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
createdAt?: string;
updatedAt?: string;
}
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/** Admin update payload for a role (partial). */
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/**
@@ -98,15 +106,27 @@ export interface IAiChatMessageRow {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
// Reasoning (thinking) tokens, when the provider reports them. Optional so
// old history rows (recorded before this shipped) stay valid. Included in
// `outputTokens` per the AI SDK usage shape.
reasoningTokens?: number;
};
// Current context size for the turn = final-step (input+output) tokens, i.e.
// how much the conversation occupies in the model's context window after this
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
// floating window's header badge.
contextTokens?: number;
// The model's max context window (denominator for the header badge); set
// alongside contextTokens on a completed turn; absent on older rows.
maxContextTokens?: number;
// Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread.
error?: string;
// Terminal outcome of the assistant turn: 'error' (provider/stream error,
// paired with `error`), 'aborted' (client disconnect — a manual Stop or a
// dropped connection), or the SDK's finish reason on a clean turn. The UI
// renders a "stopped" marker on interrupted turns.
finishReason?: string;
} | null;
createdAt: string;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import {
resolveAdoptedChatId,
newlyAddedChatIds,
extractServerChatId,
} from "./adopt-chat-id";
describe("resolveAdoptedChatId", () => {
it("adopts the server id for a brand-new chat (activeChatId null + id)", () => {
expect(resolveAdoptedChatId(null, "chat-1")).toBe("chat-1");
});
it("returns null for an existing chat even with a server id", () => {
expect(resolveAdoptedChatId("chat-existing", "chat-1")).toBeNull();
});
it("returns null for a new chat with no server id", () => {
expect(resolveAdoptedChatId(null, undefined)).toBeNull();
expect(resolveAdoptedChatId(null, null)).toBeNull();
});
});
describe("newlyAddedChatIds", () => {
it("returns the single new id", () => {
expect([...newlyAddedChatIds(["a", "b"], ["a", "b", "c"])]).toEqual(["c"]);
});
it("returns an empty set when nothing was added", () => {
expect(newlyAddedChatIds(["a", "b"], ["b", "a"]).size).toBe(0);
});
it("returns both new ids when two were added", () => {
expect(newlyAddedChatIds(["a"], ["a", "b", "c"])).toEqual(
new Set(["b", "c"]),
);
});
it("keeps only the new id across an add+delete in the same window", () => {
// before [a,b] -> after [b,new]: a was deleted, new was added.
expect([...newlyAddedChatIds(["a", "b"], ["b", "new"])]).toEqual(["new"]);
});
it("dedupes a repeated new id to a single entry", () => {
expect(newlyAddedChatIds(["a"], ["a", "new", "new"])).toEqual(
new Set(["new"]),
);
});
});
describe("extractServerChatId", () => {
it("returns the chatId when present on metadata", () => {
expect(extractServerChatId({ metadata: { chatId: "chat-1" } })).toBe(
"chat-1",
);
});
it("returns undefined when the message has no metadata", () => {
expect(extractServerChatId({})).toBeUndefined();
});
it("returns undefined when metadata lacks chatId", () => {
expect(extractServerChatId({ metadata: { other: 1 } })).toBeUndefined();
});
it("returns undefined for a non-string chatId", () => {
expect(extractServerChatId({ metadata: { chatId: 42 } })).toBeUndefined();
});
it("returns undefined for an undefined message", () => {
expect(extractServerChatId(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,70 @@
/**
* Pure helpers for adopting a brand-new chat's authoritative server id.
*
* ============================ CANONICAL #137 NOTE ============================
* This docblock is the single authoritative explanation of the new-chat id
* adoption design and the #137 two-tab race it fixes. Other call sites
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
* rather than restating it.
*
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
* id yet (`activeChatId === null`). The server creates the row and the client
* must "adopt" that row's real id so the SECOND turn targets the same chat.
*
* The OLD heuristic adopted `items[0]` — the newest chat in the refetched list.
* That races a second tab: if another tab created a chat in the same moment,
* its row could be `items[0]`, so this tab would adopt the SIBLING chat and
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
*
* PRIMARY path: the server streams the real chat id on the assistant message
* metadata's `start` part (see `chatStreamMetadata` server-side);
* `extractServerChatId` reads it off the finished message and
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
* authoritative and immune to the race.
*
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
* chunk, so no metadata id ever reached the client): adopt the single chat that
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
* new). This is unambiguous and does not race a second tab the way the old
* "newest chat in the list" guess did.
* ============================================================================
*/
/**
* Resolve the id to adopt from the server-streamed metadata. Returns
* `serverChatId` only for a brand-new chat (`activeChatId === null`) that
* received a truthy id; otherwise null (existing chat, or no id streamed).
*/
export function resolveAdoptedChatId(
activeChatId: string | null,
serverChatId: string | null | undefined,
): string | null {
return activeChatId === null && serverChatId ? serverChatId : null;
}
/**
* Read the authoritative server chat id off a finished assistant message. The
* server attaches it as `message.metadata.chatId` on the `start` part (see
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
* a missing message, missing metadata, or a non-string `chatId`.
*/
export function extractServerChatId(
message: { metadata?: unknown } | undefined,
): string | undefined {
const m = message?.metadata as { chatId?: string } | undefined;
return typeof m?.chatId === "string" ? m.chatId : undefined;
}
/**
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
* chat must not read as multiple from a duplicate.
*/
export function newlyAddedChatIds(
beforeIds: readonly string[],
afterIds: readonly string[],
): Set<string> {
const before = new Set(beforeIds);
return new Set(afterIds.filter((id) => !before.has(id)));
}

View File

@@ -1,317 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Tests for the client-only Markdown export builder. The output embeds a live
* `new Date().toISOString()` export timestamp; we never assert that value, only
* the deterministic structure (headings, numbering, fenced blocks, totals).
*
* A pass-through translator keeps role/tool labels predictable so the
* structural assertions are stable without an i18n runtime.
*/
const t = (key: string, values?: Record<string, unknown>): string => {
if (values && typeof values.name === "string") {
return key.replace("{{name}}", values.name);
}
return key;
};
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
return {
id: partial.id ?? "id",
role: partial.role ?? "user",
content: partial.content ?? null,
metadata: partial.metadata ?? null,
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
};
}
describe("buildChatMarkdown — structure", () => {
it("emits the title heading, chat id and message count", () => {
const md = buildChatMarkdown({
title: "My chat",
chatId: "chat-123",
rows: [],
t,
});
expect(md).toContain("# My chat");
expect(md).toContain("- Chat ID: `chat-123`");
expect(md).toContain("- Messages: 0");
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
});
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
expect(
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
expect(
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
});
it("numbers rows sequentially with role headings", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({ role: "assistant", content: "hello" }),
row({ role: "user", content: "again" }),
],
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("## 3. You");
// Heading numbering is strictly index+1, not e.g. role-relative.
expect(md).not.toContain("## 0.");
});
it("renders the per-row text content from `content` when no metadata.parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "plain body" })],
t,
});
expect(md).toContain("plain body");
});
});
describe("buildChatMarkdown — text parts", () => {
it("skips empty / whitespace-only text parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "ignored-content",
metadata: {
parts: [
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "kept line" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
},
}),
],
t,
});
expect(md).toContain("kept line");
// Whitespace-only part contributed no block of its own.
expect(md).not.toContain(" \n\n");
// When metadata.parts exists, the plain `content` fallback is NOT used.
expect(md).not.toContain("ignored-content");
});
});
describe("buildChatMarkdown — tool parts", () => {
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: { pageId: "p1" },
output: { id: "p1", title: "Home" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// Known tool name maps to its label key; raw name in backticks; done state.
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
expect(md).toContain("Input:");
expect(md).toContain("Output:");
// Fenced JSON blocks contain the stringified payloads.
expect(md).toContain('"pageId": "p1"');
expect(md).toContain('"title": "Home"');
expect(md).toContain("```json");
});
it("renders the generic label for an unknown tool and surfaces errorText", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-mysteryTool",
state: "output-error",
input: { a: 1 },
errorText: "boom",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
expect(md).toContain("**Error:** boom");
});
it("does not throw on a circular tool input (falls back to String)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circular: any = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "input-available",
input: circular,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
}),
).not.toThrow();
});
});
describe("buildChatMarkdown — fence anti-breakout", () => {
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
// Tool input whose stringified string form contains a literal ``` run.
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
// A bare string passes through stringify() verbatim.
input: "before ``` after",
output: "x",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// The fence around the 3-backtick content must use at least 4 backticks so
// the embedded ``` run cannot terminate the block.
expect(md).toContain("````json\nbefore ``` after\n````");
// Robust anti-breakout check: the opening fence delimiter is strictly
// longer than the longest backtick run inside the wrapped content. (A naive
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
// textually contains the 3-backtick substring.)
const open = md.match(/(`{3,})json\nbefore/);
expect(open).not.toBeNull();
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
});
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: "a ```` b",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("`````json\na ```` b\n`````");
});
});
describe("buildChatMarkdown — token totals", () => {
it("prints the total-tokens line only when the summed usage is > 0", () => {
const withTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(withTokens).toContain("- Total tokens: 15");
// Per-row usage footer too.
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
});
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
const noTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
}),
],
t,
});
expect(noTokens).not.toContain("- Total tokens:");
});
it("uses totalTokens when present rather than summing in/out", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
}),
],
t,
});
expect(md).toContain("- Total tokens: 99");
});
});

View File

@@ -1,165 +0,0 @@
/**
* Client-only Markdown builder for an AI agent chat. Serializes the already
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
* Markdown string suitable for copying to the clipboard. NO network call is
* made and NO server/DB code is touched — this reuses the rich "request
* internals" (tool calls with input/output, per-message token usage,
* finish/error info) that the chat already holds client-side.
*
* Only role labels and tool action labels are localized via the passed-in `t`
* translator; the structural document words (Input/Output/Error/Tokens/...) are
* plain English constants because the output is a technical artifact.
*/
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import {
ToolUiPart,
getToolName,
toolRunState,
toolLabelKey,
} from "@/features/ai-chat/utils/tool-parts.tsx";
// Minimal translator signature compatible with react-i18next's `t`.
type Translate = (key: string, values?: Record<string, unknown>) => string;
interface BuildChatMarkdownArgs {
title: string | null;
chatId: string;
rows: IAiChatMessageRow[];
t: Translate;
}
/** A single AI SDK UIMessage part (text part or other). */
interface TextLikePart {
type: string;
text?: string;
}
/**
* Stringify an arbitrary tool input/output value for a fenced block. Strings
* pass through as-is; everything else is pretty-printed JSON, falling back to
* `String(value)` if serialization throws (e.g. a circular structure).
*/
function stringify(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
* the longest backtick run inside the content, so embedded backticks (or even
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
*/
function fence(code: string, lang = ""): string {
const runs: string[] = code.match(/`+/g) ?? [];
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
const delim = "`".repeat(Math.max(3, longest + 1));
return `${delim}${lang}\n${code}\n${delim}`;
}
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
function rowTokens(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
}): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
);
}
/**
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
* export timestamp), so it is straightforward to unit-test.
*/
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const { title, chatId, rows, t } = args;
const blocks: string[] = [];
const heading = (title ?? "").trim() || t("Untitled chat");
blocks.push(`# ${heading}`);
// Metadata bullet list. Total tokens is only shown when there is a sum.
const totalTokens = rows.reduce((sum, row) => {
const usage = row.metadata?.usage;
return usage ? sum + rowTokens(usage) : sum;
}, 0);
const meta = [
`- Chat ID: \`${chatId}\``,
`- Exported: ${new Date().toISOString()}`,
`- Messages: ${rows.length}`,
];
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
blocks.push(meta.join("\n"));
rows.forEach((row, index) => {
blocks.push("---");
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
blocks.push(`## ${index + 1}. ${roleLabel}`);
// Created-at kept in source as an HTML comment (out of the rendered prose).
blocks.push(`<!-- ${row.createdAt} -->`);
// Resolve parts: prefer the rich persisted parts, else a single text part
// built from the plain-text content (mirrors `rowToUiMessage`).
const parts: TextLikePart[] =
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
? (row.metadata.parts as TextLikePart[])
: [{ type: "text", text: row.content ?? "" }];
for (const part of parts) {
if (part.type === "text") {
const text = (part.text ?? "").trim();
// Skip empty/whitespace-only text parts (matches MessageItem).
if (text.length > 0) blocks.push(text);
continue;
}
const isToolPart =
part.type.startsWith("tool-") || part.type === "dynamic-tool";
if (!isToolPart) continue;
const tp = part as unknown as ToolUiPart;
const name = getToolName(tp);
const { key, values } = toolLabelKey(name);
const label = t(key, values);
const state = toolRunState(tp.state);
const toolLines: string[] = [
`**Tool: ${label}** (\`${name}\`) — ${state}`,
];
if (tp.input !== undefined) {
toolLines.push("Input:");
toolLines.push(fence(stringify(tp.input), "json"));
}
if (tp.output !== undefined) {
toolLines.push("Output:");
toolLines.push(fence(stringify(tp.output), "json"));
}
if (tp.errorText) {
toolLines.push(`**Error:** ${tp.errorText}`);
}
blocks.push(toolLines.join("\n\n"));
}
if (row.metadata?.error) {
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
}
const usage = row.metadata?.usage;
if (usage) {
const total = usage.totalTokens ?? rowTokens(usage);
blocks.push(
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
);
}
});
// Blank line between blocks so the Markdown renders cleanly.
return blocks.join("\n\n");
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
describe("collapseBlankLines", () => {
it("collapses a run of 2+ newlines to a single newline", () => {
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
});
it("keeps single newlines untouched", () => {
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
});
it("preserves blank lines INSIDE a fenced code block", () => {
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
// Prose blanks collapse; the blank lines between the ``` fences survive.
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
});
it("handles a tilde fence and preserves its interior blanks", () => {
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
});
it("leaves an unclosed fence's remaining lines verbatim", () => {
const src = "intro\n\n```\nstill\n\nopen";
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
});
it("is a no-op for text with no blank lines", () => {
expect(collapseBlankLines("just one line")).toBe("just one line");
});
});
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
const loose =
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
expect(html).toContain("<li>item one</li>");
expect(html).not.toContain("<li><p>");
// The list still parses as a list after the paragraph (not a paragraph+<br>).
expect(html).toContain("<ul>");
expect(html).toContain("<p>Intro paragraph.</p>");
});
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
const loose = "Intro.\n\n1. first\n\n2. second";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
expect(html).toContain("<ol>");
expect(html).toContain("<li>first</li>");
expect(html).not.toContain("<li><p>");
});
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
const loose = "- a\n\n- b";
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
});
});

View File

@@ -0,0 +1,56 @@
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
/**
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
*
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
* normal assistant answer (where paragraph spacing is intentional).
*
* Fenced code is preserved verbatim: a fence opens on a line whose first
* non-space characters are ``` or ~~~ and closes on the next line that starts
* with the same fence character. Blank lines between fences (significant for
* code formatting) are never collapsed.
*/
export function collapseBlankLines(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let inFence = false;
let fenceChar = "";
for (const line of lines) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const ch = fenceMatch[1][0];
if (!inFence) {
inFence = true;
fenceChar = ch;
} else if (ch === fenceChar) {
inFence = false;
}
out.push(line);
continue;
}
// Inside a fenced block every line (including blanks) is significant.
if (inFence) {
out.push(line);
continue;
}
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
// `\n` between the surrounding content lines.
if (line.trim() === "") continue;
out.push(line);
}
return out.join("\n");
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
describe("estimateTokens", () => {
it("returns 0 for the empty string", () => {
expect(estimateTokens("")).toBe(0);
});
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
expect(estimateTokens("a")).toBe(1);
expect(estimateTokens("abcd")).toBe(1);
expect(estimateTokens("abcde")).toBe(2);
expect(estimateTokens("12345678")).toBe(2);
});
});

View File

@@ -0,0 +1,19 @@
/**
* Rough client-side token estimation for AI-chat UI affordances.
*
* No provider streams exact per-token usage mid-stream, so any in-flight figure
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
* ("Thinking · N tokens").
*/
/**
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
* non-empty text counts as at least one token.
*/
export function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
/**
* Pure-helper tests for `assistantMessageHasVisibleContent`, the single source of
* truth shared by MessageItem (whether to render the bubble) and
* typingIndicatorShowsName (whether the standalone indicator owns the name). It
* must mirror MessageItem's render decisions exactly so exactly one element owns
* the agent name during the pre-content "thinking" gap.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("assistantMessageHasVisibleContent", () => {
it("is false for an empty text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }]))).toBe(false);
});
it("is false for a whitespace-only text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: " " }]))).toBe(false);
});
it("is true for a non-empty text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "answer" }]))).toBe(true);
});
it("is true for a tool part", () => {
const toolPart = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([toolPart]))).toBe(true);
});
it("is true when metadata.error is set (persisted error banner)", () => {
expect(
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { error: "boom" })),
).toBe(true);
});
it("is true when metadata.finishReason is 'aborted' (persisted stopped notice)", () => {
expect(
assistantMessageHasVisibleContent(msg([], { finishReason: "aborted" })),
).toBe(true);
});
it("is false for a message with no parts and no metadata", () => {
expect(assistantMessageHasVisibleContent(msg([]))).toBe(false);
});
it("is false for an unsupported part kind (reasoning)", () => {
const reasoning = { type: "reasoning", text: "let me think" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([reasoning]))).toBe(false);
});
it("is true for a running tool part (input-available)", () => {
// Tool visibility does not depend on tool state: MessageItem renders a
// ToolCallCard for any tool part, so a still-running tool is visible.
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([runningTool]))).toBe(true);
});
it("is true for an empty leading text part followed by a non-empty one", () => {
// An empty leading text part followed by a non-empty one is still visible
// (mirrors the real streaming sequence where text arrives incrementally).
expect(
assistantMessageHasVisibleContent(
msg([{ type: "text", text: "" }, { type: "text", text: "answer" }]),
),
).toBe(true);
});
it("is false for an empty completed turn (finishReason 'stop')", () => {
// A completed turn with no text/tools and a non-aborted finishReason renders
// nothing — this is intentional (hiding a dangling name-only row), distinct
// from the `aborted`/`error` cases which DO render.
expect(
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { finishReason: "stop" })),
).toBe(false);
});
it("is false for a parts-less message (the `?? []` guard makes it safe)", () => {
// The `?? []` guard makes a parts-less object safe instead of throwing.
expect(
assistantMessageHasVisibleContent({ id: "x", role: "assistant" } as unknown as UIMessage),
).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import type { UIMessage } from "@ai-sdk/react";
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
/**
* Whether an assistant `UIMessage` has anything visible to render in its bubble.
*
* This mirrors MessageItem's render decisions EXACTLY and is the single source of
* truth shared by both MessageItem (to decide whether to render the bubble at all)
* and typingIndicatorShowsName (to decide whether the standalone "Thinking…"
* indicator owns the dimmed agent-name label). Keeping one helper guarantees the
* two stay in lockstep, so exactly one element owns the name during the pre-content
* "thinking" gap and the layout never reflows mid-stream.
*
* An assistant message has visible content iff ANY of:
* - a `text` part whose trimmed length > 0 (non-empty markdown), OR
* - ANY tool part (`isToolPart(part.type)`), OR
* - `metadata.error` is truthy (a persisted error banner renders), OR
* - `metadata.finishReason === "aborted"` (a persisted "response stopped" notice).
* Empty/whitespace-only text parts and unsupported part kinds (reasoning, sources,
* files, step-start) are NOT visible.
*/
export function assistantMessageHasVisibleContent(message: UIMessage): boolean {
const meta = message.metadata as
| { error?: string; finishReason?: string }
| undefined;
// Persisted errored/aborted turns always render their banner/notice.
if (meta?.error) return true;
if (meta?.finishReason === "aborted") return true;
// `parts` may be empty (a nascent streaming message has no parts yet).
// `?? []` also guards a sparse/partial message object (metadata-only, no
// `parts`) so iterating cannot throw — it does not change behavior for any
// current input.
for (const part of message.parts ?? []) {
if (part.type === "text" && part.text.trim().length > 0) return true;
if (isToolPart(part.type)) return true;
}
return false;
}

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Pure-helper tests for `messageSignature`, the cheap per-message content
* signature that drives MessageItem's memo (a streaming row's signature must
* change on every delta so it re-renders, while a finalized row's stays stable
* so it is skipped). Each test exercises ONE change signal and asserts it flips
* the signature; a content-identical clone must keep an EQUAL signature.
*
* The signature embeds `message.id` and `message.role`, so the `msg` factory
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
* identical content would get different signatures and the negative case would
* be impossible to express.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: "m1",
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("messageSignature", () => {
it("changes when a text part grows", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([{ type: "text", text: "alpha beta" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a new part is appended", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part's state flips", () => {
const before = msg([
{ type: "tool-getPage", state: "input-streaming" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a tool part gains an output", () => {
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { ok: true },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part gains an errorText", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "boom",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
// The specifically-commented edge case: the authoritative turn total lands on
// the final finish-step AFTER the reasoning text length and state are frozen.
// Only the token count appears between these two snapshots, so the signature
// MUST still flip — otherwise the "Thinking · N tokens" header would never
// snap from the live estimate to the exact figure.
const before = msg([
{ type: "reasoning", text: "thinking", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "thinking", state: "done" } as never],
{ usage: { reasoningTokens: 42 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.error appears", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
// A finalized row that is re-created as a fresh object (different parts array
// by reference, same parts by value) must keep an EQUAL signature, so the
// memo skips re-rendering it.
const a = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
const b = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
expect(a).not.toBe(b);
expect(messageSignature(a)).toBe(messageSignature(b));
});
});
/**
* Per-part-kind coupling guard for the load-bearing invariant documented at the
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
* MessageItem render body draws, or the memo freezes a stale row. This is an
* executable lock for the part kinds rendered TODAY — read alongside
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
* helper (message-content.ts), which "mirrors MessageItem's render decisions
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
* signature. If a new visible field is rendered without being added here AND to
* the signature, the corresponding assertion below should fail — that is the
* guard. (This intentionally stops short of the render-descriptor refactor:
* adding a part kind or a visible field still requires a human to extend both
* the signature and this block.)
*/
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
it("flips when the visible text changes", () => {
// Streaming is append-only, so the visible text only grows; the signature
// samples its length, so the growth is the change signal.
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer extended" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
it("flips when the visible reasoning text changes", () => {
const before = msg([
{ type: "reasoning", text: "think", state: "streaming" } as never,
]);
const after = msg([
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
// finish-step after text length and state are frozen.
const before = msg([
{ type: "reasoning", text: "think", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "think", state: "done" } as never],
{ usage: { reasoningTokens: 99 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
it("flips when the run state changes (running ↔ done icon + label)", () => {
// toolRunState(part.state) selects the spinner/check/error icon.
const before = msg([
{ type: "tool-getPage", state: "input-available" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when output arrives (drives the rendered citation links)", () => {
// toolCitations reads part.output to render the "/p/{id}" anchors.
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { id: "page-1", title: "Doc" },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when errorText appears (the visible red error detail line)", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "permission denied",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("metadata banners — render draws error / aborted notices", () => {
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
});

View File

@@ -0,0 +1,44 @@
import type { UIMessage } from "@ai-sdk/react";
/** Cheap content signature for one message: changes iff something VISIBLE in the
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
* appended, a tool/text part flips state once), so a per-part [type, text
* length, state, error/output presence] tuple + the persisted metadata
* (error/finishReason) is a sufficient change signal without comparing full
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
* text length, state, error/output presence] (e.g. a tool that streams
* `preliminary` output, or a client-side regenerate that edits a finalized
* row in place), extend this signature or the memo will freeze a stale row. */
export function messageSignature(message: UIMessage): string {
const parts = message.parts
.map((p) => {
const any = p as {
type: string;
text?: string;
state?: string;
errorText?: string;
output?: unknown;
};
return [
any.type,
any.text?.length ?? 0,
any.state ?? "",
any.errorText ? 1 : 0,
any.output !== undefined ? 1 : 0,
].join(":");
})
.join("|");
const meta = message.metadata as
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
| undefined;
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
// state are already frozen. Without it in the signature the row's signature would be
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
// to the exact figure.
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
meta?.finishReason ?? ""
}#${meta?.usage?.reasoningTokens ?? ""}`;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
/**
* Pure-helper tests for `reasoningTokensForPart`, the #151 anti-double-count
* rule: the authoritative `usage.reasoningTokens` is the TURN TOTAL, so it may
* only be attributed when the turn has exactly one reasoning part. With multiple
* reasoning parts (or no authoritative usage) every part falls back to its own
* per-part estimate, signalled here by `undefined`.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("reasoningTokensForPart", () => {
it("single reasoning part -> the authoritative turn total", () => {
const m = msg(
[
{ type: "reasoning", text: "thinking…" } as never,
{ type: "text", text: "answer" },
],
{ usage: { reasoningTokens: 42 } },
);
expect(reasoningTokensForPart(m)).toBe(42);
});
it("multiple reasoning parts -> undefined (each estimates on its own)", () => {
const m = msg(
[
{ type: "reasoning", text: "step one" } as never,
{ type: "reasoning", text: "step two" } as never,
{ type: "text", text: "answer" },
],
{ usage: { reasoningTokens: 99 } },
);
// Even with an authoritative total, two reasoning parts must each estimate
// (attributing the total to one would double-count against the other).
expect(reasoningTokensForPart(m)).toBeUndefined();
});
it("no authoritative usage -> undefined even for a single reasoning part", () => {
const m = msg([
{ type: "reasoning", text: "thinking…" } as never,
{ type: "text", text: "answer" },
]);
expect(reasoningTokensForPart(m)).toBeUndefined();
});
});

View File

@@ -0,0 +1,34 @@
import type { UIMessage } from "@ai-sdk/react";
/**
* Decide the authoritative reasoning token count to attribute to a single
* `reasoning` part of an assistant message — or `undefined` when the part should
* fall back to its own per-part estimate.
*
* `usage.reasoningTokens` is the TURN TOTAL, so it may only be attributed to a
* block when the turn has exactly ONE reasoning part (the common one-step turn):
* then that block can show the exact figure. With MULTIPLE reasoning parts (a
* multi-step agent turn) every block must fall back to its own estimate —
* attributing the turn total to one of them would double-count against the
* others' estimates (#151 review anti-double-count rule). When there is no
* authoritative usage at all, every part estimates.
*
* Returns the authoritative `reasoningTokens` only for the single-reasoning-part
* case; `undefined` otherwise (the caller estimates from the part text).
*/
export function reasoningTokensForPart(
message: UIMessage,
): number | undefined {
const reasoningTokens = (
message.metadata as { usage?: { reasoningTokens?: number } } | undefined
)?.usage?.reasoningTokens;
const reasoningPartCount = (message.parts ?? []).reduce(
(acc, p) => (p.type === "reasoning" ? acc + 1 : acc),
0,
);
// Exactly one reasoning part -> attribute the authoritative turn total to it.
// Otherwise (zero or multiple) each part estimates on its own.
return reasoningPartCount === 1 ? reasoningTokens : undefined;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
const DEFAULT = "Take a look at the current document";
// Covers the three-way handleRolePick behavior (issue #149) without mounting the
// chat-thread component — the logic lives in these pure helpers.
describe("roleLaunchMessage", () => {
it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
expect(
roleLaunchMessage(
{ autoStart: true, launchMessage: " Draft a plan " },
DEFAULT,
),
).toBe("Draft a plan");
});
it("autoStart=true + empty launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=true + null launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
expect(
roleLaunchMessage(
{ autoStart: false, launchMessage: "ignored" },
DEFAULT,
),
).toBeNull();
expect(
roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
).toBeNull();
});
});
// Regression guard for #149: the "picked, not sent" flag must reset when the
// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
// was no reset, so the flag stayed stuck and the role cards never returned —
// this is exactly the `true` case below (which the old code never acted on).
describe("shouldResetRolePicked", () => {
it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
// chatId still null, roleId cleared by the parent, flag stuck -> reset.
expect(shouldResetRolePicked(null, null, true)).toBe(true);
expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
});
it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
// Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
});
it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
});
it("is a no-op when the flag is already false", () => {
expect(shouldResetRolePicked(null, null, false)).toBe(false);
});
});

View File

@@ -0,0 +1,34 @@
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Decide what (if anything) to auto-send when an agent role card is picked
* (issue #149). Extracted as a pure function so the three-way behavior is
* unit-testable without mounting the chat-thread component:
* - autoStart=false -> null (bind the role only, send nothing)
* - autoStart=true + message -> the trimmed custom launchMessage
* - autoStart=true + empty/null -> the default fallback text
*/
export function roleLaunchMessage(
role: Pick<IAiRole, "autoStart" | "launchMessage">,
defaultText: string,
): string | null {
if (!role.autoStart) return null;
return role.launchMessage?.trim() || defaultText;
}
/**
* Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
* should reset to false. After an autoStart=false pick the thread shows the
* composer with chatId still null; when the user then starts a fresh chat the
* parent clears the bound role (roleId -> null) but chatId stays null, so the
* thread never remounts and the flag would otherwise stay set — hiding the role
* cards forever. Reset exactly in that state; a still-bound role (roleId set)
* keeps the cards hidden. (Regression guard for #149.)
*/
export function shouldResetRolePicked(
chatId: string | null,
roleId: string | null | undefined,
rolePickedNoSend: boolean,
): boolean {
return chatId === null && roleId == null && rolePickedNoSend;
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
newThread,
switchThread,
adoptThread,
threadSessionReducer,
} from "./thread-identity";
describe("newThread", () => {
it("uses the supplied key and has no chat id yet", () => {
expect(newThread("new-abc")).toEqual({ key: "new-abc", chatId: null });
});
});
describe("switchThread", () => {
it("switches to an existing chat: key becomes the chat id", () => {
expect(switchThread("chat-1")).toEqual({
key: "chat-1",
chatId: "chat-1",
});
});
});
describe("adoptThread", () => {
// Key UNCHANGED (no remount) + chatId moved null->realId. The unchanged key is
// what keeps the live useChat store alive; the matching chatId is what makes the
// window's render-phase reconciler (activeChatId !== thread.chatId) treat the
// adopted thread as already-in-sync rather than a switch.
it("adopts in place for a new chat: keeps the key, sets the chat id", () => {
const prev = newThread("new-abc");
expect(adoptThread(prev, "chat-1")).toEqual({
key: "new-abc",
chatId: "chat-1",
});
});
it("is a no-op for an already-persisted chat", () => {
const prev: { key: string; chatId: string | null } = {
key: "chat-1",
chatId: "chat-1",
};
expect(adoptThread(prev, "chat-2")).toBe(prev);
});
});
describe("threadSessionReducer", () => {
it("reconcile to an existing id switches (key becomes the id)", () => {
const next = threadSessionReducer(newThread("new-abc"), {
type: "reconcile",
chatId: "chat-1",
newKey: "new-xyz",
});
expect(next).toEqual({ key: "chat-1", chatId: "chat-1" });
});
it("reconcile to null starts a fresh new thread with the supplied key", () => {
const next = threadSessionReducer(switchThread("chat-1"), {
type: "reconcile",
chatId: null,
newKey: "new-xyz",
});
expect(next).toEqual({ key: "new-xyz", chatId: null });
});
it("adopt on a new thread keeps the key and sets the id", () => {
const next = threadSessionReducer(newThread("new-abc"), {
type: "adopt",
chatId: "chat-1",
});
expect(next).toEqual({ key: "new-abc", chatId: "chat-1" });
});
it("adopt on a persisted thread is a no-op", () => {
const prev = switchThread("chat-1");
expect(threadSessionReducer(prev, { type: "adopt", chatId: "chat-2" })).toBe(
prev,
);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Pure transitions for the AI-chat thread's identity: the single source of
* truth tying ChatThread's mount key to the chat id that mounted thread holds.
*
* The window keeps exactly ONE of these in state. Consolidating the mount key
* and the live thread's chat id into one atomic value makes the "stale chat id
* vs key" state unrepresentable: every change goes through one of the explicit
* transitions below, so the key and chatId can never silently diverge.
*
* - `newThread`/`switchThread` produce a key that forces a remount (+ reseed):
* `newThread` for a brand-new (id-less) chat, `switchThread` for an existing
* one. The caller picks which based on whether there is a chat id.
* - `adoptThread` keeps the SAME key so a brand-new chat learns its real id
* WITHOUT remounting (the live useChat store, holding the just-finished turn,
* is preserved and the next turn sends the real chatId).
*
* `newThread` takes the session key from the impure `generateId()` at the call
* site so these stay pure and unit-testable.
*/
export type ThreadIdentity = { key: string; chatId: string | null };
/**
* A brand-new chat: a fresh session key and no chat id yet. `newKey` is
* supplied by the caller (generateId() is impure) so this stays pure/testable.
*/
export function newThread(newKey: string): ThreadIdentity {
return { key: newKey, chatId: null };
}
/**
* Switch to an EXISTING chat: the mount key becomes the chat id, forcing a
* remount + reseed from the persisted history. (A switch to a brand-new chat
* goes through `newThread` instead — there is no id to key on.)
*/
export function switchThread(chatId: string): ThreadIdentity {
return { key: chatId, chatId };
}
/**
* In-place adoption: a brand-new chat (`prev.chatId === null`) learns its real
* id WITHOUT remounting — keep the SAME key, set the chat id. If `prev` already
* has a chatId (not a new chat), this is a no-op (returns `prev`): adoption only
* applies to an as-yet-unadopted new thread.
*/
export function adoptThread(prev: ThreadIdentity, chatId: string): ThreadIdentity {
return prev.chatId === null ? { key: prev.key, chatId } : prev;
}
/**
* Thread-identity transitions as a reducer action. See `threadSessionReducer`.
*/
export type ThreadSessionAction =
| { type: "reconcile"; chatId: string | null; newKey: string }
| { type: "adopt"; chatId: string };
/**
* Single source of truth for thread-identity transitions. `reconcile` handles a
* genuine switch (user OR external atom write) -> remount; `adopt` moves a brand-
* new chat to its real id in place (no remount).
*/
export function threadSessionReducer(
state: ThreadIdentity,
action: ThreadSessionAction,
): ThreadIdentity {
switch (action.type) {
case "reconcile":
return action.chatId === null
? newThread(action.newKey)
: switchThread(action.chatId);
case "adopt":
return adoptThread(state, action.chatId);
}
}

View File

@@ -10,9 +10,12 @@ import {
PasswordInput,
Box,
Stack,
Group,
Text,
} from "@mantine/core";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { Link, useParams, useSearchParams } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
}
if (isError) {
return <div>{t("invalid invitation link")}</div>;
// Styled error with a CTA to login, mirroring the password-reset
// error page and the 404 page (issue #133)
return (
<AuthLayout>
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid invitation link")}
</Text>
<Group justify="center">
<Button
component={Link}
to={APP_ROUTE.AUTH.LOGIN}
variant="subtle"
size="md"
>
{t("Go to login page")}
</Button>
</Group>
</Container>
</AuthLayout>
);
}
if (!invitation) {

View File

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

View File

@@ -0,0 +1,59 @@
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";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// 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.
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
}));
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
vi.mock("@/features/comment/components/comment-editor", () => ({
default: () => <div data-testid="comment-editor" />,
}));
import CommentListItem from "./comment-list-item";
const baseComment = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
content: JSON.stringify({ type: "doc", content: [] }),
creatorId: "user-1",
pageId: "page-1",
workspaceId: "ws-1",
createdAt: new Date(),
creator: { id: "user-1", name: "Service Bot", avatarUrl: null } as any,
...over,
}) as IComment;
function renderItem(comment: IComment) {
return render(
<MantineProvider>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
</MantineProvider>,
);
}
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();
});
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).
});

View File

@@ -1,4 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core";
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";
@@ -126,9 +127,18 @@ function CommentListItem({
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<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>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && (

View File

@@ -17,6 +17,13 @@ export interface IComment {
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
// Agent-edit provenance (returned by the backend via selectAll('comments')).
// createdSource === "agent" marks a comment authored via an AI agent (MCP /
// internal AI chat); aiChatId deep-links to the internal chat when present
// (null for an external MCP agent); resolvedSource marks an AI-resolved thread.
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
yjsSelection?: {
anchor: any;
head: any;

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { encodeWavPcm16 } from "./encode-wav";
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
// produce audio the server cannot decode (silence / garbled transcripts), so we
// assert the canonical 44-byte header layout and the sample quantisation rails.
// Read a DataView back out of a Blob. jsdom's Blob does not implement
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
function readView(blob: Blob): Promise<DataView> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
function readStr(view: DataView, offset: number, length: number): string {
let s = "";
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
describe("encodeWavPcm16", () => {
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(4)));
expect(readStr(view, 0, 4)).toBe("RIFF");
expect(readStr(view, 8, 4)).toBe("WAVE");
expect(readStr(view, 12, 4)).toBe("fmt ");
expect(readStr(view, 36, 4)).toBe("data");
});
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples));
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
expect(view.getUint16(22, true)).toBe(1); // channels = mono
expect(view.getUint16(34, true)).toBe(16); // bits per sample
});
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
const sampleRate = 16000;
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples, sampleRate));
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
});
it("defaults the sample rate to 16000 at offset 24", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(2)));
expect(view.getUint32(24, true)).toBe(16000);
});
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
expect(view8.getUint32(24, true)).toBe(8000);
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
expect(view48.getUint32(24, true)).toBe(48000);
expect(view48.getUint32(28, true)).toBe(48000 * 2);
});
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
// 0 -> 0, and out-of-range values are clamped to the rails first.
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
const view = await readView(encodeWavPcm16(samples));
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
});
it("produces a mono blob of length 44 + samples.length * 2", () => {
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
});
});

View File

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

View File

@@ -1,23 +1,43 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import { ActionIcon, Loader, Tooltip } from "@mantine/core";
import {
IconDownload,
IconFileText,
IconTrash,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import classes from "../common/toolbar-menu.module.css";
// STT-accepted audio MIME types (mirror of the server whitelist). If the
// fetched blob's type is not one of these, we infer it from the file
// extension so the upload's content-type is something the endpoint accepts.
const RECOGNIZED_AUDIO_MIME = new Set([
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
"audio/wav", "audio/x-wav", "audio/wave", "audio/m4a", "audio/x-m4a",
]);
const AUDIO_MIME_BY_EXT: Record<string, string> = {
mp3: "audio/mpeg", m4a: "audio/mp4", mp4: "audio/mp4",
wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg", webm: "audio/webm",
};
export function AudioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const dictationEnabled = workspace?.settings?.ai?.dictation === true;
const [isTranscribing, setIsTranscribing] = useState(false);
const editorState = useEditorState({
editor,
@@ -68,6 +88,100 @@ export function AudioMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const handleTranscribe = useCallback(async () => {
const src = editorState?.src;
if (!src || isTranscribing) return;
// The bubble menu shows for the selected audio node, so selection.from is
// that node's start position. Capture it now to disambiguate duplicate-src
// blocks after the async transcription completes.
const selectedPos = editor.state.selection.from;
setIsTranscribing(true);
try {
const fileUrl = getFileUrl(src);
// Derive a filename from the internal src for the multipart part name and
// for MIME inference when the fetched blob has no usable type.
const filename = decodeURIComponent(
src.split("?")[0].split("/").pop() || "audio",
);
const res = await fetch(fileUrl, { credentials: "include" });
if (!res.ok) {
throw new Error(`Failed to fetch audio file (HTTP ${res.status})`);
}
const blob = await res.blob();
// Ensure the upload's content-type is one the STT endpoint accepts; the
// server keys off the blob's MIME type.
let uploadBlob = blob;
const baseType = (blob.type || "").split(";")[0].trim().toLowerCase();
if (!RECOGNIZED_AUDIO_MIME.has(baseType)) {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const inferred = AUDIO_MIME_BY_EXT[ext];
if (inferred) {
// Rebuild the blob with an accepted content-type; the server keys off it.
uploadBlob = new Blob([blob], { type: inferred });
}
}
const text = (await transcribeAudio(uploadBlob, filename)).trim();
if (text.length === 0) {
notifications.show({ message: t("No speech detected") });
return;
}
// Re-scan the doc at insert time so a collaborative edit during the async
// transcription can't misplace the text. Among audio nodes with this src
// (the same file may be embedded more than once), pick the occurrence
// closest to the originally-selected block.
let insertPos: number | null = null;
let bestDelta = Infinity;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === "audio" && node.attrs.src === src) {
const delta = Math.abs(pos - selectedPos);
if (delta < bestDelta) {
bestDelta = delta;
insertPos = pos + node.nodeSize; // position just after the audio block
}
}
return true; // visit all nodes to find the closest match
});
const paragraph = { type: "paragraph", content: [{ type: "text", text }] };
try {
if (insertPos !== null) {
editor.chain().focus().insertContentAt(insertPos, paragraph).run();
} else {
editor.chain().focus().insertContent(paragraph).run();
}
} catch (insertErr) {
// A destroyed editor or out-of-bounds position must not throw; log and
// ignore so the transcription itself is not reported as a failure.
console.error("[audio-transcribe] insert failed", insertErr);
}
} catch (err) {
console.error("[audio-transcribe] failed", err);
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 error, 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 });
} finally {
setIsTranscribing(false);
}
}, [editor, editorState?.src, isTranscribing, t]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -95,6 +209,20 @@ export function AudioMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
{dictationEnabled && (
<Tooltip position="top" label={isTranscribing ? t("Transcribing…") : t("Transcribe")} withinPortal={false}>
<ActionIcon
onClick={handleTranscribe}
size="lg"
aria-label={t("Transcribe")}
variant="subtle"
disabled={isTranscribing}
>
{isTranscribing ? <Loader size={18} /> : <IconFileText size={18} />}
</ActionIcon>
</Tooltip>
)}
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}

View File

@@ -47,6 +47,26 @@ export default function CodeBlockView(props: NodeViewProps) {
return (
<NodeViewWrapper className="codeBlock">
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
With the non-editable menu rendered before it, the browser's click
hit-testing snapped the caret up one line. Render content first; the
menu is rendered after it and lifted back above visually via flex
`order: -1` (the `.codeBlock` wrapper is a flex column — see
code-block.module.css). It stays fully in flow as a full-width row
above the code: no overlay/absolute positioning. The second #146
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
<pre
spellCheck="false"
hidden={
((language === "mermaid" && !editor.isEditable) ||
(language === "mermaid" && !isSelected)) &&
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
<Group
justify="flex-end"
contentEditable={false}
@@ -83,18 +103,6 @@ export default function CodeBlockView(props: NodeViewProps) {
</CopyButton>
</Group>
<pre
spellCheck="false"
hidden={
((language === "mermaid" && !editor.isEditable) ||
(language === "mermaid" && !isSelected)) &&
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
{language === "mermaid" && (
<Suspense fallback={null}>
<MermaidView props={props} />

View File

@@ -17,7 +17,14 @@
justify-content: center;
}
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
so the menu still reads as a row above the code, exactly as before, without
sitting in-flow before the contentDOM. */
.menuGroup {
order: -1;
@media print {
display: none;
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
collectScrollAncestors,
reflowAfterPaste,
} from "./editor-paste-handler";
/**
* Unit tests for the #146 post-paste reflow helpers. jsdom does not compute
* styles or layout, so we stub getComputedStyle (per element via a Map) and the
* scroll/overflow geometry properties (per element via Object.defineProperty).
* Element trees are built DETACHED from `document`, so the ancestor walk only
* traverses the elements we create. collectScrollAncestors always appends
* document.scrollingElement, so we assert on specific ancestors with
* toContain/not.toContain rather than exact-array equality.
*/
type Overflow = { overflowX: string; overflowY: string };
const styleMap = new Map<Element, Overflow>();
function makeScrollable(
overflowY: string,
{
sh = 0,
ch = 0,
sw = 0,
cw = 0,
left = 0,
top = 0,
overflowX = "visible",
}: {
sh?: number;
ch?: number;
sw?: number;
cw?: number;
left?: number;
top?: number;
overflowX?: string;
} = {},
) {
const el = document.createElement("div");
Object.defineProperty(el, "scrollHeight", { configurable: true, value: sh });
Object.defineProperty(el, "clientHeight", { configurable: true, value: ch });
Object.defineProperty(el, "scrollWidth", { configurable: true, value: sw });
Object.defineProperty(el, "clientWidth", { configurable: true, value: cw });
Object.defineProperty(el, "scrollLeft", { configurable: true, value: left });
Object.defineProperty(el, "scrollTop", { configurable: true, value: top });
styleMap.set(el, { overflowX, overflowY });
return el;
}
// A leaf node whose parentElement is `parent`. The walk starts from
// node.parentElement, so the parent is the first candidate ancestor.
function makeNodeUnder(parent: HTMLElement) {
const node = document.createElement("div");
parent.appendChild(node);
return node;
}
// Override `document.scrollingElement` as an instance own-property (the native
// implementation is a getter on Document.prototype, which we never touch).
function setScrollingElement(value: Element | null) {
Object.defineProperty(document, "scrollingElement", {
configurable: true,
get: () => value,
});
}
beforeEach(() => {
styleMap.clear();
vi.stubGlobal("getComputedStyle", (el: Element) => {
return styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" };
});
});
afterEach(() => {
vi.unstubAllGlobals();
// Drop the per-test instance override so the native prototype getter shows
// through again (it was never modified, so no further restore is needed).
delete (document as any).scrollingElement;
});
describe("collectScrollAncestors", () => {
it("includes an overflow:overlay ancestor that overflows (macOS case)", () => {
setScrollingElement(null);
const a = makeScrollable("overlay", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("excludes an overflow:auto ancestor that does NOT overflow (gate fails)", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 100, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).not.toContain(a);
});
it("includes an overflow:auto ancestor that overflows", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("excludes a non-scrollable overflow even when it overflows", () => {
setScrollingElement(null);
const a = makeScrollable("hidden", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).not.toContain(a);
});
it("includes an X-axis overflow:scroll ancestor that overflows horizontally", () => {
setScrollingElement(null);
const a = makeScrollable("visible", {
overflowX: "scroll",
sw: 200,
cw: 100,
});
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("dedups: scrollingElement already in the walk is added exactly once", () => {
const a = makeScrollable("auto", { sh: 200, ch: 100 });
setScrollingElement(a);
const node = makeNodeUnder(a);
const result = collectScrollAncestors(node);
expect(result.filter((x) => x === a).length).toBe(1);
});
it("does not throw and appends nothing when scrollingElement is null", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
const result = collectScrollAncestors(node);
// Only the qualifying ancestor we built — no trailing scrollingElement.
expect(result).toEqual([a]);
});
});
describe("reflowAfterPaste", () => {
it("runs the double rAF and nudges each ancestor with scrollTo(scrollLeft, scrollTop)", () => {
// Run the double-nested requestAnimationFrame synchronously.
vi.stubGlobal(
"requestAnimationFrame",
(cb: FrameRequestCallback) => {
cb(0);
return 0;
},
);
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100, left: 5, top: 10 });
const node = makeNodeUnder(a);
(a as any).scrollTo = vi.fn();
reflowAfterPaste({ view: { dom: node } } as any);
expect((a as any).scrollTo).toHaveBeenCalledWith(5, 10);
});
});

View File

@@ -22,12 +22,81 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "overlay"]);
/**
* Collect every scrollable ancestor of the editor DOM whose hit-test layer
* could be stale after a paste, plus the document scrolling element. We nudge
* ALL of them (a zero-delta nudge is harmless) because the real scroll container
* varies — a styled overflow ancestor on most pages, the document itself on
* others — and `overflow: overlay` (common on macOS, where #146 reproduces)
* must count as scrollable too. Called only AFTER the paste has committed, so
* `scrollHeight > clientHeight` reflects the inserted content.
*/
export function collectScrollAncestors(node: HTMLElement): HTMLElement[] {
const targets: HTMLElement[] = [];
// Walk every ancestor (incl. body/html) — on some layouts the scroll lives on
// body rather than the documentElement that scrollingElement points at.
let el: HTMLElement | null = node.parentElement;
while (el) {
const { overflowX, overflowY } = getComputedStyle(el);
const scrollsY =
SCROLLABLE_OVERFLOW.has(overflowY) && el.scrollHeight > el.clientHeight;
const scrollsX =
SCROLLABLE_OVERFLOW.has(overflowX) && el.scrollWidth > el.clientWidth;
if (scrollsY || scrollsX) targets.push(el);
el = el.parentElement;
}
const docEl = document.scrollingElement as HTMLElement | null;
if (docEl && !targets.includes(docEl)) targets.push(docEl);
return targets;
}
/**
* Re-flow the editor's scroll containers after a paste so the browser refreshes
* its click hit-testing geometry (#146). Pasting markdown/code inserts React
* NodeViews that mount ASYNCHRONOUSLY; until the next reflow, ProseMirror's
* posAtCoords/caretRangeFromPoint can map a click to a stale (offset) line —
* which users observed clears itself on any scroll. We reproduce that scroll's
* side effect with a ZERO-delta nudge (re-assign scrollTop/Left to their current
* value), invalidating the hit-test layer WITHOUT moving the viewport. The
* container lookup AND the nudge run across two animation frames so they happen
* AFTER the pasted content + NodeViews commit (only then is the real scroll
* container measurable).
*
* This is the SECOND of two #146 mitigations; the FIRST is the content-first DOM
* order in the NodeViews (code-block-view.tsx, footnotes-list-view.tsx,
* footnote-definition-view.tsx). Editing one, check the other.
*/
export function reflowAfterPaste(editor: Editor) {
const dom = editor.view.dom as HTMLElement;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const el of collectScrollAncestors(dom)) {
// Zero-delta nudge: re-set the scroll position to its current value to
// invalidate the browser's hit-test layer WITHOUT moving the viewport.
// `scrollTo(x, y)` is the repo idiom and avoids a lint-flagged
// self-assignment.
el.scrollTo(el.scrollLeft, el.scrollTop);
}
});
});
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
) => {
// Schedule a post-paste reflow on EVERY paste path — intentionally. handlePaste
// returns BEFORE the markdown/code-insertion plugin runs, so it cannot know here
// whether async NodeViews will be inserted; the nudge is a cheap layout read on
// the next frames and a no-op for the viewport, so scheduling it unconditionally
// is simpler and harmless. Pairs with the content-first DOM order in the
// NodeViews — both address #146 from different angles.
reflowAfterPaste(editor);
const clipboardData = event.clipboardData.getData("text/plain");
if (INTERNAL_LINK_REGEX.test(clipboardData)) {

View File

@@ -73,3 +73,18 @@
display: none !important;
}
}
/* Float image (#145): on narrow screens a floated image would crowd the text to
an unreadable column, so collapse it to full width and drop the float.
`!important` is required because applyAlignment sets `float`/`padding` inline,
which a normal rule cannot override. Keys off the `data-image-align` attribute
the image node view mirrors onto its container. This module is the one actually
imported by the resize node views (node-resize-handles.ts), so the rule loads. */
@media (max-width: 600px) {
.container:global([data-image-align="floatLeft"]),
.container:global([data-image-align="floatRight"]) {
float: none !important;
width: 100% !important;
padding: 0 !important;
}
}

View File

@@ -1,5 +1,7 @@
import { FC, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { MicButton } from "@/features/dictation/components/mic-button";
interface Props {
@@ -9,6 +11,11 @@ interface Props {
}
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
// keeps the stable batch path.
const workspace = useAtomValue(workspaceAtom);
const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true;
// 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
@@ -70,7 +77,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
return (
<MicButton
size="md"
streaming
streaming={streamingDictation}
onStart={handleStart}
onText={handleText}
disabled={!editor.isEditable}

View File

@@ -1,25 +1,45 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext";
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
*/
export function backlinkLabel(index: number): string {
let out = "";
let x = index;
while (x >= 0) {
out = String.fromCharCode(97 + (x % 26)) + out;
x = Math.floor(x / 26) - 1;
}
return out;
}
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*
* After #166 a footnote can be referenced more than once (one number, one
* definition, N forward links). When it is, the back-link becomes a row of
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
* a single-reference footnote keeps the plain ↩.
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc
// change) rather than recomputing the whole map on every render.
// Read the cached number/ref-count from the numbering plugin (computed once
// per doc change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const refCount = getFootnoteRefCount(editor.state, id);
const handleBack = (e: React.MouseEvent) => {
const jumpTo = (e: React.MouseEvent, index: number) => {
e.preventDefault();
editor.commands.scrollToReference(id);
editor.commands.scrollToReference(id, index);
};
return (
@@ -29,20 +49,60 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{number}.
</span>
{/* #146: contentDOM MUST be the first child — a non-editable marker before
it makes click hit-testing snap the caret above. Content first; the
marker + back-link follow in DOM and are placed left/right via CSS
flex `order`. The second #146 mitigation lives in
editor-paste-handler.tsx (reflowAfterPaste). */}
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
className={classes.definitionMarker}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
aria-hidden="true"
>
{number}.
</span>
{refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence.
<span
className={classes.backLinks}
contentEditable={false}
role="group"
aria-label={t("Back to references")}
>
<span className={classes.backLinkArrow} aria-hidden="true">
</span>
{Array.from({ length: refCount }, (_, i) => (
<span
key={i}
className={classes.backLink}
onClick={(e) => jumpTo(e, i)}
role="button"
aria-label={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
title={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
>
{backlinkLabel(i)}
</span>
))}
</span>
) : (
// Single reference -> the plain ↩ (unchanged behavior).
<span
className={classes.backLink}
contentEditable={false}
onClick={(e) => jumpTo(e, 0)}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
)}
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,231 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
*
* Guards ALL THREE editable NodeViews touched by the fix: the two footnote views
* (FootnotesListView, FootnoteDefinitionView) AND the code block (CodeBlockView).
*
* The caret/click-offset fix rests entirely on ONE invariant: in every editable
* NodeView the editable `NodeViewContent` (contentDOM) must come FIRST in the
* wrapper, with no non-editable (`contenteditable="false"`) element before it.
* If a future edit reinserts chrome (separator, heading, marker, back-link,
* language menu) ahead of the content, the macOS hit-testing bug returns
* silently — and the symptom needs a real browser to see. This test pins the
* DOM ORDER (the proxy that IS the fix) in the existing jsdom harness.
*
* We stub `@tiptap/react` so the views render as plain DOM and we can inspect
* the child order our JSX produces — that order is exactly what regresses, and
* it does not depend on a live editor. The stubbed `NodeViewContent` carries the
* real `data-node-view-content` marker tiptap uses, so the assertion mirrors
* production. This test passes on the fixed order and FAILS on the pre-fix order
* (chrome-before-content).
*/
vi.mock("@tiptap/react", () => ({
NodeViewWrapper: ({ children, ...props }: any) => (
<div data-testid="nvw" {...props}>
{children}
</div>
),
// Mirror the real contentDOM marker so the guard matches production output.
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// footnote-definition-view reads a cached number + reference count from the
// numbering plugin; stub them so we don't need a live ProseMirror state. The
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
// backlink branch (#168). Default 1 = single reference (the #146 cases).
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
getFootnoteRefCount: () => mockRefCount.value,
}));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
// The Group mock MUST forward contentEditable: React serializes
// contentEditable={false} to the DOM attribute contenteditable="false", which
// the structural guard selects on to identify non-editable chrome.
vi.mock("@mantine/core", () => ({
Group: ({ children, className, contentEditable }: any) => (
<div className={className} contentEditable={contentEditable}>
{children}
</div>
),
Select: () => null,
Tooltip: ({ children }: any) => <>{children}</>,
ActionIcon: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
}));
vi.mock("@/components/common/copy-button", () => ({
CopyButton: ({ children }: any) =>
children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
IconCopy: () => null,
}));
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
default: () => null,
}));
import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView, {
backlinkLabel,
} from "./footnote-definition-view";
import CodeBlockView from "../code-block/code-block-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
// editor.state (the latter unused once getFootnoteNumber is mocked).
const props = {
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: { state: {}, isEditable: true, commands: {} },
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
} as any;
// CodeBlockView needs more than the footnote stub: a language attr (non-mermaid
// so MermaidView never renders), an editor with selection/on/off, and an
// extension exposing lowlight.listLanguages.
const codeBlockProps = {
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
editor: {
state: { selection: { from: 0, to: 0 } },
isEditable: true,
commands: {},
on: vi.fn(),
off: vi.fn(),
},
extension: {
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
} as any;
const cases: Array<{ name: string; ui: React.ReactElement }> = [
{ name: "FootnotesListView", ui: <FootnotesListView {...props} /> },
{ name: "FootnoteDefinitionView", ui: <FootnoteDefinitionView {...props} /> },
{ name: "CodeBlockView", ui: <CodeBlockView {...codeBlockProps} /> },
];
describe("#146 editable NodeView contentDOM-first invariant", () => {
it.each(cases)(
"$name renders the editable contentDOM ahead of all non-editable chrome",
({ ui }) => {
const { getByTestId } = render(ui);
const wrapper = getByTestId("nvw");
const content = wrapper.querySelector("[data-node-view-content]");
expect(content).not.toBeNull();
// The contentDOM sits at the FRONT of the wrapper: it is either the
// wrapper's first child (footnote views) or nested in the first child
// (code-block wraps it in <pre>). Either way the first element child
// must contain it. (compareDocumentPosition below is NOT redundant here:
// for code-block the content is not the literal first child, so we keep
// the document-order check to prove no chrome precedes the content.)
const firstEl = wrapper.firstElementChild!;
expect(firstEl === content || firstEl.contains(content!)).toBe(true);
// Chrome exists (separator/heading/marker/back-link/menu)...
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
expect(nonEditable.length).toBeGreaterThan(0);
// ...and every non-editable element comes AFTER the contentDOM, so the
// browser's click hit-testing reaches the editable content first (#146).
for (const el of Array.from(nonEditable)) {
const pos = content!.compareDocumentPosition(el);
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
},
);
});
// #168: a footnote referenced more than once shows one lettered backlink per
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
// footnote keeps the plain ↩.
describe("#168 footnote definition multi-backlinks", () => {
afterEach(() => {
// Reset the shared ref-count mock so other tests see a single reference.
mockRefCount.value = 1;
});
const makeProps = () =>
({
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: {
state: {},
isEditable: true,
commands: { scrollToReference: vi.fn() },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
}) as any;
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
mockRefCount.value = 3;
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(Array.from(links).map((l) => l.textContent)).toEqual([
"a",
"b",
"c",
]);
// The ↩ arrow is present (as decorative chrome, not a button).
expect(wrapper.textContent).toContain("↩");
});
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
mockRefCount.value = 3;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
fireEvent.click(links[1]); // "b"
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
1,
);
});
it("a single-reference footnote renders just one ↩ (no letters)", () => {
mockRefCount.value = 1;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(links.length).toBe(1);
expect(links[0].textContent).toBe("↩");
fireEvent.click(links[0]);
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
0,
);
});
});
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
describe("backlinkLabel base-26 boundary (#168)", () => {
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
expect(backlinkLabel(0)).toBe("a");
expect(backlinkLabel(25)).toBe("z");
expect(backlinkLabel(26)).toBe("aa");
expect(backlinkLabel(27)).toBe("ab");
expect(backlinkLabel(51)).toBe("az");
expect(backlinkLabel(52)).toBe("ba");
});
});

View File

@@ -57,14 +57,19 @@
word-break: break-word;
}
/* Bottom footnotes container. */
/* Bottom footnotes container. Flex column so the heading (rendered AFTER the
editable NodeViewContent in the DOM for #146) is lifted back above the list
visually via `order`, instead of sitting in-flow before the contentDOM. */
.list {
display: flex;
flex-direction: column;
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
}
.listHeading {
order: -1; /* visually above the list, though it follows it in the DOM (#146) */
font-weight: 600;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
@@ -83,6 +88,7 @@
}
.definitionMarker {
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
flex: 0 0 auto;
min-width: 1.5em;
/* Right-align within the narrow column so the period sits next to the text
@@ -98,6 +104,19 @@
min-width: 0;
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
which pushes the first text line ~0.5em below the "N." marker (aligned to
flex-start), making the number float above the text. Drop the outer margins
so the marker and the first line share the same top edge — same approach
used for callouts in core.css. */
.definitionContent > :first-child {
margin-top: 0;
}
.definitionContent > :last-child {
margin-bottom: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;
@@ -109,3 +128,18 @@
.backLink:hover {
text-decoration: underline;
}
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
occurrence. Sits on the right, after the content, like the single ↩. */
.backLinks {
flex: 0 0 auto;
display: inline-flex;
align-items: baseline;
gap: 0.3em;
user-select: none;
}
.backLinkArrow {
color: var(--mantine-color-dimmed);
font-size: 0.9em;
}

View File

@@ -3,18 +3,39 @@ import { useTranslation } from "react-i18next";
import classes from "./footnote.module.css";
/**
* NodeView for the bottom footnotes container. Renders a visual separator and a
* localized heading, then the editable list of definitions via NodeViewContent.
* NodeView for the bottom footnotes container: the editable list of definitions
* (NodeViewContent) plus a visual separator + localized heading.
*
* #146: the editable NodeViewContent MUST be the FIRST child in the DOM. A
* non-editable block rendered before it (the old separator + heading) makes the
* browser's click hit-testing (posAtCoords → caretRangeFromPoint) miss the
* contentDOM and snap the caret to the previous node (several lines above, into
* the body). So content goes first; the heading is rendered AFTER it and lifted
* back above visually with CSS flex `order` (the separator border lives on the
* flex container itself).
*
* The second #146 mitigation lives in editor-paste-handler.tsx (reflowAfterPaste).
*/
export default function FootnotesListView(_props: NodeViewProps) {
const { t } = useTranslation();
return (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
// role/aria-label preserve the section label for AT: the visible heading
// below is now aria-hidden, so without these the "Footnotes" label would be
// lost to a screen reader (WCAG 1.3.2 — DOM order has heading after content).
<NodeViewWrapper
className={classes.list}
role="group"
aria-label={t("Footnotes")}
>
<NodeViewContent />
<div
className={classes.listHeading}
contentEditable={false}
aria-hidden="true"
>
{t("Footnotes")}
</div>
</NodeViewWrapper>
);
}

View File

@@ -13,6 +13,8 @@ import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconDownload,
IconRefresh,
IconTrash,
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
};
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignImageFloatLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("floatLeft")
.run();
}, [editor]);
const alignImageFloatRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("floatRight")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Float left (wrap text)")} withinPortal={false}>
<ActionIcon
onClick={alignImageFloatLeft}
size="lg"
aria-label={t("Float left (wrap text)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isFloatLeft })}
>
<IconFloatLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Float right (wrap text)")} withinPortal={false}>
<ActionIcon
onClick={alignImageFloatRight}
size="lg"
aria-label={t("Float right (wrap text)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isFloatRight })}
>
<IconFloatRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
{altTextButton}

View File

@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getSuggestionItems } from "./menu-items";
// The slash-command `allow` callback (slash-command.ts) keeps the popup active
// only while at least one item matches the current query:
// const groups = getSuggestionItems({ query });
// const hasMatches = Object.values(groups).some((items) => items.length > 0);
// return hasMatches;
// With `allowSpaces: true`, a non-empty query that matches nothing must collapse
// to an empty result so `allow` returns false and the menu closes (instead of
// leaving literal "/todo abc" text behind). These tests pin that contract at the
// `getSuggestionItems` boundary, which is the unit-testable half of `allow`.
const KEY = "currentUser";
function hasMatches(query: string): boolean {
// Mirror the exact predicate used by slash-command.ts `allow`.
const groups = getSuggestionItems({ query });
return Object.values(groups).some((items) => items.length > 0);
}
beforeEach(() => {
// Default workspace state: HTML-embed feature OFF (matches production default).
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
});
afterEach(() => {
localStorage.clear();
});
describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => {
it("keeps the menu allowed for a query that matches items", () => {
expect(hasMatches("h1")).toBe(true);
});
it("keeps the menu allowed for a multi-word matching query", () => {
// "Heading 1" is a multi-word title kept alive by allowSpaces.
expect(hasMatches("Heading 1")).toBe(true);
});
it("closes the menu (no matches) for a non-empty query that matches nothing", () => {
expect(hasMatches("zzzznomatch")).toBe(false);
});
it("closes the menu for a space-bearing non-matching query", () => {
// The exact case the allowSpaces fix targets: "/todo abc" matches nothing.
expect(hasMatches("todo abc")).toBe(false);
});
it("returns an empty result object for a no-match query", () => {
expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({});
});
it("returns a non-empty result for the 'Heading 1' query", () => {
const groups = getSuggestionItems({ query: "Heading 1" });
const titles = Object.values(groups)
.flat()
.map((item) => item.title);
expect(titles).toContain("Heading 1");
});
});

View File

@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Page tree (child pages, recursive)",
description: "Render the full nested tree of all descendant pages",
searchTerms: [
"subpages",
"child",
"children",
"nested",
"hierarchy",
"tree",
"recursive",
"toc",
],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertSubpages({ recursive: true })
.run();
},
},
{
title: "Synced block",
description: "Create a block that stays in sync across pages.",

View File

@@ -1,9 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleRecursive = useCallback(() => {
const current = editor.getAttributes("subpages")?.recursive ?? false;
editor.commands.updateAttributes("subpages", {
recursive: !current,
});
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
.run();
}, [editor]);
// Subscribe to the live `recursive` attribute the standard way (as the
// sibling bubble menus do): useEditorState re-renders only when the selected
// value actually changes, so the mode icon/tooltip stay current after a
// toggle without re-rendering on every keystroke.
const isRecursive = useEditorState({
editor,
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
});
return (
<BaseBubbleMenu
editor={editor}
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
<Group gap={4} wrap="nowrap">
<Tooltip
position="top"
label={
isRecursive
? t("Switch to flat list")
: t("Switch to tree")
}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
<ActionIcon
onClick={toggleRecursive}
variant="default"
size="lg"
aria-label={t("Toggle subpages display mode")}
>
{isRecursive ? (
<IconList size={18} />
) : (
<IconSitemap size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</Group>
</BaseBubbleMenu>
);
}

View File

@@ -1,7 +1,10 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import {
useGetSidebarPagesQuery,
useGetPageTreeQuery,
} from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
@@ -12,16 +15,130 @@ import {
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import {
useSharedPageSubpages,
useSharedPageSubtree,
} from "@/features/share/hooks/use-shared-page-subpages";
import {
SubpageNode,
buildSubtree,
mapSharedNodes,
countNodes,
} from "./subpages-view.utils";
// Threshold above which the recursive tree shows a small count note. We never
// cap the data — this is only an informational hint for very large trees.
const LARGE_TREE_THRESHOLD = 300;
interface TreeNodeProps {
node: SubpageNode;
depth: number;
shareId?: string;
spaceSlug?: string;
// Threaded down from the variant component so a large tree does not create one
// i18n subscription (useTranslation) per rendered node.
t: (key: string) => string;
}
// Recursive renderer for a single node and its descendants. Indents each level
// by depth * 16px and reuses the same link/icon markup as the flat list.
function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) {
return (
<>
<Anchor
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: node.slugId,
pageTitle: node.title,
})
: buildPageUrl(spaceSlug, node.slugId, node.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
style={{ paddingLeft: depth * 16 }}
>
{node?.icon ? (
<span style={{ marginRight: "4px" }}>{node.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{node?.title || t("untitled")}
</span>
</Anchor>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
))}
</>
);
}
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const recursive: boolean = props.node.attrs.recursive ?? false;
//@ts-ignore
const currentPageId = editor.storage.pageId;
if (recursive) {
return (
<RecursiveSubpages
currentPageId={currentPageId}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
);
}
return (
<FlatSubpages
currentPageId={currentPageId}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
);
}
interface SubpagesVariantProps {
currentPageId: string;
shareId?: string;
spaceSlug?: string;
t: (key: string, options?: Record<string, unknown>) => string;
}
function FlatSubpages({
currentPageId,
shareId,
spaceSlug,
t,
}: SubpagesVariantProps) {
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
</NodeViewWrapper>
);
}
function RecursiveSubpages({
currentPageId,
shareId,
spaceSlug,
t,
}: SubpagesVariantProps) {
// In a shared/public context reuse the already-loaded nested shared tree
// instead of issuing a /pages/tree request.
const sharedSubtree = useSharedPageSubtree(currentPageId);
const { data, isLoading, error } = useGetPageTreeQuery(
shareId ? "" : currentPageId,
);
const tree = useMemo<SubpageNode[]>(() => {
if (shareId) {
return mapSharedNodes(sharedSubtree);
}
if (!data) return [];
return buildSubtree(data, currentPageId);
}, [data, shareId, sharedSubtree, currentPageId]);
const total = useMemo(() => countNodes(tree), [tree]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (tree.length === 0) {
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5}>
{tree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
))}
</Stack>
{total > LARGE_TREE_THRESHOLD && (
<Text c="dimmed" size="xs" pt="xs">
{t("Showing {{count}} subpages", { count: total })}
</Text>
)}
</div>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import {
buildSubtree,
countNodes,
mapSharedNodes,
SubpageNode,
} from "./subpages-view.utils";
import { IPage } from "@/features/page/types/page.types";
// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
const page = (p: Partial<IPage> & { id: string }): IPage =>
({
slugId: `slug-${p.id}`,
title: `Title ${p.id}`,
icon: undefined,
position: "a0",
parentPageId: null,
...p,
}) as IPage;
const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
describe("buildSubtree", () => {
it("nests children under the root and excludes the root itself", () => {
const pages = [
page({ id: "root" }),
page({ id: "a", parentPageId: "root", position: "a0" }),
page({ id: "b", parentPageId: "root", position: "a1" }),
page({ id: "a1", parentPageId: "a", position: "a0" }),
];
const tree = buildSubtree(pages, "root");
// Root is not rendered; only its descendants.
expect(ids(tree)).toEqual(["a", "b"]);
expect(ids(tree[0].children)).toEqual(["a1"]);
expect(tree[1].children).toEqual([]);
});
it("sorts each level by position", () => {
const pages = [
page({ id: "root" }),
page({ id: "z", parentPageId: "root", position: "a2" }),
page({ id: "x", parentPageId: "root", position: "a0" }),
page({ id: "y", parentPageId: "root", position: "a1" }),
];
expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
});
it("returns [] when the root is absent from the page set", () => {
const pages = [page({ id: "a", parentPageId: "missing-root" })];
expect(buildSubtree(pages, "missing-root")).toEqual([]);
});
it("silently drops a node whose parent is absent (unreachable parent)", () => {
const pages = [
page({ id: "root" }),
page({ id: "ok", parentPageId: "root" }),
page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
];
expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
});
it("guards against self-parenting / attaching the root", () => {
const pages = [
// A (defensive) self-parented root must not attach to itself.
page({ id: "root", parentPageId: "root" }),
page({ id: "a", parentPageId: "root" }),
];
const tree = buildSubtree(pages, "root");
expect(ids(tree)).toEqual(["a"]);
});
it("returns [] for empty input", () => {
expect(buildSubtree([], "root")).toEqual([]);
});
});
describe("countNodes", () => {
it("counts every descendant across all levels", () => {
const tree: SubpageNode[] = [
{
id: "a",
slugId: "s",
title: "A",
children: [
{ id: "a1", slugId: "s", title: "A1", children: [] },
{ id: "a2", slugId: "s", title: "A2", children: [] },
],
},
{ id: "b", slugId: "s", title: "B", children: [] },
];
expect(countNodes(tree)).toBe(4);
expect(countNodes([])).toBe(0);
});
});
describe("mapSharedNodes", () => {
it("remaps value->id / name->title and keeps nested children", () => {
const shared = [
{
value: "p1",
slugId: "s1",
name: "Parent",
icon: "📁",
children: [
{ value: "c1", slugId: "sc1", name: "Child", children: [] },
],
},
] as any;
const mapped = mapSharedNodes(shared);
expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
});
});

View File

@@ -0,0 +1,83 @@
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { IPage } from "@/features/page/types/page.types";
import { SharedPageTreeNode } from "@/features/share/utils";
// Normalized node shared by the flat and recursive subpages renderers so the
// same link/icon markup works for both API pages and shared-tree nodes.
export interface SubpageNode {
id: string;
slugId: string;
title: string;
icon?: string;
children: SubpageNode[];
}
// Subpage node carrying `position` so each level can be sorted in place.
export type SubpageNodeWithPos = SubpageNode & {
position: string;
children: SubpageNodeWithPos[];
};
/**
* Build a nested subtree (the current page's descendants) from the flat `IPage[]`
* the `/pages/tree` endpoint returns. Attaches each node to its parent by
* `parentPageId`, drops the root itself, and sorts every level by `position`.
*
* Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
* NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
* server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
* produces this list would itself loop before reaching the client, so the flat
* input is acyclic by construction. A node whose `parentPageId` points outside
* the result set (an unreachable parent) is silently dropped — it is, by
* definition, not a descendant of the root being rendered.
*/
export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
const byId = new Map<string, SubpageNodeWithPos>(
pages.map((p) => [
p.id,
{
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
children: [],
},
]),
);
for (const p of pages) {
const node = byId.get(p.id);
const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
if (node && parent && p.id !== rootId) {
parent.children.push(node);
}
}
const sortRecursive = (
nodes: SubpageNodeWithPos[],
): SubpageNodeWithPos[] => {
const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
sorted.forEach((n) => sortRecursive(n.children));
return sorted;
};
const root = byId.get(rootId);
return root ? sortRecursive(root.children) : [];
}
// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
return nodes.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
children: node.children ? mapSharedNodes(node.children) : [],
}));
}
// Count every descendant in a normalized subtree.
export function countNodes(nodes: SubpageNode[]): number {
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
}

View File

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

View File

@@ -14,6 +14,10 @@ const Command = Extension.create({
return {
suggestion: {
char: '/',
// Keep the query alive through spaces so multi-word item labels
// (e.g. "Heading 1", "Math block") match instead of terminating the
// query and leaving literal "/Heading 1" text in the document.
allowSpaces: true,
command: ({ editor, range, props }) => {
props.command({ editor, range, props });
},
@@ -23,7 +27,22 @@ const Command = Extension.create({
if ($from.parent.type.name === 'codeBlock') {
return false;
}
return true;
// With `allowSpaces: true` a query that contains a space no longer
// terminates the suggestion on its own, so a space-bearing query that
// matches nothing (e.g. "/todo abc") would otherwise keep an empty
// popup logically active and leave the literal "/todo abc" text in the
// document, only dismissable via Escape. Deactivate the suggestion when
// no item matches the current query: returning false here removes the
// decoration, fires the popup's `onExit`, and lets subsequent keystrokes
// pass through normally — restoring the pre-`allowSpaces` behavior for
// non-matching queries while keeping multi-word matches (e.g.
// "/Heading 1") working.
const query = state.doc.textBetween(range.from + 1, range.to);
const groups = getSuggestionItems({ query });
const hasMatches = Object.values(groups).some(
(items) => items.length > 0,
);
return hasMatches;
},
} as Partial<SuggestionOptions>,
};

View File

@@ -32,6 +32,8 @@ import {
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-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);
@@ -84,6 +86,10 @@ export function FullEditor({
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Single shared Y.Doc + HocuspocusProvider for both the title and body
// editors (title lives in the 'title' fragment of the same doc).
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -103,26 +109,30 @@ export function FullEditor({
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
<EditorProvidersContext.Provider
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
</Container>
);
}

View File

@@ -0,0 +1,316 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { getDefaultStore } from "jotai";
import { WebSocketStatus } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core";
import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
getSpaceById,
getSpaces,
} from "@/features/space/services/space-service.ts";
import {
createPage,
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import {
GitmostBridge,
GitmostCreatePagePayload,
GitmostCreatePageResult,
GitmostListPagesPayload,
GitmostListPagesResult,
GitmostListSpacesResult,
gitmostDecodePayloadToFile,
gitmostUploadFileToEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
// How long to wait for a freshly-navigated page's editor to mount, become
// editable, and connect its Yjs provider before giving up.
const GITMOST_EDITOR_READY_TIMEOUT_MS = 20000;
const GITMOST_EDITOR_POLL_INTERVAL_MS = 120;
// Poll the (default) jotai store until the editor for `pageId` is mounted,
// editable and its Yjs provider is connected. Resolves the live editor, or null
// on timeout. Reuses pageEditorAtom + yjsConnectionStatusAtom — the same signals
// PageEditor maintains. The storage.pageId check guards against matching a stale
// editor left over from the previously-open page.
function gitmostWaitForEditor(
pageId: string,
timeoutMs: number,
): Promise<Editor | null> {
const store = getDefaultStore();
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const check = () => {
const editor = store.get(pageEditorAtom) as Editor | null;
const yjsStatus = store.get(yjsConnectionStatusAtom);
// `storage.pageId` is a custom field PageEditor.onCreate sets; it is not
// part of Tiptap's Storage type, so read it through an indexed cast.
const editorPageId = (
editor?.storage as unknown as Record<string, unknown> | undefined
)?.pageId;
const ready =
!!editor &&
!editor.isDestroyed &&
editor.isEditable &&
editorPageId === pageId &&
yjsStatus === WebSocketStatus.Connected;
if (ready) {
resolve(editor);
return;
}
if (Date.now() >= deadline) {
resolve(null);
return;
}
setTimeout(check, GITMOST_EDITOR_POLL_INTERVAL_MS);
};
check();
});
}
// Registers the global gitmost bridge methods that work WITHOUT an open page
// (listSpaces / listPages / createPageWithRecording). Mounted once at the
// app-shell level so the react-router navigate fn and the api-client are
// available even when no page editor is mounted. insertRecording stays in
// PageEditor (tied to the live editable editor). Renders nothing.
export default function GitmostGlobalBridge() {
const navigate = useNavigate();
// The effect registers the bridge once; reading the latest navigate via a ref
// avoids a stale closure if react-router hands back a new function identity.
const navigateRef = useRef(navigate);
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
useEffect(() => {
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
w.gitmost = w.gitmost || {};
// Advertise the bridge version even before any page editor mounts; do not
// clobber a value already set by an active PageEditor.
if (typeof w.gitmost.version !== "number") w.gitmost.version = 1;
const listSpaces = async (): Promise<GitmostListSpacesResult> => {
try {
const res = await getSpaces({ limit: 100 });
const spaces = (res?.items ?? []).map((s) => ({
id: s.id,
name: s.name,
}));
// v1 returns only the first page; flag truncation so the host knows
// more spaces exist.
const truncated = Boolean(res?.meta?.hasNextPage);
return { ok: true, spaces, truncated };
} catch (err: any) {
console.error("[gitmost] listSpaces failed", err);
return {
ok: false,
error: "list-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to list spaces",
};
}
};
const listPages = async (
payload: GitmostListPagesPayload,
): Promise<GitmostListPagesResult> => {
try {
const spaceId = payload?.spaceId;
if (!spaceId) {
return {
ok: false,
error: "bad-args",
message: "spaceId is required",
};
}
const res = await getSidebarPages({
spaceId,
pageId: payload?.parentPageId,
limit: 100,
});
const pages = (res?.items ?? []).map((p) => ({
id: p.id,
title: p.title,
hasChildren: Boolean(p.hasChildren),
}));
// v1 returns only the first page of children; flag truncation so the
// host knows more exist.
const truncated = Boolean(res?.meta?.hasNextPage);
return { ok: true, pages, truncated };
} catch (err: any) {
console.error("[gitmost] listPages failed", err);
return {
ok: false,
error: "list-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to list pages",
};
}
};
const createPageWithRecording = async (
payload: GitmostCreatePagePayload,
): Promise<GitmostCreatePageResult> => {
try {
const { spaceId, parentPageId, title, base64, filename, mimeType } =
payload || ({} as GitmostCreatePagePayload);
if (!spaceId) {
return {
ok: false,
error: "no-space",
message: "spaceId is required",
};
}
// Validate/decode the recording BEFORE creating the page so a bad
// payload never leaves an empty junk page behind. Per the createPage
// error contract, any decode failure collapses to "insert-failed" (the
// real reason is kept in `message`).
const decoded = gitmostDecodePayloadToFile({
base64,
filename,
mimeType,
});
if ("error" in decoded) {
return {
ok: false,
error: "insert-failed",
message: decoded.error.message ?? "Invalid recording payload",
};
}
// Resolve the space slug (needed for router navigation); also a
// permission/existence probe -> no-space on failure.
let spaceSlug: string | undefined;
try {
const space = await getSpaceById(spaceId);
spaceSlug = space?.slug;
} catch (err: any) {
console.error("[gitmost] resolve space failed", err);
return {
ok: false,
error: "no-space",
message:
err?.response?.data?.message ??
err?.message ??
"Space not found or no access",
};
}
if (!spaceSlug) {
return {
ok: false,
error: "no-space",
message: "Space not found or no access",
};
}
// Create the page (REST). Default title when none is provided.
const defaultTitle = `Recording ${new Date().toLocaleString()}`;
let page;
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared IPage type; cast to satisfy the createPage signature.
page = await createPage({
spaceId,
parentPageId: parentPageId ?? undefined,
title: title ?? defaultTitle,
} as any);
} catch (err: any) {
console.error("[gitmost] createPage failed", err);
return {
ok: false,
error: "create-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to create page",
};
}
if (!page?.id || !page?.slugId) {
return {
ok: false,
error: "create-failed",
message: "Failed to create page",
};
}
// Reset the shared Yjs status before navigating. The atom is global and
// is NOT reset when a PageEditor unmounts, so it can still hold
// "connected" from a previously-open page; clearing it ensures the
// readiness gate below waits for the NEW page's provider to connect.
getDefaultStore().set(yjsConnectionStatusAtom, "");
// Navigate via the router (no full reload).
navigateRef.current(buildPageUrl(spaceSlug, page.slugId, page.title));
// Wait for the new page's editor: mounted, editable, Yjs connected.
const editor = await gitmostWaitForEditor(
page.id,
GITMOST_EDITOR_READY_TIMEOUT_MS,
);
if (!editor) {
return {
ok: false,
error: "editor-timeout",
message: "Editor was not ready in time",
// Return pageId so the host can still surface the created page.
pageId: page.id,
};
}
// Same insert path as insertRecording.
const result = await gitmostUploadFileToEditor(
editor,
page.id,
decoded.file,
);
if (!result.ok) {
return {
ok: false,
error: "insert-failed",
message: result.message ?? "Failed to insert recording",
pageId: page.id,
};
}
return { ok: true, pageId: page.id };
} catch (err: any) {
console.error("[gitmost] createPageWithRecording failed", err);
return {
ok: false,
error: "insert-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to create page with recording",
};
}
};
w.gitmost.listSpaces = listSpaces;
w.gitmost.listPages = listPages;
w.gitmost.createPageWithRecording = createPageWithRecording;
return () => {
// Only remove our own registrations (defensive against a future second
// mount having replaced them).
if (w.gitmost) {
if (w.gitmost.listSpaces === listSpaces) delete w.gitmost.listSpaces;
if (w.gitmost.listPages === listPages) delete w.gitmost.listPages;
if (w.gitmost.createPageWithRecording === createPageWithRecording) {
delete w.gitmost.createPageWithRecording;
}
}
};
}, []);
return null;
}

View File

@@ -0,0 +1,263 @@
import { Editor } from "@tiptap/core";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
// --- gitmost native bridge: shared types & helpers ------------------------
// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
// WKWebView). This module holds the parts shared between the open-page bridge
// (insertRecording, in page-editor.tsx) and the global bridge (gitmost-global-
// bridge.tsx): payload decoding/validation and the audio-insert pipeline, so
// both apply identical rules without depending on editor internals.
export interface GitmostInsertRecordingPayload {
base64: string; // raw file bytes, base64 (no data: prefix)
filename: string;
mimeType: string; // must be an audio/* type
}
export interface GitmostInsertRecordingResult {
ok: boolean;
attachmentId?: string;
// Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
error?: string;
message?: string; // human-readable, may be surfaced by the host
}
export interface GitmostSpaceSummary {
id: string;
name: string;
}
export interface GitmostListSpacesResult {
ok: boolean;
spaces?: GitmostSpaceSummary[];
// v1 lists only the first page of spaces; true when more exist server-side.
truncated?: boolean;
error?: string;
message?: string;
}
export interface GitmostListPagesPayload {
spaceId: string;
parentPageId?: string;
}
export interface GitmostPageSummary {
id: string;
title: string;
hasChildren: boolean;
}
export interface GitmostListPagesResult {
ok: boolean;
pages?: GitmostPageSummary[];
// v1 lists only the first page of children; true when more exist server-side.
truncated?: boolean;
error?: string;
message?: string;
}
export interface GitmostCreatePagePayload {
spaceId: string;
parentPageId?: string; // omit/null = space root
title?: string; // default "Recording <timestamp>"
base64: string;
filename: string;
mimeType: string;
}
export interface GitmostCreatePageResult {
ok: boolean;
pageId?: string;
// Machine-readable code: "no-space" | "create-failed" | "editor-timeout" | "insert-failed"
error?: string;
message?: string;
}
// Full bridge surface exposed on `window.gitmost`. Writers attach a subset
// (Partial), so readonly/share pages and no-page states are valid.
export interface GitmostBridge {
ready: boolean;
version: number;
insertRecording: (
payload: GitmostInsertRecordingPayload,
) => Promise<GitmostInsertRecordingResult>;
listSpaces: () => Promise<GitmostListSpacesResult>;
listPages: (payload: GitmostListPagesPayload) => Promise<GitmostListPagesResult>;
createPageWithRecording: (
payload: GitmostCreatePagePayload,
) => Promise<GitmostCreatePageResult>;
}
// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
// oversized payload can be rejected before the buffer is allocated.
export function gitmostEstimateBase64Bytes(base64: string): number {
const len = base64.length;
if (len === 0) return 0;
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.floor((len * 3) / 4) - padding;
}
// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
// with no embedded whitespace (per the native-host contract). Throws
// InvalidCharacterError on malformed input.
export function gitmostBase64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
const parts: Uint8Array[] = [];
let total = 0;
for (let i = 0; i < base64.length; i += CHUNK) {
const binary = atob(base64.slice(i, i + CHUNK));
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) {
bytes[j] = binary.charCodeAt(j);
}
parts.push(bytes);
total += bytes.length;
}
// Back the result with an explicit ArrayBuffer so the view is typed
// Uint8Array<ArrayBuffer> (not ArrayBufferLike), which `new File([...])`
// accepts as a BlobPart under the lib.dom typings.
const out = new Uint8Array(new ArrayBuffer(total));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
// Decode + validate a recording payload into a File, or return an error result.
// Shared so insertRecording (open page) and createPageWithRecording (no page
// open) apply identical validation. Error codes: "bad-type" | "too-large" |
// "insert-failed".
export function gitmostDecodePayloadToFile(
payload: GitmostInsertRecordingPayload,
): { file: File } | { error: GitmostInsertRecordingResult } {
const { filename, mimeType } =
payload || ({} as GitmostInsertRecordingPayload);
let base64 = payload?.base64;
if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
return {
error: { ok: false, error: "bad-type", message: "Not an audio file" },
};
}
if (typeof base64 !== "string" || base64.length === 0) {
return {
error: { ok: false, error: "insert-failed", message: "Empty payload" },
};
}
// Defensively strip an accidental data:*;base64, prefix.
const marker = base64.indexOf("base64,");
if (base64.startsWith("data:") && marker !== -1) {
base64 = base64.slice(marker + "base64,".length);
}
const sizeLimit = getFileUploadSizeLimit();
// Reject oversized payloads before allocating the decode buffer.
if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
return {
error: {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
},
};
}
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = gitmostBase64ToBytes(base64);
} catch (decodeErr: any) {
return {
error: {
ok: false,
error: "insert-failed",
message: decodeErr?.message ?? "Invalid base64 payload",
},
};
}
const file = new File([bytes], filename || "recording", { type: mimeType });
// Exact size check (the pre-decode estimate is approximate).
if (file.size > sizeLimit) {
return {
error: {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
},
};
}
return { file };
}
// Insert an already-decoded recording File into a live editor via the existing
// audio pipeline (placeholder -> POST /api/files/upload -> `audio` node,
// Yjs-synced). Returns the attachment id on success.
export async function gitmostUploadFileToEditor(
editor: Editor,
pageId: string,
file: File,
): Promise<GitmostInsertRecordingResult> {
try {
// Insert at the cursor, falling back to the end of the document.
const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
// uploadAudioAction returns the attachment on success and undefined when
// the upload failed (the pipeline swallows the upload error and shows its
// own notification).
const attachment = (await (uploadAudioAction(
file,
editor,
pos,
pageId,
) as unknown as Promise<{ id?: string } | undefined>));
if (attachment?.id) {
return { ok: true, attachmentId: attachment.id };
}
return { ok: false, error: "insert-failed", message: "Upload failed" };
} catch (err: any) {
// Never swallow: log the raw error and surface the real reason.
console.error("[gitmost] audio upload into editor failed", err);
return {
ok: false,
error: "insert-failed",
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
};
}
}
// Full insert path used by the open-page bridge (insertRecording): guard the
// editor, validate/decode the payload, then upload. Never throws — resolves to
// a result code.
export async function gitmostInsertRecordingIntoEditor(
editor: Editor | null,
pageId: string,
payload: GitmostInsertRecordingPayload,
): Promise<GitmostInsertRecordingResult> {
try {
// Only a live, editable editor may receive a recording.
if (!editor || editor.isDestroyed || !editor.isEditable) {
return { ok: false, error: "no-editor", message: "No editable page open" };
}
const decoded = gitmostDecodePayloadToFile(payload);
if ("error" in decoded) return decoded.error;
return await gitmostUploadFileToEditor(editor, pageId, decoded.file);
} catch (err: any) {
// The bridge must never throw — surface any unexpected failure as a code.
console.error("[gitmost] insertRecording failed", err);
return {
ok: false,
error: "insert-failed",
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
};
}
}

View File

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

View File

@@ -6,16 +6,7 @@ import React, {
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { WebSocketStatus } from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -28,13 +19,15 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -49,6 +42,7 @@ import { TableHandlesLayer } from "@/features/editor/components/table/handle/tab
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import AudioMenu from "@/features/editor/components/audio/audio-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
@@ -57,17 +51,19 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { useDebouncedCallback } from "@mantine/hooks";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import {
GitmostBridge,
GitmostInsertRecordingPayload,
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -92,7 +88,6 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -106,15 +101,10 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -123,128 +113,27 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
if (!providersReady || !remote || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
...collabExtensions(remote, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [providersReady, remote, currentUser?.user]);
const editor = useEditor(
{
@@ -333,6 +222,39 @@ export default function PageEditor({
},
});
// Expose the gitmost native bridge only while an editable page editor is
// mounted. Registering/tearing down here ties `ready` + `insertRecording`
// to the lifetime of the current editable editor: readonly/share pages and
// page switches re-run this effect (deps: live editable flag + pageId),
// recreating the closure over the active editor/pageId so a recording always
// targets whatever page is active at call time.
useEffect(() => {
if (!editor || !editor.isEditable) return;
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
w.gitmost = w.gitmost || {};
w.gitmost.version = 1;
w.gitmost.ready = true;
const insertRecording = (
payload: GitmostInsertRecordingPayload,
): Promise<GitmostInsertRecordingResult> =>
gitmostInsertRecordingIntoEditor(editor, pageId, payload);
w.gitmost.insertRecording = insertRecording;
return () => {
// Only tear down if our registration is still the active one. With
// React's mount-before-unmount ordering, a newer PageEditor instance may
// have already replaced the bridge; clearing it here would disable the
// live editor's bridge.
if (w.gitmost && w.gitmost.insertRecording === insertRecording) {
w.gitmost.ready = false;
delete w.gitmost.insertRecording;
}
};
}, [editor, pageId, editorIsEditable]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@@ -441,6 +363,7 @@ export default function PageEditor({
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<AudioMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
@@ -452,7 +375,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
remote && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}

View File

@@ -1,5 +1,9 @@
.ProseMirror {
.codeBlock {
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
editable contentDOM is first) is lifted back above the code via `order`. */
display: flex;
flex-direction: column;
padding: 4px;
border-radius: var(--mantine-radius-default);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import {
ToggleFavoriteParams,
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
export function useAddFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data),
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Added to favorites") });
},
});
}
export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data),
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Removed from favorites") });
},
});
}

View File

@@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t
import { useTranslation } from "react-i18next";
import { zod4Resolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
type FormValues = {
name: string;
description: string;
};
export function CreateGroupForm() {
const { t } = useTranslation();
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
// Build the schema with friendly, translated validation messages (issue #130)
const formSchema = z.object({
name: z
.string()
.trim()
.min(2, t("Group name must be at least 2 characters"))
.max(100, t("Group name must be 100 characters or fewer")),
description: z
.string()
.max(500, t("Description must be 500 characters or fewer")),
});
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {

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