Compare commits
67 Commits
fix/ai-cha
...
01825ccb5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01825ccb5d | ||
|
|
26cce98d8d | ||
|
|
8a31be9789 | ||
|
|
3c20ab1406 | ||
|
|
0bc42a442e | ||
|
|
9933079de2 | ||
|
|
c317f5502c | ||
|
|
8bfe572ebe | ||
|
|
47414e908c | ||
|
|
29c620bfe4 | ||
|
|
25304fe7d9 | ||
|
|
87856109fd | ||
| 719bccd80d | |||
| 83e64bad1a | |||
| ee78a96803 | |||
| d971d02346 | |||
|
|
53cbec9354 | ||
|
|
686c3f9d14 | ||
|
|
6faf2475e6 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
e99c00a9ee | ||
|
|
1f459d8d26 | ||
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 | ||
|
|
eb5b696431 | ||
|
|
422389d84e | ||
|
|
fad1aa0501 | ||
|
|
8bb4224a20 | ||
| 13589b3973 | |||
|
|
69fcccd6e8 | ||
|
|
0db48f1706 | ||
|
|
2e72a24d13 | ||
|
|
aad0a37cfd | ||
|
|
50d3e7b476 | ||
|
|
bd62d906bb | ||
|
|
e4b46ddbfc | ||
|
|
deeec50b5f | ||
|
|
7eefdad512 | ||
|
|
a7f8ee04b3 | ||
|
|
378d8b676b | ||
| 580f7bd5bb | |||
|
|
b538c729c3 | ||
|
|
0643cd1d82 | ||
| e3b23e0d26 | |||
|
|
b392219659 | ||
|
|
ba5cd02439 | ||
|
|
1043fe3b51 | ||
|
|
df50f23d58 | ||
|
|
eb5c8e6611 | ||
|
|
d32ad73158 | ||
|
|
acf2241e23 | ||
|
|
fdeede003b | ||
|
|
1d610b3a62 | ||
|
|
6bb9dfdc86 | ||
|
|
770ba70541 | ||
|
|
3d47c306fa | ||
|
|
c919d4f636 | ||
|
|
c4807022f2 | ||
|
|
00ca4ff3d6 | ||
|
|
ef7d04d1e7 | ||
|
|
5b59a70e3f | ||
|
|
fbdb8aa16c | ||
|
|
9b61024b95 | ||
|
|
2644fe6a83 | ||
|
|
993f884e64 |
21
.env.example
21
.env.example
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
|
||||
# Example: https://intranet.example.com,https://portal.example.com
|
||||
IFRAME_ALLOWED_ORIGINS=
|
||||
|
||||
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||
# Leave empty for a same-origin (web-only) deployment.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||
SWAGGER_ENABLED=false
|
||||
|
||||
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||
# Leave empty for Android bundled mode / local development.
|
||||
CAP_SERVER_URL=
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
@@ -187,3 +200,11 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
||||
#
|
||||
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
|
||||
# (input re-sent per step + output, summed across every accepted turn). The
|
||||
# hourly request cap above bounds how MANY calls run, not how expensive each is,
|
||||
# so this caps the owner's actual provider bill directly. Like the request cap it
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
157
.github/workflows/develop.yml
vendored
157
.github/workflows/develop.yml
vendored
@@ -56,3 +56,160 @@ jobs:
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Build server
|
||||
run: pnpm server:build
|
||||
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Start server (prod)
|
||||
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
|
||||
# migration mismatch) is diagnosable; without this the only signal is
|
||||
# the generic health-loop timeout below, ~120s later.
|
||||
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
|
||||
|
||||
- name: Wait for server health
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3000/api/health > /dev/null; then
|
||||
echo "Server is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Server did not become healthy in time"
|
||||
exit 1
|
||||
|
||||
- name: Dump server log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/server.log || true
|
||||
|
||||
- name: Seed admin
|
||||
run: |
|
||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
|
||||
|
||||
- name: Run mcp e2e
|
||||
env:
|
||||
DOCMOST_API_URL: http://localhost:3000/api
|
||||
DOCMOST_EMAIL: e2e@example.com
|
||||
DOCMOST_PASSWORD: E2ePassword123
|
||||
run: pnpm --filter @docmost/mcp test:e2e
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
53
AGENTS.md
53
AGENTS.md
@@ -283,37 +283,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
### Cutting a release
|
||||
|
||||
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
|
||||
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
|
||||
|
||||
1. Make sure `main` is clean and pushed (`git status`, `git push`).
|
||||
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
|
||||
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
|
||||
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
|
||||
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
|
||||
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
|
||||
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
|
||||
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
|
||||
```bash
|
||||
git push gitea develop && git push gitea vX.Y.Z
|
||||
git push github develop && git push github vX.Y.Z
|
||||
```
|
||||
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
|
||||
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only develop # or a merge commit if fast-forward is not possible
|
||||
git push gitea main && git push github main
|
||||
```
|
||||
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
|
||||
|
||||
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
|
||||
|
||||
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
|
||||
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
|
||||
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
|
||||
|
||||
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
|
||||
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
|
||||
|
||||
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
|
||||
|
||||
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
|
||||
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
|
||||
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
|
||||
|
||||
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
|
||||
|
||||
Fix / checklist when develop still shows the old version after a back-merge:
|
||||
|
||||
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
|
||||
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
|
||||
|
||||
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
|
||||
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
|
||||
|
||||
## Planning docs
|
||||
|
||||
|
||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -12,6 +12,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
|
||||
message gains a "send now" action that interrupts the streaming turn and
|
||||
immediately sends that message, keeping the agent's partial output. The
|
||||
follow-up turn is tagged as an interrupt so the model is told its previous
|
||||
answer was cut off and builds on it instead of restarting; the rest of the
|
||||
queue still flushes normally afterward. (#198)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
the database step by step and exported server-side, the desktop app no longer
|
||||
freezes at 100% CPU on long agent runs, and MCP writes are badged with
|
||||
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
|
||||
per-reference back-links), hardens page moves and duplication against cycles
|
||||
and lost edits, and caps the anonymous public-share assistant with a
|
||||
per-workspace rolling-day token budget.
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
@@ -52,9 +91,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||
byline reads the live editor content (including unsaved edits), generates a
|
||||
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||
rows, which silently dropped the beginning of any longer chat. It is now
|
||||
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||
safety net far above any realistic chat, not a conversational limit). On a
|
||||
very long chat this can eventually reach the model's context window; the
|
||||
client already surfaces that as "start a new chat". (#202)
|
||||
|
||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||
@@ -99,6 +153,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
no longer froze on the previous step's authoritative usage; the current step's
|
||||
estimate is combined per-component with `max`, so the count rises smoothly and
|
||||
never jumps backwards. (#163)
|
||||
- **AI chat: "New chat" during a streaming first turn now resets the whole
|
||||
chat, not just the role badge.** Starting a new chat mid-stream cleared the
|
||||
header but left the in-flight turn's messages behind, so the fresh chat opened
|
||||
pre-populated with the previous conversation; it now fully resets. (#161)
|
||||
- **AI chat: a dropped tool argument now yields an actionable error.** When the
|
||||
model omitted a required parameter (typically `pageId`) in a parallel/batch
|
||||
tool call, the assistant forwarded zod's raw "expected string, received
|
||||
undefined" text; tool inputs now return a message naming each missing/invalid
|
||||
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
|
||||
(#190)
|
||||
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
|
||||
under one of its own descendants is rejected in the same transaction as the
|
||||
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
|
||||
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
|
||||
so a pre-existing cycle can no longer spin a query. (#207)
|
||||
- **Page/editor robustness batch.** Duplicating a page now copies shared
|
||||
attachments for every referencing page (not just the first); colliding block
|
||||
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
|
||||
wrong node; transient collab store failures are retried so autosave edits
|
||||
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
|
||||
(#206)
|
||||
|
||||
### Security
|
||||
|
||||
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
|
||||
share assistant now caps a workspace's actual token spend (input + output,
|
||||
summed across every accepted turn) over a trailing day, on top of the hourly
|
||||
request cap — so a caller who evades the per-IP throttle still cannot run up
|
||||
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
|
||||
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
|
||||
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
@@ -142,6 +227,18 @@ embeds — plus a large batch of security hardening and test coverage.
|
||||
injected into the `<head>` of public share pages only (for analytics such as
|
||||
Google Analytics or Yandex.Metrika), kept separate from the member-facing
|
||||
HTML-embed feature.
|
||||
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
|
||||
children, and comments are cached in IndexedDB (TanStack Query persister plus
|
||||
`y-indexeddb` for the page's Yjs document), and a PWA service worker
|
||||
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
|
||||
offline. The offline cache (persisted query cache, Yjs page documents, and the
|
||||
service-worker API cache) is cleared on logout so a previous user's private
|
||||
data does not remain in the browser.
|
||||
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
|
||||
can request the access JWT in the response body (`data.authToken`) in addition
|
||||
to the httpOnly cookie (the web client stays cookie-only); an optional
|
||||
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
|
||||
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
|
||||
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
|
||||
embedded `/mcp` endpoint.
|
||||
- **Page tree**: Expand all / Collapse all for the space tree, and
|
||||
@@ -157,6 +254,12 @@ embeds — plus a large batch of security hardening and test coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||
separately-hosted cross-domain client must now be listed in
|
||||
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||
are still allowed.
|
||||
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
|
||||
when the workspace HTML-embed toggle is on, can be inserted by any member
|
||||
(previously admin-only). Turning the toggle off hides existing embeds and
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 в хлебных крошках.
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,15 @@
|
||||
"Move page": "Move page",
|
||||
"Move page to a different space.": "Move page to a different space.",
|
||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||
"Syncing changes…": "Syncing changes…",
|
||||
"All changes synced": "All changes synced",
|
||||
"Update available": "Update available",
|
||||
"Reload": "Reload",
|
||||
"Make available offline": "Make available offline",
|
||||
"Saving page for offline use...": "Saving page for offline use...",
|
||||
"Page is now available offline": "Page is now available offline",
|
||||
"Failed to make page available offline": "Failed to make page available offline",
|
||||
"Table of contents": "Table of contents",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||
"Share": "Share",
|
||||
@@ -598,6 +607,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Make temporary": "Make temporary",
|
||||
"Make permanent": "Make permanent",
|
||||
"New temporary note": "New temporary note",
|
||||
"Temporary note": "Temporary note",
|
||||
"Temporary notes": "Temporary notes",
|
||||
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||
"Note is now permanent": "Note is now permanent",
|
||||
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -715,6 +735,8 @@
|
||||
"Test": "Test",
|
||||
"Available tools": "Available tools",
|
||||
"No tools available": "No tools available",
|
||||
"Failed": "Failed",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Created successfully",
|
||||
"Deleted successfully": "Deleted successfully",
|
||||
"Clear": "Clear",
|
||||
@@ -1167,8 +1189,9 @@
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"Tokens generated this turn": "Tokens generated this turn",
|
||||
"Context size / model limit": "Context size / model limit",
|
||||
"Context window (tokens)": "Context window (tokens)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
@@ -1177,6 +1200,8 @@
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Send now": "Send now",
|
||||
"Interrupt and send now": "Interrupt and send now",
|
||||
"Stop": "Stop",
|
||||
"Response stopped.": "Response stopped.",
|
||||
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
|
||||
@@ -1315,5 +1340,23 @@
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)"
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address",
|
||||
"Generate title with AI": "Generate title with AI",
|
||||
"Title generated": "Title generated",
|
||||
"Failed to generate title": "Failed to generate title",
|
||||
"The note is empty": "The note is empty",
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later"
|
||||
}
|
||||
|
||||
@@ -474,6 +474,15 @@
|
||||
"Move page": "Переместить страницу",
|
||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||
"Syncing changes…": "Синхронизация изменений…",
|
||||
"All changes synced": "Все изменения синхронизированы",
|
||||
"Update available": "Доступно обновление",
|
||||
"Reload": "Перезагрузить",
|
||||
"Make available offline": "Сделать доступным офлайн",
|
||||
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||
"Page is now available offline": "Страница доступна офлайн",
|
||||
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||
"Table of contents": "Оглавление",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||
"Share": "Поделиться",
|
||||
@@ -607,6 +616,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Make temporary": "Сделать временной",
|
||||
"Make permanent": "Сделать постоянной",
|
||||
"New temporary note": "Новая временная заметка",
|
||||
"Temporary note": "Временная заметка",
|
||||
"Temporary notes": "Временные заметки",
|
||||
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||
"Note is now permanent": "Заметка теперь постоянная",
|
||||
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Permanently delete": "Удалить навсегда",
|
||||
@@ -704,19 +724,27 @@
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
@@ -1169,5 +1197,23 @@
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)"
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
|
||||
"Generate title with AI": "Сгенерировать название через AI",
|
||||
"Title generated": "Название сгенерировано",
|
||||
"Failed to generate title": "Не удалось сгенерировать название",
|
||||
"The note is empty": "Заметка пустая",
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -161,12 +162,6 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||
// context size below.
|
||||
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -193,6 +188,7 @@ export default function AiChatWindow() {
|
||||
const {
|
||||
threadKey,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
@@ -215,12 +211,25 @@ export default function AiChatWindow() {
|
||||
// just-failed chat after they chose a fresh one.
|
||||
const startNewChat = useCallback((): void => {
|
||||
cancelPendingAdoption();
|
||||
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
|
||||
// while a brand-new chat's first turn is still streaming leaves activeChatId
|
||||
// null (the real id is adopted only at turn end), so setActiveChatId(null)
|
||||
// alone is a no-op and the reconciler never remounts — the chat/stream/history
|
||||
// would persist and only the role badge would drop. This always remounts the
|
||||
// thread into a clean new chat.
|
||||
startFreshThread();
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
}, [
|
||||
cancelPendingAdoption,
|
||||
startFreshThread,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -287,24 +296,19 @@ export default function AiChatWindow() {
|
||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
||||
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||
// mid-stream — acceptable for v1.
|
||||
const contextTokens = useMemo(() => {
|
||||
if (!activeChatId || !messageRows) return 0;
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
return meta.contextTokens;
|
||||
}
|
||||
const usage = meta.usage;
|
||||
if (usage) {
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) return fallback;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
//
|
||||
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||
// on a completed turn, but the numerator and denominator are taken from the
|
||||
// most recent row carrying EACH value independently — they may land on
|
||||
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
|
||||
// no row has it (older rows, or no admin-configured limit) — the badge then
|
||||
// shows just the current size with no denominator.
|
||||
const { contextTokens, maxContextTokens } = useMemo(
|
||||
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||
[activeChatId, messageRows],
|
||||
);
|
||||
|
||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||
// first-frame jump): compute an initial top-right placement the first time,
|
||||
@@ -495,20 +499,17 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||
once it finishes, fall back to the persisted context size. Require
|
||||
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||
flash a "0" badge before any token streams in (#151 review). */}
|
||||
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(liveTurnTokens)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : contextTokens > 0 ? (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
{/* Always show the persisted "current / max" context. The denominator
|
||||
(the admin-configured model limit) is appended only when known;
|
||||
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||
Hidden entirely until a turn has recorded a context figure. */}
|
||||
{contextTokens > 0 ? (
|
||||
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(contextTokens)}
|
||||
{maxContextTokens > 0
|
||||
? ` / ${formatTokens(maxContextTokens)}`
|
||||
: ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -622,6 +623,7 @@ export default function AiChatWindow() {
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
threadKey={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
@@ -634,7 +636,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
142
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
142
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
// the spies back to the test body.
|
||||
const h = vi.hoisted(() => ({
|
||||
state: {
|
||||
status: "streaming" as string,
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
|
||||
function renderThread() {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("tags exactly the next send as interrupted (one-shot flag)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
it("sends immediately without an interrupt when not streaming", () => {
|
||||
h.state.status = "ready";
|
||||
renderThread();
|
||||
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconClockHour4,
|
||||
IconPlayerPlayFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@@ -20,10 +24,10 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
@@ -46,6 +50,11 @@ export interface OpenPageContext {
|
||||
interface ChatThreadProps {
|
||||
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
||||
chatId: string | null;
|
||||
/** This thread's mount key (the same value the parent uses as React `key`).
|
||||
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
|
||||
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
|
||||
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
|
||||
threadKey?: string;
|
||||
/** Persisted rows to seed initial messages (existing chats only). */
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
@@ -67,20 +76,16 @@ interface ChatThreadProps {
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for a
|
||||
* new chat, adopts the freshly created chat id. `serverChatId` is the
|
||||
* authoritative id the server streamed on the assistant message metadata, or
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
|
||||
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
|
||||
* finishing on a thread already abandoned by New chat mid-stream (#161). */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
|
||||
* chat id streamed on the assistant message metadata, so a brand-new chat
|
||||
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||
* parent then reverts the badge to the persisted context size). */
|
||||
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +122,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
*/
|
||||
export default function ChatThread({
|
||||
chatId,
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
@@ -125,7 +131,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
onLiveTurnTokens,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -201,12 +206,25 @@ export default function ChatThread({
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// "Send now" single-flight flags. Kept in refs (not state) so they are read
|
||||
// inside the stable `onFinish` callback and the transport closure WITHOUT a
|
||||
// re-render or a stale closure. Both are one-shot (read-and-clear).
|
||||
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
|
||||
// though an aborted turn normally keeps the queue intact.
|
||||
// - interruptNextSendRef: tag the next send as a user interrupt so the server
|
||||
// injects the "your previous answer was interrupted" note for that turn only.
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return;
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
@@ -232,17 +250,26 @@ export default function ChatThread({
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
// are read live from refs so changing chats/pages does NOT recreate the
|
||||
// transport. `openPage` is null on a non-page route.
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
// Read-and-clear the interrupt flag so the "you were interrupted" note
|
||||
// is carried by ONLY this request (the one resending the promoted
|
||||
// message right after we aborted the previous turn). The server still
|
||||
// confirms it against history before acting on it.
|
||||
const interrupted = interruptNextSendRef.current;
|
||||
interruptNextSendRef.current = false; // one-shot
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
interrupted,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -267,14 +294,31 @@ export default function ChatThread({
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design.
|
||||
onTurnFinished(extractServerChatId(message));
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
// session ignore this finish if it belongs to a thread abandoned by New chat
|
||||
// mid-stream (#161).
|
||||
onTurnFinished(extractServerChatId(message), threadKey);
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
// interrupt note travels with this send via interruptNextSendRef.
|
||||
if (flushOnAbortRef.current) {
|
||||
flushOnAbortRef.current = false;
|
||||
// Suppress the "Response stopped." flash for an intentional interrupt.
|
||||
setStopNotice(null);
|
||||
// If the promoted head vanished (e.g. the user removed it before the
|
||||
// abort landed) flushNext sends nothing — clear the one-shot interrupt
|
||||
// tag so it can't leak onto the next unrelated send. On a real send the
|
||||
// tag is consumed by prepareSendMessagesRequest and stays untouched.
|
||||
if (!flushNext()) interruptNextSendRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
@@ -289,13 +333,20 @@ export default function ChatThread({
|
||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||
// the UI separately shows a friendly classified banner (see errorView).
|
||||
console.error("AI chat stream error:", streamError);
|
||||
onTurnFinished();
|
||||
onTurnFinished(undefined, threadKey);
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
|
||||
// CURRENT status rather than a value captured in a stale render closure — a turn
|
||||
// can finish between render and click, and arming the interrupt refs against a
|
||||
// no-op stop() would leave them set to leak into a later, unrelated Stop.
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
|
||||
// on the assistant message metadata at the `start` chunk (message.metadata.
|
||||
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
|
||||
@@ -327,9 +378,49 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
// queue/flush machinery: promote the target to the head, then abort — the
|
||||
// onFinish flush-on-abort branch sends exactly that head, tagged as an
|
||||
// interrupt so the server notes the previous answer was cut off.
|
||||
const sendNow = useCallback(
|
||||
(id: string) => {
|
||||
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
|
||||
// the turn may have finished between this render and the click, in which case
|
||||
// stop() is a no-op and arming the interrupt refs would strand them for a
|
||||
// later, unrelated Stop. Reading the ref always sees the current status.
|
||||
const liveStreaming =
|
||||
statusRef.current === "submitted" || statusRef.current === "streaming";
|
||||
if (liveStreaming) {
|
||||
// Promote to head so the onFinish -> flushNext path sends exactly it.
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
|
||||
} else {
|
||||
// Nothing to interrupt: just send it now (no interrupt note).
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
);
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
|
||||
// the race where a flag was armed but the expected abort never fired (the turn
|
||||
// finished in the same tick as the click), so it cannot leak into a later turn.
|
||||
useEffect(() => {
|
||||
if (isStreaming) setStopNotice(null);
|
||||
if (isStreaming) {
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
@@ -338,53 +429,6 @@ export default function ChatThread({
|
||||
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||
// the badge to the persisted context size.
|
||||
const lastEmitRef = useRef(0);
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!onLiveTurnTokens) return;
|
||||
if (!isStreaming) {
|
||||
// Turn ended (or never started): clear any pending throttle and revert.
|
||||
if (emitTimerRef.current) {
|
||||
clearTimeout(emitTimerRef.current);
|
||||
emitTimerRef.current = null;
|
||||
}
|
||||
lastEmitRef.current = 0;
|
||||
onLiveTurnTokens(null);
|
||||
return;
|
||||
}
|
||||
const tail = messages[messages.length - 1];
|
||||
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||
const total = live ? live.reasoning + live.output : 0;
|
||||
const now = Date.now();
|
||||
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||
const elapsed = now - lastEmitRef.current;
|
||||
if (elapsed >= MIN_INTERVAL) {
|
||||
lastEmitRef.current = now;
|
||||
onLiveTurnTokens(total);
|
||||
} else if (!emitTimerRef.current) {
|
||||
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||
emitTimerRef.current = setTimeout(() => {
|
||||
emitTimerRef.current = null;
|
||||
lastEmitRef.current = Date.now();
|
||||
onLiveTurnTokens(total);
|
||||
}, MIN_INTERVAL - elapsed);
|
||||
}
|
||||
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||
|
||||
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||
// trailing emit can't fire into a torn-down thread's parent.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
@@ -468,6 +512,17 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
import type { UseChatSessionOptions } from "./use-chat-session";
|
||||
|
||||
@@ -227,6 +227,50 @@ describe("useChatSession", () => {
|
||||
expect(result.current.threadKey).toBe("C");
|
||||
});
|
||||
|
||||
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
|
||||
// Brand-new chat whose first turn is still streaming: the id is adopted only
|
||||
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
|
||||
// chat" must still remount to a clean thread even though the atom is unchanged
|
||||
// — the render-phase reconciler (null === null) would otherwise do nothing,
|
||||
// leaving the old chat/stream/history in place (the bug: only the role badge
|
||||
// dropped).
|
||||
const { result } = setup({ activeChatId: null, chats: { items: [] } });
|
||||
const keyBefore = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
|
||||
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
|
||||
// abort the abandoned stream on unmount: its onFinish still fires later with
|
||||
// the real server id, tagged with the OLD (abandoned) mount key. That must not
|
||||
// adopt — it would yank the user back into the chat they just left.
|
||||
const { result, setActiveChatId, onInvalidateChatList } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const abandonedKey = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(abandonedKey);
|
||||
// The abandoned turn finishes in the background, streaming its real id "A".
|
||||
result.current.onTurnFinished("A", abandonedKey);
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
|
||||
// It still refreshes the chat list so the left-behind chat shows in history.
|
||||
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
|
||||
// The happy path must keep working: onTurnFinished tagged with the mounted
|
||||
// thread's own key adopts in place as before.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const currentKey = result.current.threadKey;
|
||||
result.current.onTurnFinished("A", currentKey);
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
||||
// Open an existing chat whose history is still loading => loader on.
|
||||
const { result, rerender } = setup({
|
||||
|
||||
@@ -31,9 +31,19 @@ export interface UseChatSessionResult {
|
||||
threadKey: string;
|
||||
/** Show the history loader instead of the live thread. */
|
||||
waitingForHistory: boolean;
|
||||
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
|
||||
* even when `activeChatId` is unchanged. The window calls this from
|
||||
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
|
||||
* still streaming (activeChatId still null, nothing to diverge) actually
|
||||
* resets the chat instead of only dropping the role badge (#161). */
|
||||
startFreshThread: () => void;
|
||||
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
||||
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
|
||||
* thread that produced the turn (omit => "current thread", back-compatible):
|
||||
* a turn ABANDONED by New chat mid-stream still fires this after its thread
|
||||
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
|
||||
* new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
||||
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
||||
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
||||
@@ -98,6 +108,15 @@ export function useChatSession(
|
||||
: switchThread(activeChatId),
|
||||
);
|
||||
|
||||
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
|
||||
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
|
||||
// does not abort a stream on unmount and proxies callbacks through a ref, so an
|
||||
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
|
||||
// matching its key against this ref keeps that late finish from adopting the
|
||||
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
|
||||
const threadKeyRef = useRef(thread.key);
|
||||
threadKeyRef.current = thread.key;
|
||||
|
||||
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
||||
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
||||
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
||||
@@ -115,7 +134,23 @@ export function useChatSession(
|
||||
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
||||
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
||||
const onTurnFinished = useCallback(
|
||||
(serverChatId?: string) => {
|
||||
(serverChatId?: string, finishingThreadKey?: string) => {
|
||||
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
|
||||
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
|
||||
// not abort on unmount and proxies callbacks through a ref). If that late
|
||||
// finish ran the adoption path it would set activeChatId to the abandoned
|
||||
// chat's real id and yank the user out of the fresh chat they just opened.
|
||||
// So adopt / arm the fallback ONLY for the still-mounted thread; an
|
||||
// abandoned one merely refreshes the chat list (so the left-behind chat
|
||||
// surfaces in history) and does nothing else. A missing key (undefined)
|
||||
// means "current thread" — keeps old call sites/tests working.
|
||||
if (
|
||||
finishingThreadKey !== undefined &&
|
||||
finishingThreadKey !== threadKeyRef.current
|
||||
) {
|
||||
onInvalidateChatList();
|
||||
return;
|
||||
}
|
||||
// Read the live id from the ref, not the closure: on a failed turn this can
|
||||
// run twice in one turn (onFinish + onError) before any re-render, and the
|
||||
// primary branch below updates the ref so the second call sees the adopted id.
|
||||
@@ -258,9 +293,28 @@ export function useChatSession(
|
||||
pendingNewChatRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
|
||||
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
|
||||
// so "New chat" pressed while a brand-new chat's first turn is still streaming
|
||||
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
|
||||
// end of the turn) is a no-op for it and the abandoned thread/stream/history
|
||||
// would persist. Dispatching reconcile with a fresh key and chatId:null here
|
||||
// always produces a new mount key, so React remounts ChatThread (a clean useChat
|
||||
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
|
||||
// keeps the reconciler from interfering. Also disarms any pending fallback.
|
||||
const startFreshThread = useCallback(() => {
|
||||
pendingNewChatRef.current = null;
|
||||
dispatch({
|
||||
type: "reconcile",
|
||||
chatId: null,
|
||||
newKey: `new-${generateId()}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
threadKey: thread.key,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
|
||||
@@ -68,6 +68,19 @@ export async function exportAiChat(
|
||||
return req.data.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from note content (markdown). One-shot, non-streaming
|
||||
* (#199): the server only summarizes the supplied text and returns a suggestion;
|
||||
* it never writes the page. The caller applies the title via /pages/update.
|
||||
*/
|
||||
export async function generatePageTitle(content: string): Promise<string> {
|
||||
const req = await api.post<{ title: string }>(
|
||||
"/ai-chat/generate-page-title",
|
||||
{ content },
|
||||
);
|
||||
return req.data.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface IAiChatMessageRow {
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||
// floating window's header badge.
|
||||
contextTokens?: number;
|
||||
// The model's max context window (denominator for the header badge); set
|
||||
// alongside contextTokens on a completed turn; absent on older rows.
|
||||
maxContextTokens?: number;
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
|
||||
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
estimateTokens,
|
||||
liveTurnTokens,
|
||||
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
@@ -25,147 +13,3 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — estimate path", () => {
|
||||
it("is all zeros for an undefined message", () => {
|
||||
expect(liveTurnTokens(undefined)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is all zeros for a parts-less message", () => {
|
||||
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates output from text parts", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||
});
|
||||
|
||||
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "12345678" },
|
||||
{ type: "text", text: "abcd" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||
});
|
||||
|
||||
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcd" }, // 1
|
||||
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcdefgh" }, // 2
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||
});
|
||||
|
||||
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-getPage", state: "input-available" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — authoritative path", () => {
|
||||
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
|
||||
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "x" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 42 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||
});
|
||||
|
||||
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||
});
|
||||
|
||||
it("falls back to the estimate when metadata has no usage object", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
|
||||
it("ticks the in-flight step above the completed-steps authoritative base", () => {
|
||||
// The authoritative usage is the sum over COMPLETED steps (step 1). The
|
||||
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
|
||||
// the parts -> the running estimate must push the live figure above the base
|
||||
// so the badge keeps growing between step boundaries.
|
||||
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: longText }], {
|
||||
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
|
||||
}),
|
||||
);
|
||||
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
|
||||
expect(r.output).toBe(200);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
|
||||
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "reasoning", text: longReasoning }], {
|
||||
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
|
||||
}),
|
||||
);
|
||||
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
|
||||
expect(r.reasoning).toBe(100);
|
||||
expect(r.output).toBe(0);
|
||||
expect(r.authoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
|
||||
// Short on-screen text (estimate tiny) but a large authoritative output:
|
||||
// the exact figure wins at the boundary (the counter never under-reports).
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 5000 },
|
||||
}),
|
||||
);
|
||||
expect(r.output).toBe(5000);
|
||||
});
|
||||
|
||||
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
|
||||
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||
* once the server attaches it on a step/turn boundary (see the server's
|
||||
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
|
||||
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||
* bundle, and be wrong for Gemini/Ollama anyway).
|
||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||
* ("Thinking · N tokens").
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -24,90 +17,3 @@ export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||
export interface AuthoritativeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||
export interface LiveTurnTokens {
|
||||
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||
reasoning: number;
|
||||
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||
output: number;
|
||||
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||
authoritative: boolean;
|
||||
}
|
||||
|
||||
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||
const meta = message?.metadata as
|
||||
| { usage?: AuthoritativeUsage }
|
||||
| undefined;
|
||||
const usage = meta?.usage;
|
||||
if (!usage || typeof usage !== "object") return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token split for the given (streaming) assistant message.
|
||||
*
|
||||
* COMBINES the authoritative server usage with the running text estimate so the
|
||||
* counter ticks in real time AND lands exact. The server only attaches
|
||||
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
|
||||
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
|
||||
* So a multi-step turn that returned the authoritative figure verbatim would
|
||||
* FREEZE between boundaries and jump in steps (issue #163).
|
||||
*
|
||||
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
|
||||
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
|
||||
* per-component MAX of the authoritative base and the estimate:
|
||||
* - between boundaries the estimate of the in-flight step ticks the number up;
|
||||
* - at a boundary the authoritative figure snaps it to exact;
|
||||
* - because the server's usage is cumulative and we only ever take the max, the
|
||||
* number is MONOTONIC — it never drops.
|
||||
*
|
||||
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
|
||||
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
|
||||
*/
|
||||
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||
|
||||
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
|
||||
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
|
||||
let estReasoning = 0;
|
||||
let estOutput = 0;
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "reasoning") {
|
||||
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||
} else if (part.type === "text") {
|
||||
estOutput += estimateTokens((part as { text?: string }).text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const usage = metadataUsage(message);
|
||||
if (!usage) {
|
||||
// No authoritative usage streamed yet: the estimate IS the live figure.
|
||||
return { reasoning: estReasoning, output: estOutput, authoritative: false };
|
||||
}
|
||||
|
||||
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
|
||||
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
|
||||
// figure (never go negative if a provider reports them inconsistently).
|
||||
const authReasoning = usage.reasoningTokens ?? 0;
|
||||
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
|
||||
|
||||
// Per-component max: the in-flight step's estimate ticks above the completed-
|
||||
// steps base between boundaries, and the authoritative figure wins once it
|
||||
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
|
||||
return {
|
||||
reasoning: Math.max(authReasoning, estReasoning),
|
||||
output: Math.max(authOutput, estOutput),
|
||||
authoritative: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enqueueMessage,
|
||||
dequeue,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "./queue-helpers";
|
||||
@@ -89,6 +90,52 @@ describe("removeQueuedById", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteToHead", () => {
|
||||
it("moves the matching id to the front, preserving the rest's order", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
expect(promoteToHead(queue, "c")).toEqual([
|
||||
{ id: "c", text: "third" },
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op order-wise when the id is already the head", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "a")).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an equivalent list when the id is not present", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "missing")).toEqual(queue);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
promoteToHead(queue, "b");
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO order", () => {
|
||||
it("preserves order across enqueue -> dequeue", () => {
|
||||
let queue: QueuedMessage[] = [];
|
||||
|
||||
@@ -32,3 +32,16 @@ export function removeQueuedById(
|
||||
): QueuedMessage[] {
|
||||
return queue.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
/** Move the queued message with the given id to the FRONT (returns a new array).
|
||||
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
|
||||
* "send now" action: promoting a message to the head lets the existing
|
||||
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
|
||||
export function promoteToHead(
|
||||
queue: QueuedMessage[],
|
||||
id: string,
|
||||
): QueuedMessage[] {
|
||||
const target = queue.find((m) => m.id === id);
|
||||
if (!target) return queue;
|
||||
return [target, ...queue.filter((m) => m.id !== id)];
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
color?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI "generate title" button (#199). Reads the live editor content and applies a
|
||||
* model-suggested title immediately. Rendered in the page byline, only in edit
|
||||
* mode and when the workspace's generative AI flag is on.
|
||||
*/
|
||||
export const GenerateTitleGroup: FC<Props> = ({
|
||||
pageId,
|
||||
color = "gray",
|
||||
iconSize = 20,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const gen = useGeneratePageTitle(pageId);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={color}
|
||||
aria-label={t("Generate title with AI")}
|
||||
loading={gen.isPending}
|
||||
onClick={() => gen.mutate()}
|
||||
>
|
||||
<IconSparkles size={iconSize} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -104,6 +104,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
|
||||
which pushes the first text line ~0.5em below the "N." marker (aligned to
|
||||
flex-start), making the number float above the text. Drop the outer margins
|
||||
so the marker and the first line share the same top edge — same approach
|
||||
used for callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,17 +26,22 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
|
||||
|
||||
type PageUser = {
|
||||
id: string;
|
||||
@@ -74,6 +79,9 @@ export function FullEditor({
|
||||
const [user] = useAtom(userAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
// AI title generation reuses the generative AI flag (same gate as the on-page
|
||||
// generative menu); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
@@ -84,6 +92,10 @@ export function FullEditor({
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||
|
||||
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||
// editors (title lives in the 'title' fragment of the same doc).
|
||||
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||
|
||||
// Apply the user's saved preference only once on initial load, not on every
|
||||
// page navigation — so the mode sticks across navigations within a session.
|
||||
useEffect(() => {
|
||||
@@ -103,44 +115,55 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<EditorProvidersContext.Provider
|
||||
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</EditorProvidersContext.Provider>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
pageId: string;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
editable?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDictationEnabled?: boolean;
|
||||
isTitleGenEnabled?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
pageId,
|
||||
creator,
|
||||
contributors,
|
||||
editable,
|
||||
isEditMode,
|
||||
isDictationEnabled,
|
||||
isTitleGenEnabled,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
@@ -148,6 +171,9 @@ function PageByline({
|
||||
const showDictation = Boolean(
|
||||
isDictationEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
const showTitleGen = Boolean(
|
||||
isTitleGenEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -238,6 +264,11 @@ function PageByline({
|
||||
{showDictation && editor && (
|
||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||
)}
|
||||
{/* Shown only in edit mode when the workspace's generative AI flag is on,
|
||||
so AI title generation stays reachable from the byline (#199). */}
|
||||
{showTitleGen && (
|
||||
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// --- Mocks for the hook's collaborators ---------------------------------------
|
||||
|
||||
const generatePageTitleMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
||||
}));
|
||||
|
||||
const updateTitleMock = vi.fn();
|
||||
const updatePageDataMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
||||
updatePageData: (page: unknown) => updatePageDataMock(page),
|
||||
}));
|
||||
|
||||
const emitMock = vi.fn();
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => emitMock,
|
||||
}));
|
||||
|
||||
const localEmitMock = vi.fn();
|
||||
vi.mock("@/lib/local-emitter.ts", () => ({
|
||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||
}));
|
||||
|
||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||
// purely via the fake page editor's getHTML().
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
htmlToMarkdown: (html: string) => html,
|
||||
}));
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Import after mocks are registered.
|
||||
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||
|
||||
// --- Test helpers -------------------------------------------------------------
|
||||
|
||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
getHTML: () => html,
|
||||
storage: { pageId },
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
function makeTitleEditor(): Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
isFocused: false,
|
||||
commands: { setContent: vi.fn() },
|
||||
} as unknown as Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
}
|
||||
|
||||
function setup(pageId: string, store = createStore()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
||||
wrapper,
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
const PAGE_A = {
|
||||
id: "pageA",
|
||||
title: "Generated Title",
|
||||
spaceId: "space1",
|
||||
slugId: "slugA",
|
||||
parentPageId: null,
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useGeneratePageTitle", () => {
|
||||
it("shows a notice and bails when the editor content is empty", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
||||
);
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the title untouched when the model returns nothing usable", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockResolvedValue(" ");
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Could not generate a title",
|
||||
color: "yellow",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
generatePageTitleMock.mockResolvedValue("Generated Title");
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Title generated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Control when generation resolves so we can navigate mid-flight.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// User navigates to page B: the live page editor now belongs to pageB.
|
||||
act(() => {
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// DB write is still correct (keyed by the captured pageId)...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bails before calling the model when the page editor is destroyed", async () => {
|
||||
const store = createStore();
|
||||
const pageEditor = makePageEditor("pageA");
|
||||
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
||||
store.set(pageEditorAtom as never, pageEditor);
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[403, "AI title generation is disabled"],
|
||||
[503, "AI is not configured"],
|
||||
[429, "Too many requests, please try again later"],
|
||||
[500, "Failed to generate title"],
|
||||
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message, color: "red" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
// Maximum length we send to the model. The server truncates again; this is a
|
||||
// cheap client-side bound so we never ship a huge body over the wire.
|
||||
const MAX_CONTENT_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* Generate a title for the given page from the LIVE editor content (#199),
|
||||
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
|
||||
* server endpoint only summarizes the supplied markdown — it never writes the
|
||||
* page; the actual title write goes through the existing /pages/update mutation
|
||||
* (which enforces edit permission), and is mirrored to the title field + other
|
||||
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
|
||||
* button can show a loading state via `isPending`.
|
||||
*/
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
|
||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
||||
if (!markdown) {
|
||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (
|
||||
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
|
||||
).trim();
|
||||
if (!title) {
|
||||
// The model returned nothing usable — keep the existing title untouched.
|
||||
notifications.show({
|
||||
message: t("Could not generate a title"),
|
||||
color: "yellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
|
||||
notifications.show({ message: t("Title generated") });
|
||||
},
|
||||
onError: (err) => {
|
||||
// Map known HTTP statuses to friendly messages, falling back to generic.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
const message =
|
||||
status === 403
|
||||
? t("AI title generation is disabled")
|
||||
: status === 503
|
||||
? t("AI is not configured")
|
||||
: status === 429
|
||||
? t("Too many requests, please try again later")
|
||||
: t("Failed to generate title");
|
||||
notifications.show({ message, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -6,16 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -28,13 +19,15 @@ import {
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
@@ -58,10 +51,8 @@ import {
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -72,9 +63,7 @@ import {
|
||||
GitmostInsertRecordingResult,
|
||||
gitmostInsertRecordingIntoEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
@@ -99,7 +88,6 @@ export default function PageEditor({
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -113,22 +101,10 @@ export default function PageEditor({
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
// Always holds the latest collab token. The provider effect below runs once
|
||||
// per pageId, so a handler created inside it would otherwise close over a
|
||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
collabTokenRef.current = collabQuery?.token;
|
||||
}, [collabQuery?.token]);
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
@@ -137,141 +113,27 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSynced(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSynced(event.state);
|
||||
};
|
||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
||||
// may be stale). Guard the decode: a missing or unparseable token must
|
||||
// not throw "Invalid token specified" and should trigger a refresh so
|
||||
// the editor reconnects even when the initial token fetch failed.
|
||||
const token = collabTokenRef.current;
|
||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||
if (token) {
|
||||
try {
|
||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
||||
} catch {
|
||||
needsRefresh = true;
|
||||
}
|
||||
}
|
||||
if (!needsRefresh) return;
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
onStateless: onStatelessHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||
// the context (defensive) — in practice full-editor always provides it.
|
||||
const editorProviders = useEditorProviders();
|
||||
const remote = editorProviders?.remote ?? null;
|
||||
const providersReady = editorProviders?.providersReady ?? false;
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
if (!providersReady || !remote || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
...collabExtensions(remote, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
}, [providersReady, remote, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -513,7 +375,7 @@ export default function PageEditor({
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock factories are hoisted above imports, so the spies they reference must
|
||||
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
|
||||
const h = vi.hoisted(() => ({
|
||||
clear: vi.fn(),
|
||||
del: vi.fn(),
|
||||
}));
|
||||
|
||||
// The module under test imports the app entry at load time — it must be mocked.
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { clear: h.clear },
|
||||
}));
|
||||
vi.mock("idb-keyval", () => ({
|
||||
del: h.del,
|
||||
}));
|
||||
|
||||
import { clearOfflineCache } from "./clear-offline-cache";
|
||||
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||
|
||||
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
|
||||
// globals are stubbed per-test. We restore them afterwards.
|
||||
const originalIndexedDB = (globalThis as any).indexedDB;
|
||||
const originalCaches = (globalThis as any).caches;
|
||||
|
||||
beforeEach(() => {
|
||||
h.clear.mockClear();
|
||||
h.del.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).indexedDB = originalIndexedDB;
|
||||
(globalThis as any).caches = originalCaches;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("clearOfflineCache", () => {
|
||||
it("resolves without throwing when the browser globals are absent", async () => {
|
||||
(globalThis as any).indexedDB = undefined;
|
||||
delete (globalThis as any).caches;
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
|
||||
// The two store-agnostic steps still run.
|
||||
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
|
||||
});
|
||||
|
||||
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
|
||||
const deleteDatabase = vi.fn((_name: string) => {
|
||||
const request: any = {};
|
||||
// Resolve the deletion on the next microtask, like a real IDBRequest.
|
||||
queueMicrotask(() => request.onsuccess && request.onsuccess());
|
||||
return request;
|
||||
});
|
||||
(globalThis as any).indexedDB = {
|
||||
databases: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: "page.aaa" },
|
||||
{ name: "page.bbb" },
|
||||
{ name: "keyval-store" },
|
||||
{ name: undefined },
|
||||
]),
|
||||
deleteDatabase,
|
||||
};
|
||||
|
||||
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||
(globalThis as any).caches = {
|
||||
keys: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
"workbox-runtime-https://app/api-get-cache",
|
||||
"other-cache",
|
||||
]),
|
||||
delete: cacheDelete,
|
||||
};
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
|
||||
// Only the two page.* databases are deleted.
|
||||
expect(deleteDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
|
||||
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
|
||||
|
||||
// Only the api-get-cache entry is deleted.
|
||||
expect(cacheDelete).toHaveBeenCalledTimes(1);
|
||||
expect(cacheDelete).toHaveBeenCalledWith(
|
||||
"workbox-runtime-https://app/api-get-cache",
|
||||
);
|
||||
});
|
||||
|
||||
it("never throws even if a step rejects (best-effort)", async () => {
|
||||
h.del.mockRejectedValueOnce(new Error("idb boom"));
|
||||
(globalThis as any).indexedDB = {
|
||||
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
|
||||
deleteDatabase: vi.fn(),
|
||||
};
|
||||
(globalThis as any).caches = {
|
||||
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
92
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
92
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { del } from "idb-keyval";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||
|
||||
/**
|
||||
* Best-effort purge of all of the current user's offline data from the browser.
|
||||
*
|
||||
* On logout the previous user's private data would otherwise linger locally and
|
||||
* be readable by the next person on the device. This clears the three offline
|
||||
* stores the app writes:
|
||||
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
|
||||
* `OFFLINE_CACHE_KEY`),
|
||||
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
|
||||
* y-indexeddb in make-offline.ts), and
|
||||
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
|
||||
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
|
||||
* rule was removed — offline reads come from the persisted RQ cache), so
|
||||
* this is now a defensive cleanup for caches left by older app versions.
|
||||
*
|
||||
* Fully best-effort: every step is isolated so a single failure neither blocks
|
||||
* the remaining steps nor throws to the caller (logout must never be blocked on
|
||||
* cache cleanup). Callers may ignore the resolved value.
|
||||
*
|
||||
* Limitations:
|
||||
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
|
||||
* is unavailable in some browsers (notably Firefox). There we skip silently;
|
||||
* those `page.<id>` databases are then left in place.
|
||||
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
|
||||
* service-worker-capable browsers).
|
||||
*/
|
||||
export async function clearOfflineCache(): Promise<void> {
|
||||
// 1a. Drop the in-memory query cache immediately.
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch {
|
||||
// best-effort: ignore in-memory cache reset failures
|
||||
}
|
||||
|
||||
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||
try {
|
||||
await del(OFFLINE_CACHE_KEY);
|
||||
} catch {
|
||||
// best-effort: ignore persisted-cache deletion failures
|
||||
}
|
||||
|
||||
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||
// it is missing we cannot enumerate the page databases, so we skip silently.
|
||||
try {
|
||||
if (
|
||||
typeof indexedDB !== "undefined" &&
|
||||
typeof indexedDB.databases === "function"
|
||||
) {
|
||||
const dbs = await indexedDB.databases();
|
||||
for (const db of dbs) {
|
||||
const name = db?.name;
|
||||
if (typeof name !== "string" || !name.startsWith("page.")) continue;
|
||||
try {
|
||||
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||
// not race the page teardown, but never reject on it.
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(name);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// best-effort per database
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort: ignore enumeration/deletion failures
|
||||
}
|
||||
|
||||
// 3. Clear any legacy service worker API cache. Current builds no longer
|
||||
// create it, but an older client may have left an "api-get-cache" entry
|
||||
// (Workbox may prefix the name), so match by substring rather than exact name.
|
||||
try {
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.includes("api-get-cache"))
|
||||
.map((key) => caches.delete(key)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// best-effort: ignore Cache Storage failures
|
||||
}
|
||||
}
|
||||
258
apps/client/src/features/offline/make-offline.test.ts
Normal file
258
apps/client/src/features/offline/make-offline.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||
// inspected by the assertions below.
|
||||
const h = vi.hoisted(() => ({
|
||||
ydocDestroy: vi.fn(),
|
||||
idbDestroy: vi.fn(),
|
||||
providerOn: vi.fn(),
|
||||
providerOff: vi.fn(),
|
||||
providerDestroy: vi.fn(),
|
||||
}));
|
||||
|
||||
// The module under test imports the app entry at load time — it must be mocked.
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
getPageById: vi.fn(),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
getSidebarPages: vi.fn(),
|
||||
getAllSidebarPages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||
getSpaceById: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||
// value when the module under test calls `new Y.Doc()` etc.
|
||||
vi.mock("yjs", () => ({
|
||||
Doc: vi.fn(function () {
|
||||
return { destroy: h.ydocDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("y-indexeddb", () => ({
|
||||
IndexeddbPersistence: vi.fn(function () {
|
||||
return { destroy: h.idbDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@hocuspocus/provider", () => ({
|
||||
HocuspocusProvider: vi.fn(function () {
|
||||
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
warmInfiniteAll,
|
||||
warmPageYdoc,
|
||||
makePageAvailableOffline,
|
||||
} from "./make-offline";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
getPageById,
|
||||
getPageBreadcrumbs,
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||
|
||||
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||
// objects and break the provider/idb/yjs spies).
|
||||
setQueryData.mockClear();
|
||||
prefetchQuery.mockReset();
|
||||
prefetchQuery.mockResolvedValue(undefined);
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
|
||||
h.ydocDestroy.mockClear();
|
||||
h.idbDestroy.mockClear();
|
||||
h.providerOn.mockClear();
|
||||
h.providerOff.mockClear();
|
||||
h.providerDestroy.mockClear();
|
||||
});
|
||||
|
||||
describe("warmInfiniteAll", () => {
|
||||
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||
pages: [res],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
});
|
||||
|
||||
it("walks the cursor chain across multiple pages", async () => {
|
||||
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||
const r2 = { items: [], meta: { nextCursor: null } };
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(r0)
|
||||
.mockResolvedValueOnce(r1)
|
||||
.mockResolvedValueOnce(r2);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||
undefined,
|
||||
"c1",
|
||||
"c2",
|
||||
]);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("caps pagination at maxPages", async () => {
|
||||
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
|
||||
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("reports errors (returns false) and never writes the cache on failure", async () => {
|
||||
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||
).resolves.toBe(false);
|
||||
expect(setQueryData).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePageAvailableOffline", () => {
|
||||
const okPage = {
|
||||
id: "uuid-1",
|
||||
slugId: "slug-1",
|
||||
space: { slug: "space-slug" },
|
||||
};
|
||||
|
||||
it("returns ok:true with no failures when every step succeeds", async () => {
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, failed: [] });
|
||||
});
|
||||
|
||||
it("returns ok:false with the failed step label when a warm step fails", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Comments warm fails -> labeled "comments".
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toContain("comments");
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("warmPageYdoc", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Grab the synced handler the provider registered.
|
||||
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
const handler = h.providerOn.mock.calls.find(
|
||||
(c) => c[0] === "synced",
|
||||
)![1] as () => void;
|
||||
|
||||
handler();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
// Listener detached and everything cleaned up.
|
||||
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||
handler();
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves and cleans up after the timeout when synced never fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
272
apps/client/src/features/offline/make-offline.ts
Normal file
272
apps/client/src/features/offline/make-offline.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as Y from "yjs";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
getPageById,
|
||||
getPageBreadcrumbs,
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
pageKeys,
|
||||
sidebarPagesQueryOptions,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
/**
|
||||
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
|
||||
*
|
||||
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
|
||||
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
|
||||
* spinning forever offline, and silently truncates large lists. This walks the
|
||||
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||
*
|
||||
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
|
||||
* but it is reported — the error is logged with context and `false` is returned
|
||||
* so the caller can record the failed step instead of silently succeeding.
|
||||
*
|
||||
* Returns true if the whole list was paginated and written, false on any error.
|
||||
*
|
||||
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||
*/
|
||||
export async function warmInfiniteAll<T>(
|
||||
queryKey: readonly unknown[],
|
||||
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||
maxPages = 50,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const pages: IPagination<T>[] = [];
|
||||
const pageParams: (string | undefined)[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const res = await fetchPage(cursor);
|
||||
pages.push(res);
|
||||
pageParams.push(cursor);
|
||||
cursor = res?.meta?.nextCursor ?? undefined;
|
||||
if (!cursor) break;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakePageAvailableOfflineParams {
|
||||
pageId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
|
||||
* step succeeded; `failed` lists the labels of the steps that failed (a subset
|
||||
* of: "page", "space", "tree", "breadcrumbs", "comments").
|
||||
*/
|
||||
export interface MakePageAvailableOfflineResult {
|
||||
ok: boolean;
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort prefetch of a page's read queries so they get persisted to
|
||||
* IndexedDB and become readable offline.
|
||||
*
|
||||
* Each step is isolated and this function does NOT throw — a partial warm is
|
||||
* still useful. Instead of silently succeeding, every failed step is logged
|
||||
* with a label and recorded in the returned result: `{ ok, failed }` where
|
||||
* `ok` is true only if no step failed and `failed` lists the failed step
|
||||
* labels. Only meaningful while online (the underlying requests must succeed).
|
||||
*/
|
||||
export async function makePageAvailableOffline({
|
||||
pageId,
|
||||
spaceId,
|
||||
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
|
||||
const failed: string[] = [];
|
||||
|
||||
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
|
||||
// like usePageQuery's onData effect. Every page consumer reads
|
||||
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
|
||||
// so warming only the uuid key would leave the offline page blank.
|
||||
let page: IPage | undefined;
|
||||
try {
|
||||
page = await getPageById({ pageId });
|
||||
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
|
||||
queryClient.setQueryData(pageKeys.detail(page.id), page);
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: page step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("page");
|
||||
}
|
||||
|
||||
// Warm the space — page.tsx renders nothing until the space query resolves
|
||||
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
|
||||
// the space is actually persisted before the caller fires its toast. Shares
|
||||
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
|
||||
try {
|
||||
const spaceSlug = page?.space?.slug;
|
||||
if (spaceSlug) {
|
||||
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: space step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("space");
|
||||
}
|
||||
|
||||
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
|
||||
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
|
||||
// Fully paginated so large root levels are not truncated at 100.
|
||||
if (spaceId) {
|
||||
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
|
||||
getSidebarPages({ spaceId, cursor, limit: 100 }),
|
||||
);
|
||||
if (!ok) failed.push("tree");
|
||||
}
|
||||
|
||||
// Warm the children of the page and of every ancestor so the path to this
|
||||
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
|
||||
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
|
||||
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
|
||||
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
|
||||
// would never be read by the offline tree.
|
||||
const warmSidebarChildren = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
|
||||
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
|
||||
const params = { pageId: id, spaceId };
|
||||
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: tree node step failed", {
|
||||
pageId: id,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The page's own children.
|
||||
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
|
||||
|
||||
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
|
||||
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
|
||||
// (the UI derives the path from the tree).
|
||||
try {
|
||||
const ancestors = (await getPageBreadcrumbs(pageId)) as
|
||||
| Array<{ id?: string }>
|
||||
| undefined;
|
||||
for (const ancestor of ancestors ?? []) {
|
||||
const ancestorId = ancestor?.id;
|
||||
if (!ancestorId || ancestorId === pageId) continue;
|
||||
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: breadcrumbs step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("breadcrumbs");
|
||||
}
|
||||
|
||||
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
|
||||
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
|
||||
// only the first page leaves the offline comments panel spinning forever on
|
||||
// pages with >100 comments. Fully paginate so the last cached page has no
|
||||
// nextCursor and the panel settles offline.
|
||||
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
|
||||
getPageComments({ pageId, cursor, limit: 100 }),
|
||||
);
|
||||
if (!commentsOk) failed.push("comments");
|
||||
|
||||
// Dedupe — the tree label can be recorded once per failed node/ancestor.
|
||||
const uniqueFailed = [...new Set(failed)];
|
||||
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
|
||||
* can open offline.
|
||||
*
|
||||
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
|
||||
* pull the server state into IndexedDB, then tears both down once synced (or
|
||||
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
|
||||
*
|
||||
* Only meaningful when online at warm time; offline it is a no-op that resolves.
|
||||
*/
|
||||
export async function warmPageYdoc(
|
||||
pageId: string,
|
||||
collabUrl: string,
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
let ydoc: Y.Doc | null = null;
|
||||
let local: IndexeddbPersistence | null = null;
|
||||
let remote: HocuspocusProvider | null = null;
|
||||
|
||||
try {
|
||||
const documentName = `page.${pageId}`;
|
||||
ydoc = new Y.Doc();
|
||||
local = new IndexeddbPersistence(documentName, ydoc);
|
||||
remote = new HocuspocusProvider({
|
||||
url: collabUrl,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token,
|
||||
});
|
||||
|
||||
const provider = remote;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Clear the pending timeout and detach the listener so neither leaks
|
||||
// after we resolve.
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
try {
|
||||
provider.off("synced", finish);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Resolve once the server state has synced into the local doc...
|
||||
provider.on("synced", finish);
|
||||
// ...or give up after a short timeout so we never hang.
|
||||
timeoutId = setTimeout(finish, 8000);
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
try {
|
||||
remote?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
local?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
ydoc?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
84
apps/client/src/features/offline/query-persister.test.ts
Normal file
84
apps/client/src/features/offline/query-persister.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
shouldDehydrateOfflineQuery,
|
||||
OFFLINE_PERSIST_ROOTS,
|
||||
} from "./query-persister";
|
||||
|
||||
// Small helper to build the structural query shape the predicate reads.
|
||||
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||
({ state: { status }, queryKey }) as any;
|
||||
|
||||
describe("shouldDehydrateOfflineQuery", () => {
|
||||
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(
|
||||
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the status is not success (status gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty/undefined queryKey", () => {
|
||||
// String(undefined) is not a member of the allowlist.
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||
it("contains exactly the expected 8 navigation/read roots", () => {
|
||||
const expected = [
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
];
|
||||
expect(OFFLINE_PERSIST_ROOTS.size).toBe(8);
|
||||
for (const root of expected) {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain volatile/auth keys", () => {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||
});
|
||||
});
|
||||
50
apps/client/src/features/offline/query-persister.ts
Normal file
50
apps/client/src/features/offline/query-persister.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
|
||||
// Structural subset of a TanStack Query we read when deciding what to persist.
|
||||
// We avoid importing the branded `Query` class because the persist-client and
|
||||
// react-query may resolve to different `@tanstack/query-core` copies, whose
|
||||
// `Query` types are nominally incompatible (private brand). This structural
|
||||
// shape stays assignable to whichever copy the persister expects.
|
||||
type DehydratableQuery = {
|
||||
state: { status: string };
|
||||
queryKey: readonly unknown[];
|
||||
};
|
||||
|
||||
// idb-keyval key under which TanStack Query persists its dehydrated cache.
|
||||
// Exported so the logout cache-clear logic deletes the exact same key (no
|
||||
// magic-string drift between persist and purge).
|
||||
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
|
||||
|
||||
// IndexedDB-backed storage adapter for TanStack Query's async persister.
|
||||
const idbStorage = {
|
||||
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||
setItem: (key: string, value: string) => set(key, value),
|
||||
removeItem: (key: string) => del(key),
|
||||
};
|
||||
|
||||
export const queryPersister = createAsyncStoragePersister({
|
||||
storage: idbStorage,
|
||||
key: OFFLINE_CACHE_KEY,
|
||||
throttleTime: 1000,
|
||||
});
|
||||
|
||||
// Only navigation/read query roots are persisted for offline reading.
|
||||
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
]);
|
||||
|
||||
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||
return (
|
||||
query.state.status === "success" &&
|
||||
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,40 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
} from "@/features/page-embed/services/page-embed-api";
|
||||
import type {
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||
* Centralised here so the header menu and the banner can't drift apart on the
|
||||
* cache-key plumbing.
|
||||
*/
|
||||
export function syncTemporaryExpiresInCache(
|
||||
page: { id: string; slugId: string },
|
||||
temporaryExpiresAt: string | null,
|
||||
) {
|
||||
for (const key of [page.slugId, page.id]) {
|
||||
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData(["pages", key], {
|
||||
...cached,
|
||||
temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +51,20 @@ export function useToggleTemplateMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemporaryMutation() {
|
||||
return useMutation<
|
||||
ToggleTemporaryResponse,
|
||||
Error,
|
||||
{ pageId: string; temporary?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemporary(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message || "Failed to update temporary note",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemporary(params: {
|
||||
pageId: string;
|
||||
temporary?: boolean;
|
||||
}): Promise<ToggleTemporaryResponse> {
|
||||
const r = await api.post("/pages/toggle-temporary", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ToggleTemporaryResponse = {
|
||||
pageId: string;
|
||||
// null => the note was made permanent; ISO string => armed deadline.
|
||||
temporaryExpiresAt: string | null;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconPrinter,
|
||||
IconCloud,
|
||||
IconCloudCheck,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
@@ -24,6 +27,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -34,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
@@ -160,6 +169,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!page?.temporaryExpiresAt;
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
if (!page?.id) return;
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline in the page cache so the menu label flips and
|
||||
// any banner updates. The sidebar icon refreshes via its own query.
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -309,6 +341,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleToggleTemporary}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
@@ -377,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
function ConnectionWarning() {
|
||||
const { t } = useTranslation();
|
||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisconnected) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||
@@ -396,7 +436,7 @@ function ConnectionWarning() {
|
||||
}
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [yjsConnectionStatus]);
|
||||
}, [isDisconnected]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
@@ -407,22 +447,59 @@ function ConnectionWarning() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showWarning) return null;
|
||||
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||
// existing >5s debounce before surfacing this state.
|
||||
if (isDisconnected) {
|
||||
if (!showWarning) return null;
|
||||
|
||||
const offlineLabel = t(
|
||||
"Offline — changes are saved locally and will sync when you reconnect",
|
||||
);
|
||||
return (
|
||||
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
role="status"
|
||||
aria-label={offlineLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (2): connected but the remote replica is not fully caught up yet.
|
||||
if (!isRemoteSynced || !isLocalSynced) {
|
||||
const syncingLabel = t("Syncing changes…");
|
||||
return (
|
||||
<Tooltip label={syncingLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={syncingLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCloud size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (3): fully synced — subtle confirmation indicator.
|
||||
const syncedLabel = t("All changes synced");
|
||||
return (
|
||||
<Tooltip
|
||||
label={t("Real-time editor connection lost. Retrying...")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
||||
aria-label={syncedLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
<IconCloudCheck size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
type TemporaryNoteBannerProps = {
|
||||
slugId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Banner shown on an open temporary note ("structure or die"). Mirrors
|
||||
* DeletedPageBanner: it reads the page from the shared query cache and offers
|
||||
* the explicit rescue action — "Make permanent". Children ride along to trash
|
||||
* with the note, which is noted in the copy.
|
||||
*/
|
||||
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: false,
|
||||
});
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">
|
||||
<Trans
|
||||
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
||||
values={{ time: expiresTimeAgo }}
|
||||
/>
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
@@ -43,11 +44,36 @@ import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factories for page queries. The hooks below and
|
||||
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||
* runtime keys can never silently drift apart.
|
||||
*/
|
||||
export const pageKeys = {
|
||||
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
|
||||
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
|
||||
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
|
||||
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
|
||||
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
|
||||
* fetchAllAncestorChildren and the offline warm path consume this so the key,
|
||||
* queryFn and staleTime stay identical.
|
||||
*/
|
||||
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
|
||||
queryOptions({
|
||||
queryKey: pageKeys.sidebar(params),
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function usePageQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
): UseQueryResult<IPage, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["pages", pageInput.pageId],
|
||||
queryKey: pageKeys.detail(pageInput.pageId),
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -56,9 +82,9 @@ export function usePageQuery(
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -80,18 +106,20 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||
pageKeys.detail(data.slugId),
|
||||
);
|
||||
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
@@ -145,11 +173,11 @@ export function useRemovePageMutation() {
|
||||
});
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (cached) {
|
||||
const stamped = { ...cached, deletedAt: new Date() };
|
||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||
}
|
||||
|
||||
invalidateOnDeletePage(pageId);
|
||||
@@ -270,8 +298,11 @@ export function useRestorePageMutation() {
|
||||
// Replace would strip space/permissions/content and break the editor.
|
||||
const merge = (cached: IPage | undefined) =>
|
||||
cached ? { ...cached, ...restoredPage } : cached;
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||
queryClient.setQueryData<IPage>(
|
||||
pageKeys.detail(restoredPage.slugId),
|
||||
merge,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
@@ -286,7 +317,7 @@ export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams | null,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryKey: pageKeys.sidebar(data),
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||
@@ -297,7 +328,7 @@ export function useGetSidebarPagesQuery(
|
||||
|
||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({
|
||||
spaceId: data.spaceId,
|
||||
@@ -323,7 +354,7 @@ export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["breadcrumbs", pageId],
|
||||
queryKey: pageKeys.breadcrumbs(pageId),
|
||||
queryFn: () => getPageBreadcrumbs(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
@@ -335,10 +366,12 @@ export async function fetchAllAncestorChildren(
|
||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||
opts?: { fresh?: boolean },
|
||||
) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
|
||||
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
|
||||
// this fetch never drift, but override staleTime for the `fresh` reconnect
|
||||
// refresh (#159 #8), which must force a server refetch (staleTime 0).
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
...sidebarPagesQueryOptions(params),
|
||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -348,7 +381,7 @@ export async function fetchAllAncestorChildren(
|
||||
|
||||
export function useRecentChangesQuery(spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
@@ -414,12 +447,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
queryKey = pageKeys.sidebar({
|
||||
pageId: data.parentPageId,
|
||||
spaceId: data.spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
@@ -479,7 +512,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -503,7 +536,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", data.spaceId],
|
||||
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,9 +550,9 @@ export function invalidateOnUpdatePage(
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
queryKey = pageKeys.rootSidebar(spaceId);
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
@@ -542,7 +575,7 @@ export function invalidateOnUpdatePage(
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -557,8 +590,8 @@ export function updateCacheOnMovePage(
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
@@ -578,7 +611,7 @@ export function updateCacheOnMovePage(
|
||||
if (oldParentId !== null) {
|
||||
const oldParentCache = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IPage>>
|
||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||
|
||||
const remainingChildren =
|
||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||
@@ -616,8 +649,8 @@ export function updateCacheOnMovePage(
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCloudDownload,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -30,7 +32,16 @@ import {
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
||||
import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} from "@/features/page-embed/queries/page-embed-query";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
makePageAvailableOffline,
|
||||
warmPageYdoc,
|
||||
} from "@/features/offline/make-offline";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
@@ -65,6 +76,42 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const isFavorited = favoriteIds.has(node.id);
|
||||
const toggleTemplate = useToggleTemplateMutation();
|
||||
const isTemplate = !!node.isTemplate;
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!node.temporaryExpiresAt;
|
||||
const { data: collabQuery } = useCollabToken();
|
||||
|
||||
const handleMakeAvailableOffline = async () => {
|
||||
notifications.show({ message: t("Saving page for offline use...") });
|
||||
try {
|
||||
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||
// reports whether every warm step succeeded.
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: node.id,
|
||||
spaceId: node.spaceId,
|
||||
});
|
||||
// Best-effort: warm the page's Yjs document into IndexedDB.
|
||||
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
||||
|
||||
if (result.ok) {
|
||||
notifications.show({ message: t("Page is now available offline") });
|
||||
} else {
|
||||
// Partial warm — the page may still be partly usable offline, but some
|
||||
// queries failed to cache, so surface it as an error rather than a
|
||||
// silent success.
|
||||
notifications.show({
|
||||
message: t("Failed to make page available offline"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||
// unexpected failures stay guarded here.
|
||||
notifications.show({
|
||||
message: t("Failed to make page available offline"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
@@ -84,6 +131,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: node.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline locally so the icon/menu update immediately.
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, node.id, {
|
||||
temporaryExpiresAt: res.temporaryExpiresAt,
|
||||
} as any),
|
||||
);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
||||
@@ -202,6 +272,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{t("Export")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCloudDownload size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleMakeAvailableOffline();
|
||||
}}
|
||||
>
|
||||
{t("Make available offline")}
|
||||
</Menu.Item>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Item
|
||||
@@ -248,6 +329,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleTemporary();
|
||||
}}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{node.temporaryExpiresAt && (
|
||||
<Tooltip
|
||||
// Children ride along to trash with the note (recursive removePage).
|
||||
label={t("Temporary note — moves to trash unless made permanent")}
|
||||
withArrow
|
||||
>
|
||||
<IconClockHour4
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
// Same visual-only indicator pattern as the template icon, but
|
||||
// orange to flag the impending death timer.
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginLeft: rem(4),
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
}}
|
||||
aria-label={t("Temporary note")}
|
||||
role="img"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
useCreatePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -22,8 +21,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
handleCreate: (parentId: string | null) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleCreate: (
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -35,7 +36,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// children) and then immediately invokes a handler.
|
||||
const store = useStore();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
@@ -119,9 +119,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
|
||||
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
||||
const payload: {
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
temporary?: boolean;
|
||||
} = { spaceId };
|
||||
if (parentId) payload.parentPageId = parentId;
|
||||
// Ask the server to arm the death timer for a "temporary note".
|
||||
if (opts?.temporary) payload.temporary = true;
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
@@ -138,6 +144,8 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
hasChildren: false,
|
||||
// Show the temporary-note icon immediately on optimistic insert.
|
||||
temporaryExpiresAt: createdPage.temporaryExpiresAt,
|
||||
children: [],
|
||||
};
|
||||
|
||||
@@ -181,20 +189,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
||||
);
|
||||
try {
|
||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||
} catch (error) {
|
||||
console.error("Error updating page title:", error);
|
||||
}
|
||||
},
|
||||
[updatePageMutation, setData],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const node = treeModel.find(
|
||||
@@ -240,7 +234,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
return { handleMove, handleCreate, handleDelete };
|
||||
}
|
||||
|
||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||
|
||||
@@ -752,6 +752,27 @@ describe("treeModel.placeByPosition", () => {
|
||||
});
|
||||
expect(t.map((n) => n.id)).toEqual(["r1", "child", "r2", "rp"]);
|
||||
});
|
||||
|
||||
it("returns same reference (no-op) when the destination parent is inside the source's own subtree (#206 ui-state-races-1)", () => {
|
||||
// Moving `a` under its own descendant `b` is a cycle. Without the guard,
|
||||
// remove(a) drops b too and insertByPosition can't re-place a -> the whole
|
||||
// subtree silently vanishes. The guard refuses the move (same reference).
|
||||
const cyclic: P[] = [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
position: "a0",
|
||||
children: [{ id: "b", name: "B", position: "a1" }],
|
||||
},
|
||||
];
|
||||
const t = treeModel.placeByPosition(cyclic, "a", {
|
||||
parentId: "b",
|
||||
position: "a5",
|
||||
});
|
||||
expect(t).toBe(cyclic);
|
||||
expect(treeModel.find(t, "a")).not.toBeNull();
|
||||
expect(treeModel.find(t, "b")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeModel.move", () => {
|
||||
|
||||
@@ -294,6 +294,20 @@ export const treeModel = {
|
||||
const source = treeModel.find(tree, sourceId);
|
||||
if (!source) return tree;
|
||||
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
|
||||
// Cycle guard, mirroring `move`'s `isDescendant` check (#206 ui-state-races-1).
|
||||
// If the destination parent is INSIDE the moved node's own subtree (reachable
|
||||
// when server-authoritative move events arrive out of order — e.g. X moved
|
||||
// under Y, then Y under X, but on this receiver Y is still inside X), then
|
||||
// `remove(sourceId)` would drop the future parent along with the whole subtree
|
||||
// and `insertByPosition` could not find it again — the node and ALL its
|
||||
// descendants would silently vanish. Refuse the move and return the same
|
||||
// reference so callers can detect the no-op and reconcile (refetch) instead.
|
||||
if (
|
||||
to.parentId !== null &&
|
||||
treeModel.isDescendant(tree, sourceId, to.parentId)
|
||||
) {
|
||||
return tree;
|
||||
}
|
||||
const removed = treeModel.remove(tree, sourceId);
|
||||
// Reuse the same position-ordered insertion as `insertByPosition` by
|
||||
// stamping the authoritative position onto the moved node first.
|
||||
|
||||
@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface IPage {
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
// Create-only input flag: ask the server to arm the timer on a new page.
|
||||
temporary?: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
useRemoveShareAliasMutation,
|
||||
useSetShareAliasMutation,
|
||||
useShareAliasForPageQuery,
|
||||
} from "@/features/share/queries/share-query.ts";
|
||||
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
interface ShareAliasSectionProps {
|
||||
pageId: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
|
||||
function aliasPrefixLabel(): string {
|
||||
const url = getAppUrl();
|
||||
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
return `${host}/l/`;
|
||||
}
|
||||
|
||||
export default function ShareAliasSection({
|
||||
pageId,
|
||||
readOnly,
|
||||
}: ShareAliasSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
|
||||
const setAliasMutation = useSetShareAliasMutation();
|
||||
const removeAliasMutation = useRemoveShareAliasMutation();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
currentPageTitle: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Seed the input from the page's current alias (if any).
|
||||
useEffect(() => {
|
||||
setValue(currentAlias?.alias ?? "");
|
||||
}, [currentAlias?.alias, pageId]);
|
||||
|
||||
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
|
||||
const isValid = isValidShareAlias(normalized);
|
||||
const unchanged = currentAlias?.alias === normalized;
|
||||
|
||||
// Debounced availability probe (skips when invalid or unchanged).
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
setAvailability(null);
|
||||
if (!isValid || unchanged) return;
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkShareAliasAvailability(normalized);
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [normalized, isValid, unchanged]);
|
||||
|
||||
const prettyLink = currentAlias?.alias
|
||||
? `${getAppUrl()}/l/${currentAlias.alias}`
|
||||
: null;
|
||||
|
||||
const handleSave = async (confirmReassign = false) => {
|
||||
try {
|
||||
await setAliasMutation.mutateAsync({
|
||||
pageId,
|
||||
alias: normalized,
|
||||
confirmReassign,
|
||||
});
|
||||
setReassign(null);
|
||||
} catch (error: any) {
|
||||
// The address already points at another page: prompt to move it here.
|
||||
if (error?.status === 409 || error?.response?.status === 409) {
|
||||
const data = error?.response?.data;
|
||||
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
|
||||
setReassign({
|
||||
alias: normalized,
|
||||
currentPageTitle: data?.currentPageTitle ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!currentAlias?.id) return;
|
||||
await removeAliasMutation.mutateAsync(currentAlias.id);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
{prettyLink && (
|
||||
<Group my="xs" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={prettyLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={prettyLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={prettyLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
// Show the canonical form once the user pauses so what they type maps
|
||||
// visibly to what gets stored.
|
||||
onBlur={() => setValue(normalized)}
|
||||
leftSection={
|
||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
||||
{aliasPrefixLabel()}
|
||||
</Text>
|
||||
}
|
||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="xs" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
loading={setAliasMutation.isPending}
|
||||
disabled={readOnly || !isValid || unchanged}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{currentAlias?.id && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
loading={removeAliasMutation.isPending}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={!!reassign}
|
||||
onClose={() => setReassign(null)}
|
||||
title={t("Move custom address?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
{reassign?.currentPageTitle
|
||||
? t(
|
||||
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
|
||||
{
|
||||
alias: reassign?.alias,
|
||||
title: reassign?.currentPageTitle,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'The address "{{alias}}" is already in use. Move it to this page?',
|
||||
{ alias: reassign?.alias },
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setReassign(null)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => handleSave(true)}
|
||||
loading={setAliasMutation.isPending}
|
||||
>
|
||||
{t("Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
{pageId && (
|
||||
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -20,11 +22,14 @@ import {
|
||||
import {
|
||||
createShare,
|
||||
deleteShare,
|
||||
getShareAliasForPage,
|
||||
getSharedPageTree,
|
||||
getShareForPage,
|
||||
getShareInfo,
|
||||
getSharePageInfo,
|
||||
getShares,
|
||||
removeShareAlias,
|
||||
setShareAlias,
|
||||
updateShare,
|
||||
} from "@/features/share/services/share-service.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareAliasForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareAlias | null, Error> {
|
||||
return useQuery({
|
||||
// The endpoint resolves to null when the page has no alias; normalize the
|
||||
// absence so React Query never sees `undefined`.
|
||||
queryKey: ["share-alias-for-page", pageId],
|
||||
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IShareAlias, Error, ISetShareAlias>({
|
||||
mutationFn: (data) => setShareAlias(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
// A 409 reassign-required is handled inline by the modal (it shows the
|
||||
// "move address here?" confirmation), so don't surface a generic toast.
|
||||
if (error?.["status"] === 409) return;
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message || t("Failed to set custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (aliasId) => removeShareAlias(aliasId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message ||
|
||||
t("Failed to remove custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSharedPageTreeQuery(
|
||||
shareId: string,
|
||||
): UseQueryResult<ISharedPageTree, Error> {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
IShareAliasAvailability,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
|
||||
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareAliasForPage(
|
||||
pageId: string,
|
||||
): Promise<IShareAlias | null> {
|
||||
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setShareAlias(
|
||||
data: ISetShareAlias,
|
||||
): Promise<IShareAlias> {
|
||||
const req = await api.post<IShareAlias>("/share-aliases/set", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removeShareAlias(aliasId: string): Promise<void> {
|
||||
await api.post("/share-aliases/remove", { aliasId });
|
||||
}
|
||||
|
||||
export async function checkShareAliasAvailability(
|
||||
alias: string,
|
||||
): Promise<IShareAliasAvailability> {
|
||||
const req = await api.post<IShareAliasAvailability>(
|
||||
"/share-aliases/availability",
|
||||
{ alias },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
// Mirrors the server-side util so the modal's live feedback matches what the
|
||||
// server will accept/store.
|
||||
describe("normalizeShareAlias", () => {
|
||||
it("lowercases, trims and maps separators to single hyphens", () => {
|
||||
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
|
||||
});
|
||||
|
||||
it("collapses repeated hyphens and trims edges", () => {
|
||||
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidShareAlias", () => {
|
||||
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
|
||||
expect(isValidShareAlias("hello-world")).toBe(true);
|
||||
expect(isValidShareAlias("a".repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
|
||||
expect(isValidShareAlias("a")).toBe(false);
|
||||
expect(isValidShareAlias("-a")).toBe(false);
|
||||
expect(isValidShareAlias("a--b")).toBe(false);
|
||||
expect(isValidShareAlias("Hello")).toBe(false);
|
||||
expect(isValidShareAlias("привет")).toBe(false);
|
||||
});
|
||||
});
|
||||
26
apps/client/src/features/share/share-alias.util.ts
Normal file
26
apps/client/src/features/share/share-alias.util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Client copy of the vanity share-alias helpers. Kept in sync with the server
|
||||
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
|
||||
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
|
||||
* separated, length 2..60.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === "string" &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,30 @@ export interface IShareInfoInput {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
// Vanity /l/:alias pointer.
|
||||
export interface IShareAlias {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
alias: string;
|
||||
pageId: string | null;
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISetShareAlias {
|
||||
pageId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHourglass,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconStar,
|
||||
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
|
||||
handleCreate(null);
|
||||
}
|
||||
|
||||
function handleCreateTemporaryPage() {
|
||||
handleCreate(null, { temporary: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.navbar}>
|
||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Create page")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Standalone second button: a "temporary note" auto-moves to
|
||||
trash after the workspace lifetime unless made permanent. */}
|
||||
<Tooltip
|
||||
label={t("New temporary note")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreateTemporaryPage}
|
||||
aria-label={t("New temporary note")}
|
||||
>
|
||||
<IconHourglass />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factories for space queries. The hooks below and
|
||||
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||
* runtime keys can never silently drift apart.
|
||||
*/
|
||||
export const spaceKeys = {
|
||||
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
|
||||
list: (params?: QueryParams) => ["spaces", params] as const,
|
||||
members: (spaceId: string, query?: string) =>
|
||||
["spaceMembers", spaceId, query] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared queryOptions for fetching a space by id/slug. Both
|
||||
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
|
||||
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
|
||||
* prefetchQuery ignores it anyway and the warm path always passes a real id;
|
||||
* the hook reapplies `enabled` itself.)
|
||||
*/
|
||||
export const spaceByIdQueryOptions = (spaceId: string) =>
|
||||
queryOptions({
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function useGetSpacesQuery(
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["spaces", params],
|
||||
queryKey: spaceKeys.list(params),
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(spaceId)) {
|
||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
}
|
||||
|
||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
|
||||
// a 5min staleTime which would let this prefetch skip fetching fresh data;
|
||||
// prefetchSpace must always refetch (default staleTime: 0).
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["space", spaceSlug],
|
||||
queryKey: spaceKeys.detail(spaceSlug),
|
||||
queryFn: () => getSpaceById(spaceSlug),
|
||||
});
|
||||
|
||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
...spaceByIdQueryOptions(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Space updated successfully") });
|
||||
|
||||
const space = queryClient.getQueryData([
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
const space = queryClient.getQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||
queryClient.setQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
updatedSpace,
|
||||
);
|
||||
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
||||
|
||||
if (variables.slug) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.slug],
|
||||
queryKey: spaceKeys.detail(variables.slug),
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
||||
// Remove space-specific queries
|
||||
if (variables.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.id],
|
||||
queryKey: spaceKeys.detail(variables.id),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
||||
query?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["spaceMembers", spaceId, query],
|
||||
queryKey: spaceKeys.members(spaceId, query),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||
enabled: !!spaceId,
|
||||
|
||||
@@ -183,6 +183,34 @@ describe("applyMoveTreeNode", () => {
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe("a4");
|
||||
});
|
||||
|
||||
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
|
||||
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
|
||||
// applied yet). An out-of-order "move a under b" event now arrives — b is a
|
||||
// descendant of a, so re-parenting would make placeByPosition remove a (and
|
||||
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
|
||||
// and b silently vanished; now the reducer leaves the tree untouched.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("a", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("b", { position: "a1", parentPageId: "a" })],
|
||||
}),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "a",
|
||||
parentId: "b",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
// No silent data loss: both nodes survive.
|
||||
expect(treeModel.find(next, "a")).not.toBeNull();
|
||||
expect(treeModel.find(next, "b")).not.toBeNull();
|
||||
// The cyclic move is refused as a no-op (same reference) pending reconcile.
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeleteTreeNode", () => {
|
||||
|
||||
@@ -76,6 +76,19 @@ export function applyMoveTreeNode(
|
||||
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
|
||||
// Cyclic / out-of-order move guard (#206 ui-state-races-1): if the
|
||||
// authoritative new parent is currently INSIDE the moved node's own subtree on
|
||||
// this client (e.g. server moved X under Y then Y under X and the events
|
||||
// arrived such that Y is still nested in X here), re-parenting is impossible to
|
||||
// represent locally. `placeByPosition` returns `prev` for this, but the
|
||||
// `placed === prev` fallback below would then `remove` the source — dropping
|
||||
// the node AND every descendant (incl. the would-be parent) silently. Leave the
|
||||
// tree untouched instead; a later corrective event or a reconnect refetch
|
||||
// reconciles it. Never delete a subtree we cannot safely re-place.
|
||||
if (newParentId && treeModel.isDescendant(prev, payload.id, newParentId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Place the node by its fractional `position` among the new siblings — NOT by
|
||||
// the sender's absolute `index` (the sender computed that against its own
|
||||
// loaded set, which differs from this receiver's). Using the position keeps
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the inline "Test" button presentation. Covers the four
|
||||
* states (idle / loading is handled by the component's `isPending`, so here:
|
||||
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
|
||||
* branches that are easiest to break silently.
|
||||
*/
|
||||
// Identity-ish translator that echoes the key and interpolates {{n}} so the
|
||||
// label/tooltip branches are observable without the real i18n bundle.
|
||||
const t = (key: string, options?: Record<string, unknown>): string =>
|
||||
options && "n" in options
|
||||
? key.replace("{{n}}", String((options as { n: unknown }).n))
|
||||
: key;
|
||||
|
||||
describe("mcpTestButtonView", () => {
|
||||
it("idle when there is no result", () => {
|
||||
expect(mcpTestButtonView(undefined, t)).toEqual({
|
||||
state: "idle",
|
||||
color: undefined,
|
||||
variant: "default",
|
||||
label: "Test",
|
||||
tooltip: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("ok with tools lists them in the tooltip", () => {
|
||||
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: "OK · 2",
|
||||
tooltip: "a, b",
|
||||
});
|
||||
});
|
||||
|
||||
it('ok with zero tools shows "No tools available"', () => {
|
||||
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: "OK · 0",
|
||||
tooltip: "No tools available",
|
||||
});
|
||||
});
|
||||
|
||||
it("failed surfaces the error text in the tooltip", () => {
|
||||
expect(
|
||||
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
|
||||
).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "402: nope",
|
||||
});
|
||||
});
|
||||
|
||||
it("failed when the request itself rejects (no result payload)", () => {
|
||||
// 401/403/500/network: there is no { ok } body, only a thrown error. The
|
||||
// row must still show a red "Failed" rather than reverting to idle "Test".
|
||||
expect(
|
||||
mcpTestButtonView(undefined, t, {
|
||||
response: { data: { message: "Unauthorized" } },
|
||||
}),
|
||||
).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("reject without a server message falls back to the generic label", () => {
|
||||
// A bare network error (no response body) still surfaces as failed, using
|
||||
// the i18n fallback for the tooltip.
|
||||
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: "Failed",
|
||||
tooltip: "Failed to update data",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
|
||||
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** Subset of an axios-style rejection we read for the reject tooltip. */
|
||||
type McpTestRequestError = {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort extraction of a server-sent message from a rejected test request
|
||||
* (axios stores it at `error.response.data.message`). Returns undefined for a
|
||||
* bare/network error so the caller can fall back to a generic label.
|
||||
*/
|
||||
function readRequestErrorMessage(error: unknown): string | undefined {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
return (error as McpTestRequestError).response?.data?.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation for the inline "Test" button, derived from the current test
|
||||
* result tristate (no result yet / ok / failed). Color is never the only signal
|
||||
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
|
||||
* pure derivation (rather than two parallel if/else chains) so the button and
|
||||
* tooltip can never drift apart, and so the text branches are unit-testable
|
||||
* without rendering the row.
|
||||
*/
|
||||
export interface McpTestButtonView {
|
||||
/** Tristate; the component maps this to the leftSection icon. */
|
||||
state: "idle" | "ok" | "failed";
|
||||
/** Mantine Button color; undefined = theme default (idle). */
|
||||
color?: string;
|
||||
/** Mantine Button variant. */
|
||||
variant: string;
|
||||
/** Translated button label. */
|
||||
label: string;
|
||||
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export function mcpTestButtonView(
|
||||
result: IAiMcpServerTestResult | undefined,
|
||||
t: Translate,
|
||||
error?: unknown,
|
||||
): McpTestButtonView {
|
||||
if (result?.ok) {
|
||||
return {
|
||||
state: "ok",
|
||||
color: "green",
|
||||
variant: "light",
|
||||
label: t("OK · {{n}}", { n: result.tools.length }),
|
||||
tooltip:
|
||||
result.tools.length > 0
|
||||
? result.tools.join(", ")
|
||||
: t("No tools available"),
|
||||
};
|
||||
}
|
||||
if (result && result.ok === false) {
|
||||
return {
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: t("Failed"),
|
||||
tooltip: result.error,
|
||||
};
|
||||
}
|
||||
if (error) {
|
||||
// The test request itself rejected (401/403/500/network) — there is no
|
||||
// `{ ok }` payload, so without this branch the row would silently revert to
|
||||
// the idle "Test" instead of reporting the failure. Tooltip prefers the
|
||||
// server-sent message, else the generic i18n fallback.
|
||||
return {
|
||||
state: "failed",
|
||||
color: "red",
|
||||
variant: "light",
|
||||
label: t("Failed"),
|
||||
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "idle",
|
||||
color: undefined,
|
||||
variant: "default",
|
||||
label: t("Test"),
|
||||
tooltip: "",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -10,18 +10,28 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconPencil,
|
||||
IconPlugConnected,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiMcpServersQuery,
|
||||
useDeleteAiMcpServerMutation,
|
||||
useTestAiMcpServerMutation,
|
||||
useUpdateAiMcpServerMutation,
|
||||
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
|
||||
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
|
||||
|
||||
/**
|
||||
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
{servers?.map((server) => (
|
||||
<Group key={server.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||
>
|
||||
{server.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: server.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<AiMcpServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
onEdit={openEdit}
|
||||
onDelete={confirmDelete}
|
||||
onToggleEnabled={(enabled) =>
|
||||
updateMutation.mutate({ id: server.id, enabled })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
interface AiMcpServerRowProps {
|
||||
server: IAiMcpServer;
|
||||
onEdit: (server: IAiMcpServer) => void;
|
||||
onDelete: (server: IAiMcpServer) => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single external MCP server row: name/badge/url on the left and the
|
||||
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
|
||||
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
|
||||
* independent per row (a shared mutation would make `isPending` global and make
|
||||
* every row flicker).
|
||||
*/
|
||||
function AiMcpServerRow({
|
||||
server,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
}: AiMcpServerRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const testMutation = useTestAiMcpServerMutation();
|
||||
const result = testMutation.data;
|
||||
|
||||
// The row is keyed by `server.id`, so editing the connection-relevant fields
|
||||
// (url/transport/headers) does NOT remount it — an old success/failure result
|
||||
// would otherwise stick. Clear the result when those fields change.
|
||||
useEffect(() => {
|
||||
testMutation.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.url, server.transport, server.hasHeaders]);
|
||||
|
||||
// Single derivation of the button/tooltip presentation from the test tristate
|
||||
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
|
||||
// there is no result; the icon is mapped from `view.state` below. When the
|
||||
// request itself rejects (401/403/500/network) there is no `data` payload, so
|
||||
// we feed the mutation error in too — otherwise the row would silently revert
|
||||
// to "Test" instead of showing a red "Failed".
|
||||
const view = mcpTestButtonView(
|
||||
result,
|
||||
t,
|
||||
testMutation.isError ? testMutation.error : undefined,
|
||||
);
|
||||
const tooltipLabel = view.tooltip;
|
||||
const buttonColor = view.color;
|
||||
const buttonVariant = view.variant;
|
||||
const buttonLabel = view.label;
|
||||
const buttonIcon =
|
||||
view.state === "ok" ? (
|
||||
<IconCheck size={16} />
|
||||
) : view.state === "failed" ? (
|
||||
<IconX size={16} />
|
||||
) : (
|
||||
<IconPlugConnected size={16} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||
>
|
||||
{server.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Always clickable: testing a disabled server before enabling it is useful. */}
|
||||
<Tooltip
|
||||
label={tooltipLabel}
|
||||
disabled={view.state === "idle"}
|
||||
multiline
|
||||
maw={320}
|
||||
withinPortal
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
miw={88}
|
||||
color={buttonColor}
|
||||
variant={buttonVariant}
|
||||
leftSection={testMutation.isPending ? undefined : buttonIcon}
|
||||
loading={testMutation.isPending}
|
||||
onClick={() => testMutation.mutate(server.id)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => onEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => onDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
@@ -83,6 +84,9 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
const formSchema = z.object({
|
||||
chatModel: z.string(),
|
||||
// Max context window in tokens shown in the chat header badge. A number, or ""
|
||||
// when the NumberInput is empty (no limit).
|
||||
chatContextWindow: z.union([z.number(), z.literal("")]),
|
||||
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
|
||||
chatApiStyle: z.enum(["openai-compatible", "openai"]),
|
||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||
@@ -311,6 +315,7 @@ export default function AiProviderSettings() {
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
chatModel: "",
|
||||
chatContextWindow: "",
|
||||
chatApiStyle: "openai-compatible" as ChatApiStyle,
|
||||
publicShareChatModel: "",
|
||||
publicShareAssistantRoleId: "",
|
||||
@@ -334,6 +339,7 @@ export default function AiProviderSettings() {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
chatModel: settings.chatModel ?? "",
|
||||
chatContextWindow: settings.chatContextWindow ?? "",
|
||||
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
|
||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||
@@ -364,6 +370,12 @@ export default function AiProviderSettings() {
|
||||
// Everything is OpenAI-compatible.
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
// Max context window for the chat header badge; empty NumberInput ("") →
|
||||
// 0, which clears the limit server-side (no denominator shown).
|
||||
chatContextWindow:
|
||||
typeof values.chatContextWindow === "number"
|
||||
? values.chatContextWindow
|
||||
: 0,
|
||||
chatApiStyle: values.chatApiStyle,
|
||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||
// back to chatModel server-side.
|
||||
@@ -767,6 +779,18 @@ export default function AiProviderSettings() {
|
||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||
</Text>
|
||||
|
||||
<NumberInput
|
||||
mt="sm"
|
||||
label={t("Context window (tokens)")}
|
||||
description={t(
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
)}
|
||||
min={0}
|
||||
allowDecimal={false}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatContextWindow")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
mt="sm"
|
||||
label={t("Protocol")}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
|
||||
// has no explicit value configured yet.
|
||||
const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
|
||||
* is frozen per-note at creation, so changing this only affects notes created
|
||||
* afterwards. `temporaryNoteHours` is a top-level workspace column (like
|
||||
* trashRetentionDays), not a nested setting.
|
||||
*/
|
||||
export default function TemporaryNoteSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { isAdmin } = useUserRole();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [value, setValue] = useState<number>(
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (!value || value < 1) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
temporaryNoteHours: value,
|
||||
} as Partial<IWorkspace>);
|
||||
setWorkspace({ ...updated, temporaryNoteHours: value });
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message:
|
||||
(err as any)?.response?.data?.message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Temporary notes")}
|
||||
</Text>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
)}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label={t("Temporary note lifetime (hours)")}
|
||||
min={1}
|
||||
allowDecimal={false}
|
||||
value={value}
|
||||
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
|
||||
disabled={!isAdmin || isLoading}
|
||||
w={220}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type ChatApiStyle = "openai-compatible" | "openai";
|
||||
export interface IAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Max context window in tokens shown in the chat header badge; 0/unset = no limit.
|
||||
chatContextWindow?: number;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
||||
publicShareChatModel?: string;
|
||||
@@ -56,6 +58,8 @@ export interface IAiSettings {
|
||||
export interface IAiSettingsUpdate {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Max context window in tokens for the chat header badge; 0 = clear the limit.
|
||||
chatContextWindow?: number;
|
||||
chatApiStyle?: ChatApiStyle;
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface IWorkspace {
|
||||
aiDictationStreaming?: boolean;
|
||||
aiPublicShareAssistant?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||
temporaryNoteHours?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
isScimEnabled?: boolean;
|
||||
|
||||
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
@@ -21,6 +22,12 @@ import {
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import {
|
||||
queryPersister,
|
||||
shouldDehydrateOfflineQuery,
|
||||
} from "@/features/offline/query-persister";
|
||||
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -30,6 +37,8 @@ export const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -50,15 +59,34 @@ root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24,
|
||||
buster: APP_VERSION,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||
native shell serves assets itself; a browser SW would conflict. */}
|
||||
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
|
||||
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
|
||||
// and skipped inside the Capacitor native WebView). The earlier hand-written
|
||||
// /sw.js registration from the mobile bootstrap was removed here to avoid a
|
||||
// double registration / competing service worker.
|
||||
|
||||
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
|
||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
|
||||
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
|
||||
import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
|
||||
<WorkspaceNameForm />
|
||||
<HtmlEmbedSettings />
|
||||
<TrackerSettings />
|
||||
<TemporaryNoteSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { isCapacitorNativePlatform } from "./is-capacitor";
|
||||
|
||||
describe("isCapacitorNativePlatform", () => {
|
||||
afterEach(() => {
|
||||
// Keep tests isolated from each other and from the rest of the suite.
|
||||
delete (globalThis as any).Capacitor;
|
||||
});
|
||||
|
||||
it("returns false when Capacitor is undefined", () => {
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses isNativePlatform() when it is a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the boolean property when isNativePlatform is not a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when reading Capacitor throws (try/catch)", () => {
|
||||
Object.defineProperty(globalThis, "Capacitor", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
});
|
||||
23
apps/client/src/pwa/is-capacitor.ts
Normal file
23
apps/client/src/pwa/is-capacitor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Detects whether the client is running inside a Capacitor native WebView
|
||||
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
|
||||
*
|
||||
* This is a pure runtime check against the global `Capacitor` object that the
|
||||
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
|
||||
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
|
||||
* false and the Workbox service worker registers normally.
|
||||
*
|
||||
* Inside the native WebView the SW must NOT register: it would layer a redundant
|
||||
* (and conflicting) cache over Capacitor's own asset serving and interfere with
|
||||
* the native auth/CORS flow.
|
||||
*/
|
||||
export function isCapacitorNativePlatform(): boolean {
|
||||
try {
|
||||
const cap = (globalThis as any)?.Capacitor;
|
||||
return !!(cap && typeof cap.isNativePlatform === "function"
|
||||
? cap.isNativePlatform()
|
||||
: cap?.isNativePlatform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
|
||||
// Stable notification id so we can show/hide a single update prompt.
|
||||
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
|
||||
|
||||
/**
|
||||
* Listens for a waiting service worker and surfaces a Mantine notification
|
||||
* prompting the user to reload into the new version.
|
||||
*
|
||||
* Must be mounted inside the Mantine provider subtree (Notifications must be
|
||||
* available). Renders nothing itself.
|
||||
*/
|
||||
export function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegisterError(error) {
|
||||
// Best-effort: a failed registration must not break the app.
|
||||
console.error("Service worker registration error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!needRefresh) return;
|
||||
|
||||
notifications.show({
|
||||
id: UPDATE_NOTIFICATION_ID,
|
||||
title: t("Update available"),
|
||||
message: (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="xs"
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
>
|
||||
{t("Reload")}
|
||||
</Button>
|
||||
),
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
|
||||
// Hide the notification when the prompt is no longer needed / on cleanup.
|
||||
return () => {
|
||||
notifications.hide(UPDATE_NOTIFICATION_ID);
|
||||
};
|
||||
}, [needRefresh, t, updateServiceWorker]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default PwaUpdatePrompt;
|
||||
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
|
||||
|
||||
describe("isApiPath", () => {
|
||||
it("matches the /api segment and its subtree", () => {
|
||||
expect(isApiPath("/api")).toBe(true);
|
||||
expect(isApiPath("/api/")).toBe(true);
|
||||
expect(isApiPath("/api/pages")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not over-match sibling paths", () => {
|
||||
expect(isApiPath("/apidocs")).toBe(false);
|
||||
expect(isApiPath("/apixyz")).toBe(false);
|
||||
expect(isApiPath("/")).toBe(false);
|
||||
expect(isApiPath("/pages")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCollabOrSocketPath", () => {
|
||||
it("matches the /collab and /socket.io segments and their subtrees", () => {
|
||||
expect(isCollabOrSocketPath("/collab")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not over-match sibling paths", () => {
|
||||
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
|
||||
expect(isCollabOrSocketPath("/collabx")).toBe(false);
|
||||
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/pwa/sw-strategy.ts
Normal file
32
apps/client/src/pwa/sw-strategy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Canonical service-worker routing predicates.
|
||||
*
|
||||
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
|
||||
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
|
||||
* generated service worker and CANNOT reference imported symbols. The matching
|
||||
* logic is therefore duplicated as inline regex literals in
|
||||
* apps/client/vite.config.ts. This module is the testable source of truth, and
|
||||
* the two MUST be kept in sync. This duplication is intentional and is the
|
||||
* documented Workbox limitation.
|
||||
*
|
||||
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
|
||||
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
|
||||
* wrongly treated as API/realtime traffic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when `pathname` is the `/api` segment or anything beneath it.
|
||||
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
|
||||
*/
|
||||
export function isApiPath(pathname: string): boolean {
|
||||
return /^\/api(\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
|
||||
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
|
||||
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
|
||||
*/
|
||||
export function isCollabOrSocketPath(pathname: string): boolean {
|
||||
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
|
||||
}
|
||||
2
apps/client/src/vite-env.d.ts
vendored
2
apps/client/src/vite-env.d.ts
vendored
@@ -1,2 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
declare const APP_VERSION: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import * as path from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
@@ -53,7 +54,51 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
injectRegister: null,
|
||||
strategies: "generateSW",
|
||||
manifest: false,
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
|
||||
navigateFallback: "index.html",
|
||||
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
|
||||
// segments are consistently excluded from the SPA fallback, mirroring
|
||||
// the runtimeCaching urlPattern regexes below.
|
||||
//
|
||||
// `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve
|
||||
// exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`):
|
||||
// robots.txt, the SEO/OG/analytics-injected public share HTML, and the
|
||||
// embedded MCP endpoint are served by server controllers, so the SW must
|
||||
// never shadow them with the precached index.html app shell (doing so
|
||||
// would break SEO and MCP).
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api(\/|$)/,
|
||||
/^\/collab(\/|$)/,
|
||||
/^\/socket\.io(\/|$)/,
|
||||
/^\/share(\/|$)/,
|
||||
/^\/mcp(\/|$)/,
|
||||
/^\/robots\.txt$/,
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
|
||||
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
|
||||
// functions standalone into the generated service worker, so they cannot
|
||||
// import the module — the matching logic is intentionally duplicated as
|
||||
// self-contained inline regex literals anchored to a path segment boundary.
|
||||
runtimeCaching: [
|
||||
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||
// All /api stays network-only; offline reads come from the persisted
|
||||
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
|
||||
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.93.0",
|
||||
"version": "0.94.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -64,6 +64,7 @@
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
|
||||
@@ -24,7 +24,9 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import {
|
||||
CollaborationHandler,
|
||||
CollabEventHandlers,
|
||||
writeTitleFragment,
|
||||
} from './collaboration.handler';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
@@ -149,6 +151,45 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
|
||||
*
|
||||
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
|
||||
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
|
||||
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
|
||||
* through the local Hocuspocus `openDirectConnection`. The title sync
|
||||
* therefore works in BOTH single-process (no Redis) and Redis-clustered
|
||||
* deployments.
|
||||
*
|
||||
* openDirectConnection loads the doc from persistence when no editor is
|
||||
* connected, so this works whether or not an editor is currently open: the
|
||||
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
|
||||
*
|
||||
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
|
||||
* into the connection `context` so onStoreDocument sees `context.actor ===
|
||||
* 'agent'` for the resulting title store (mirrors the body/REST path). The
|
||||
* resulting title store is usually a no-op anyway — PageService already wrote
|
||||
* the same title to the page.title column, so onStoreDocument's
|
||||
* `titleText !== page.title` guard skips the column write — but we wire the
|
||||
* context for correctness regardless.
|
||||
*/
|
||||
async writePageTitle(
|
||||
pageId: string,
|
||||
title: string,
|
||||
context?: { user?: User; actor?: string; aiChatId?: string },
|
||||
): Promise<void> {
|
||||
const documentName = `page.${pageId}`;
|
||||
const connection = await this.hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
context ?? {},
|
||||
);
|
||||
try {
|
||||
await connection.transact((doc) => writeTitleFragment(doc, title));
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
139
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
139
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { writeTitleFragment } from './collaboration.handler';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
// Read the plain text held in the doc's 'title' XmlFragment, the same way
|
||||
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
|
||||
const readTitleText = (doc: Y.Doc): string => {
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
|
||||
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
|
||||
// Seed the doc's 'title' fragment with an OLD title, like a real page.
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
expect(readTitleText(doc)).toBe('Old Title');
|
||||
|
||||
writeTitleFragment(doc, 'New Title');
|
||||
|
||||
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
|
||||
// (append) or "New TitleNew Title" (duplication). A single heading node.
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
|
||||
expect(titleJson.content).toHaveLength(1);
|
||||
expect(titleJson.content[0].type).toBe('heading');
|
||||
});
|
||||
|
||||
it('seeds the title fragment when it started empty', () => {
|
||||
const doc = new Y.Doc();
|
||||
// Force the 'title' fragment to exist but be empty.
|
||||
doc.getXmlFragment('title');
|
||||
expect(readTitleText(doc)).toBe('');
|
||||
|
||||
writeTitleFragment(doc, 'First Title');
|
||||
|
||||
expect(readTitleText(doc)).toBe('First Title');
|
||||
});
|
||||
|
||||
it('does not corrupt the body when rewriting the title', () => {
|
||||
// A doc with both a body and an old title; the body must survive untouched.
|
||||
const doc = new Y.Doc();
|
||||
const bodyDoc = TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
|
||||
],
|
||||
},
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
|
||||
|
||||
writeTitleFragment(doc, 'New');
|
||||
|
||||
expect(readTitleText(doc)).toBe('New');
|
||||
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
|
||||
expect(jsonToText(bodyJson)).toContain('body text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
|
||||
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
|
||||
// method must drive the clear+seed through that direct connection (NOT through
|
||||
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
|
||||
const makeGateway = (doc: Y.Doc) => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||
fn(doc);
|
||||
});
|
||||
const openDirectConnection = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ transact, disconnect });
|
||||
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
|
||||
return { gateway, openDirectConnection, transact, disconnect };
|
||||
};
|
||||
|
||||
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
|
||||
const { gateway, openDirectConnection, disconnect } = makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ user: { id: 'u1' } }),
|
||||
);
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('threads agent provenance into the connection context', async () => {
|
||||
const doc = new Y.Doc();
|
||||
const { gateway, openDirectConnection } = makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'Agent Title', {
|
||||
user: { id: 'u1' },
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects even when the transaction throws', async () => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const openDirectConnection = jest.fn().mockResolvedValue({
|
||||
transact: jest.fn().mockRejectedValue(new Error('boom')),
|
||||
disconnect,
|
||||
});
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
|
||||
await expect(
|
||||
gateway.writePageTitle('page-1', 'X', {}),
|
||||
).rejects.toThrow('boom');
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
@@ -13,6 +14,35 @@ export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
||||
*
|
||||
* Used by the gateway's direct `writePageTitle` method to write a new page
|
||||
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
||||
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
||||
* rename that only updated the page.title DB column would be reverted on the
|
||||
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
||||
* The whole fragment is replaced (no merge/append),
|
||||
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
||||
*
|
||||
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
||||
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
||||
* title in an open editor WILL overwrite that in-progress edit. This is
|
||||
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
||||
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
||||
* DB column to on the next save.
|
||||
*/
|
||||
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
||||
const titleFragment = doc.getXmlFragment('title');
|
||||
|
||||
if (titleFragment.length > 0) {
|
||||
titleFragment.delete(0, titleFragment.length);
|
||||
}
|
||||
|
||||
const newTitleDoc = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationHandler {
|
||||
private readonly logger = new Logger(CollaborationHandler.name);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
@@ -241,3 +245,49 @@ describe('prosemirrorNodeToYElement', () => {
|
||||
expect(element.get(1).get(0).toString()).toBe('two');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTitleSeedYdoc', () => {
|
||||
it('builds a level-1 heading carrying the title text', () => {
|
||||
const doc = buildTitleSeedYdoc('Hello World');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
expect(first.attrs.level).toBe(1);
|
||||
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('Some Title');
|
||||
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a heading with no text child for an empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
// No text content for an empty title.
|
||||
expect(first.content ?? []).toHaveLength(0);
|
||||
expect(jsonToText(json).trim()).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||
const title = 'Round Trip Title';
|
||||
const doc1 = buildTitleSeedYdoc(title);
|
||||
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||
|
||||
const doc2 = buildTitleSeedYdoc(text1);
|
||||
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||
|
||||
expect(text1).toBe(title);
|
||||
expect(text2).toBe(text1);
|
||||
});
|
||||
|
||||
// Touch tiptapExtensions so the import is exercised (mirrors the brief's import
|
||||
// list and guards against accidental tree-shaking of the schema dependency).
|
||||
it('uses the shared tiptap extensions schema', () => {
|
||||
expect(Array.isArray(tiptapExtensions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
import { Node, Schema } from '@tiptap/pm/model';
|
||||
import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
@@ -143,6 +144,34 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
||||
* fragment named exactly 'title' (the collaborative title-editor contract with
|
||||
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
||||
* whose text is the title (empty title => heading with no text child).
|
||||
*
|
||||
* The encoded state of the returned doc can be merged into a body doc via
|
||||
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
||||
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
||||
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
||||
*/
|
||||
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
||||
return TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: title ? [{ type: 'text', text: title }] : [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'title',
|
||||
tiptapExtensions,
|
||||
);
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||
|
||||
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
|
||||
// rename) from the standalone collab process to the API process, which is the
|
||||
// single broadcast authority. Imported by both halves of the bridge:
|
||||
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
|
||||
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';
|
||||
|
||||
@@ -182,4 +182,46 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// persist-1 — a transient DB failure during store must not silently lose the
|
||||
// edit. hocuspocus unloads (destroys) the in-memory Y.Doc right after this
|
||||
// hook resolves, so the store has to retry while it still holds the only copy.
|
||||
it('retries a transient DB failure and still persists the edit (persist-1)', async () => {
|
||||
const document = ydocFor(doc('NEW HUMAN CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
|
||||
let attempts = 0;
|
||||
pageRepo.updatePage.mockImplementation(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('deadlock detected'); // transient
|
||||
callOrder.push('updatePage');
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// First attempt failed and rolled back; the retry persisted the edit.
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
|
||||
// The edit WAS saved, so the post-store success path runs as normal.
|
||||
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
||||
// success: no "page.updated" badge broadcast and no history snapshot for
|
||||
// content that was never written.
|
||||
it('does not run post-store side effects when every store attempt fails (persist-1)', async () => {
|
||||
const document = ydocFor(doc('NEW HUMAN CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(
|
||||
ext.onStoreDocument(buildData(document, 'user') as any),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// Bounded retry exhausted (MAX_STORE_ATTEMPTS).
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(3);
|
||||
// No false-success: nothing downstream fires for the unsaved content.
|
||||
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||
|
||||
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
|
||||
const bodyJson = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||
};
|
||||
|
||||
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
|
||||
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
|
||||
const buildDoc = () => {
|
||||
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
d.broadcastStateless = jest.fn();
|
||||
return d;
|
||||
};
|
||||
|
||||
const cloneOut = (doc: any) =>
|
||||
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
|
||||
|
||||
const addTitleFragment = (doc: any, title: string) =>
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
|
||||
describe('PersistenceExtension', () => {
|
||||
let pageRepo: any;
|
||||
let pageHistoryRepo: any;
|
||||
let trx: any;
|
||||
let db: any;
|
||||
let aiQueue: any;
|
||||
let historyQueue: any;
|
||||
let notificationQueue: any;
|
||||
let collabHistory: any;
|
||||
let transclusionService: any;
|
||||
let ext: PersistenceExtension;
|
||||
|
||||
beforeEach(() => {
|
||||
pageRepo = {
|
||||
findById: jest.fn(),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageHistoryRepo = {
|
||||
findPageLastHistory: jest.fn(),
|
||||
saveHistory: jest.fn(),
|
||||
};
|
||||
trx = {};
|
||||
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
|
||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('seedTitleFragment', () => {
|
||||
it('returns false for empty/whitespace/null titles', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT re-seed an existing non-empty title fragment', () => {
|
||||
const doc = new Y.Doc();
|
||||
addTitleFragment(doc, 'Existing');
|
||||
|
||||
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
|
||||
|
||||
const text = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(text)).toContain('Existing');
|
||||
expect(JSON.stringify(text)).not.toContain('Other');
|
||||
});
|
||||
|
||||
it('seeds an empty fragment from a non-empty title and returns true', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
|
||||
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(json)).toContain('Hello');
|
||||
});
|
||||
|
||||
it('returns false (defensive) when reading the fragment throws', () => {
|
||||
const fakeDoc = {
|
||||
get: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStoreDocument', () => {
|
||||
const basePage = (overrides: any) => ({
|
||||
id: 'PAGE_ID',
|
||||
slugId: 'slug',
|
||||
spaceId: 'space',
|
||||
parentPageId: null,
|
||||
creatorId: 'creator',
|
||||
contributorIds: ['creator'],
|
||||
workspaceId: 'ws',
|
||||
title: 'whatever',
|
||||
content: null,
|
||||
lastUpdatedSource: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
|
||||
|
||||
it('no-op when neither body nor title changed', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'hello title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).not.toHaveBeenCalled();
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('title-only change persists the title without body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].contributorIds).toBeDefined();
|
||||
expect('content' in call[0]).toBe(false);
|
||||
// Title-only must not touch the body-authorship provenance.
|
||||
expect('lastUpdatedSource' in call[0]).toBe(false);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
expect(call[3].treeUpdate.title).toBe('New Title');
|
||||
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(
|
||||
'PAGE_ID',
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
|
||||
// The client can momentarily seed the 'title' fragment as an EMPTY heading
|
||||
// (hasTitleFragment true, extracted text '') before the real title syncs.
|
||||
// Body is unchanged here, so the only candidate write is the title -> the
|
||||
// guard must turn this into a full no-op (no updatePage, no broadcast).
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
// No write at all: the empty title is not authoritative and the body is
|
||||
// unchanged, so onStoreDocument must take the no-op fast path.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty title fragment
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] }, // different body -> bodyChanged
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
// Body is persisted, but the title is NOT included (empty == not
|
||||
// authoritative) and no tree update is broadcast for the title.
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
expect(call[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('body + title change persists both with full body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].lastUpdatedSource).toBe('user');
|
||||
expect(call[3].treeUpdate).toBeDefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('body-only change persists the body without a tree update', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'whatever',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
// No treeUpdate for a body-only save.
|
||||
expect(call[3]).toBeUndefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoadDocument', () => {
|
||||
it('returns early (no DB read) when the document is not empty', async () => {
|
||||
const document = { isEmpty: () => false };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined and does not persist when the page is null', async () => {
|
||||
const document = { isEmpty: () => true };
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
// Both the cheap pre-check and the locked re-read return the same row.
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The locked re-read must take the row lock inside the tx.
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
// Persist must run inside the transaction.
|
||||
expect(call[2]).toBe(trx);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Has Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Hot path: only the cheap lock-free read, no locked re-read, no write.
|
||||
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'T',
|
||||
ydoc: null,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[2]).toBe(trx);
|
||||
// The rebuilt doc carries the body.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
});
|
||||
|
||||
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
|
||||
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
|
||||
// rebuild path), but by the time we hold the lock another process has
|
||||
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
|
||||
const healed = TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
|
||||
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
|
||||
|
||||
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
const lockedRow = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Healed Title',
|
||||
ydoc: healedYdoc,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById
|
||||
.mockResolvedValueOnce(preCheck) // cheap pre-check
|
||||
.mockResolvedValueOnce(lockedRow); // locked re-read
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
|
||||
// seeded -> no clobbering write.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('healed');
|
||||
});
|
||||
|
||||
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
|
||||
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
await expect(
|
||||
ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any),
|
||||
).rejects.toThrow('db down');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
|
||||
expect(result).toBeTruthy();
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import * as Y from 'yjs';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToText,
|
||||
@@ -116,6 +117,10 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
|
||||
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
|
||||
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
|
||||
// runs exactly once (see healUnderLock).
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
@@ -127,33 +132,164 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page.ydoc) {
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
|
||||
const doc = new Y.Doc();
|
||||
const dbState = new Uint8Array(page.ydoc);
|
||||
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
|
||||
|
||||
Y.applyUpdate(doc, dbState);
|
||||
return doc;
|
||||
// Legacy pages persisted their title only in the `page.title` column; the
|
||||
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
|
||||
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
|
||||
// only when that fragment is empty AND there is a non-empty column title.
|
||||
let titleSeedNeeded = false;
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
|
||||
} catch (err) {
|
||||
// A malformed title fragment must not break loading; skip the seed.
|
||||
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
|
||||
titleSeedNeeded = false;
|
||||
}
|
||||
|
||||
if (!titleSeedNeeded) {
|
||||
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
|
||||
// title fragment. If the persist fails we must NOT hand out an unpersisted
|
||||
// fresh-client-id seed (it could later duplicate the title), so we fall
|
||||
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
|
||||
// seed. The title just won't render until a later successful heal —
|
||||
// non-fatal, non-corrupting.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
|
||||
err,
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
// if no ydoc state in db convert json in page.content to Ydoc.
|
||||
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
|
||||
// title-seeds + persists every legacy page (content set, ydoc null) on its
|
||||
// first open, which neutralizes the duplication trap incrementally. A
|
||||
// proactive one-shot BATCH migration over all such pages could be added
|
||||
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
|
||||
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
|
||||
// exists in this repo yet, so we deliberately avoid a fragile migration.
|
||||
//
|
||||
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
|
||||
// a row lock (see healUnderLock).
|
||||
if (page.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
page.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
return ydoc;
|
||||
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
|
||||
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
|
||||
// returning it would re-arm the duplication trap. A transient DB failure
|
||||
// means the client reconnects and retries: correctness over availability.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
|
||||
* title fragment, then persist) so it runs exactly ONCE per page, closing the
|
||||
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
|
||||
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
|
||||
* process via openDirectConnection AND the standalone collab process both
|
||||
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
|
||||
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
|
||||
* row FOR UPDATE inside a transaction and re-validating state under the lock:
|
||||
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
|
||||
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
|
||||
* write rolls back and propagates out (the caller then decides refuse vs.
|
||||
* fall-back).
|
||||
*/
|
||||
private async healUnderLock(pageId: string): Promise<Y.Doc> {
|
||||
return executeTx(this.db, async (trx) => {
|
||||
const locked = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
const doc = new Y.Doc();
|
||||
let rebuilt = false;
|
||||
|
||||
if (locked?.ydoc) {
|
||||
// Another process already healed (or the page always had a ydoc): adopt
|
||||
// the healthy persisted state, do NOT rebuild.
|
||||
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
|
||||
} else if (locked?.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
const built = TiptapTransformer.toYdoc(
|
||||
locked.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
|
||||
rebuilt = true;
|
||||
}
|
||||
// else: no ydoc and no content -> a fresh empty doc.
|
||||
|
||||
// Idempotent, emptiness-guarded title seed (safe to call always).
|
||||
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
|
||||
|
||||
if (rebuilt || seeded) {
|
||||
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
|
||||
// error propagates out of executeTx to the caller.
|
||||
await this.pageRepo.updatePage(
|
||||
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
|
||||
}
|
||||
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
|
||||
* pages whose persisted ydoc has no title fragment yet.
|
||||
*
|
||||
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
|
||||
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
|
||||
* fragment would re-introduce the Yjs duplication trap, so we never do it.
|
||||
* Returns true when a seed was applied (so the caller can persist).
|
||||
* Defensive: a malformed title must not break document loading.
|
||||
*/
|
||||
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
|
||||
const trimmed = (title ?? '').trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
if (titleFrag.length !== 0) return false;
|
||||
|
||||
const titleSeed = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
|
||||
this.logger.debug('seeded title fragment from page.title column');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
@@ -171,7 +307,34 @@ export class PersistenceExtension implements Extension {
|
||||
this.logger.warn('jsonToText' + err?.['message']);
|
||||
}
|
||||
|
||||
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
|
||||
// (the collaborative title-editor contract with the client). Extract it
|
||||
// defensively: a malformed title fragment must NOT crash the document store.
|
||||
// `hasTitleFragment` distinguishes "the doc actually carries a title
|
||||
// fragment" from "legacy doc with no title fragment" — only the former may
|
||||
// write page.title, so a legacy doc never clobbers the column with ''.
|
||||
let titleText = '';
|
||||
let hasTitleFragment = false;
|
||||
try {
|
||||
const titleFrag = document.get('title', Y.XmlFragment);
|
||||
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
|
||||
if (hasTitleFragment) {
|
||||
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
|
||||
titleText = titleJson ? jsonToText(titleJson).trim() : '';
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('title extraction: ' + err?.['message']);
|
||||
hasTitleFragment = false;
|
||||
}
|
||||
|
||||
let page: Page = null;
|
||||
// Tracks whether the BODY ('default') changed in this store. The heavy
|
||||
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
|
||||
// gated on this so a title-only change does not trigger them.
|
||||
let bodyChanged = false;
|
||||
// Tracks a successful title-only persist so the post-tx contributor folding
|
||||
// (collabHistory.addContributors) runs for the title-only case too.
|
||||
let titleOnlyPersisted = false;
|
||||
const editingUserIds = this.consumeContributors(documentName);
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
@@ -181,85 +344,197 @@ export class PersistenceExtension implements Extension {
|
||||
context?.actor,
|
||||
);
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
// Persist with a small bounded retry. The in-memory Y.Doc is the ONLY copy
|
||||
// of the latest edit until this hook returns: hocuspocus destroys/unloads the
|
||||
// doc right after onStoreDocument resolves (see storeDocumentHooks' finally
|
||||
// -> unloadDocument). If a transient DB error (deadlock, serialization
|
||||
// failure, dropped connection) is merely logged and swallowed, the function
|
||||
// resolves "successfully", the doc is unloaded, and the edit is lost silently
|
||||
// (#206 persist-1). Retrying here re-attempts the write while we still hold
|
||||
// the doc; on total failure we clear `page` so the post-store side effects
|
||||
// (badge broadcast, history snapshot) never report a save that didn't happen.
|
||||
const MAX_STORE_ATTEMPTS = 3;
|
||||
for (let attempt = 1; attempt <= MAX_STORE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
page = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
this.logger.error(`Page with id ${pageId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
contributorIds = Array.from(
|
||||
new Set([
|
||||
...existingContributors,
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds the
|
||||
// OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
|
||||
// state is already agent-authored (boundary already pinned on the
|
||||
// user->agent transition), if the page is effectively empty, or if the
|
||||
// latest existing snapshot already equals this human state (avoid
|
||||
// duplicates).
|
||||
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
});
|
||||
if (!page) {
|
||||
this.logger.error(`Page with id ${pageId} not found`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
// Human stays the responsible author; these annotate the source.
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
|
||||
// Only a populated 'title' fragment may update page.title; compare
|
||||
// against the current column value (treat null as '').
|
||||
//
|
||||
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
|
||||
// can momentarily initialize the 'title' fragment as an EMPTY heading
|
||||
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
|
||||
// BEFORE the server's real-title seed has synced. Writing that '' would
|
||||
// silently wipe a non-empty page.title to "untitled". A wiki page is
|
||||
// never legitimately retitled to empty via this path, so we treat an
|
||||
// empty extracted title as "not authoritative" and never persist it.
|
||||
// The `titleText.length > 0` clause makes this guard apply to BOTH the
|
||||
// title-only branch and the body+title branch below.
|
||||
//
|
||||
// DELIBERATE: this intentionally makes it impossible to retitle a page
|
||||
// to EMPTY via the collab path — a wiki page is never legitimately
|
||||
// empty-titled. If a non-empty-title rule ever needs relaxing or
|
||||
// enforcing differently, the REST UpdatePageDto is the place to validate
|
||||
// the title, not this collab guard.
|
||||
const titleChanged =
|
||||
hasTitleFragment &&
|
||||
titleText.length > 0 &&
|
||||
titleText !== (page.title ?? '');
|
||||
|
||||
// No-op fast path: neither body nor title changed.
|
||||
if (!bodyChanged && !titleChanged) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Title-only change: the body is unchanged, so skip the heavy body
|
||||
// history/contributor logic and persist just the new title and the
|
||||
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
|
||||
// used to drop this case entirely, losing the title change.
|
||||
if (!bodyChanged) {
|
||||
// Fold the window's editing users into contributors the same way the
|
||||
// body branch does, so a user who edited ONLY the title is not dropped
|
||||
// from page.contributorIds.
|
||||
const contributorIds = Array.from(
|
||||
new Set([
|
||||
...(page.contributorIds || []),
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
title: titleText,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
contributorIds,
|
||||
// A title-only change is not a body-authorship transition; leave
|
||||
// lastUpdatedSource/aiChatId untouched so the user->agent history
|
||||
// boundary in the body branch is not bypassed.
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
{
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
titleOnlyPersisted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
contributorIds = Array.from(
|
||||
new Set([
|
||||
...existingContributors,
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds the
|
||||
// OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
|
||||
// state is already agent-authored (boundary already pinned on the
|
||||
// user->agent transition), if the page is effectively empty, or if the
|
||||
// latest existing snapshot already equals this human state (avoid
|
||||
// duplicates).
|
||||
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
// Human stays the responsible author; these annotate the source.
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
// Persist the title in the SAME transaction when the title fragment
|
||||
// changed alongside the body.
|
||||
...(titleChanged ? { title: titleText } : {}),
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
// Only attach when the title actually changed; a body-only save must
|
||||
// not trigger a tree broadcast.
|
||||
titleChanged
|
||||
? {
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update page ${pageId} (attempt ${attempt}/${MAX_STORE_ATTEMPTS})`,
|
||||
err,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||
// The write failed and rolled back; clear the partially-assigned `page`
|
||||
// so the post-store success branch below is skipped (no false "saved"
|
||||
// broadcast / history snapshot for content that was never persisted).
|
||||
page = null;
|
||||
if (attempt < MAX_STORE_ATTEMPTS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `page` is truthy whenever anything was persisted (body OR title-only), so
|
||||
// the page.updated broadcast fires for a title-only change too.
|
||||
if (page) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
@@ -277,11 +552,20 @@ export class PersistenceExtension implements Extension {
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Record the window's editing users in collab history for a title-only
|
||||
// change too (the body branch does this below, gated on bodyChanged).
|
||||
if (page && titleOnlyPersisted) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
}
|
||||
|
||||
// Body-only side-effects: skip them for a title-only change (body unchanged).
|
||||
if (page && bodyChanged) {
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
if (page && bodyChanged) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
import {
|
||||
PageEvent,
|
||||
TreeUpdateSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
const treeUpdate: TreeUpdateSnapshot = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
title: 'Renamed',
|
||||
icon: '🚀',
|
||||
};
|
||||
|
||||
describe('PageTreeBridgePublisher', () => {
|
||||
let publisher: PageTreeBridgePublisher;
|
||||
let redis: { publish: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = { publish: jest.fn().mockResolvedValue(1) };
|
||||
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageTreeBridgePublisher,
|
||||
{ provide: RedisService, useValue: redisService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
|
||||
});
|
||||
|
||||
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||
expect(redis.publish).toHaveBeenCalledWith(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(treeUpdate),
|
||||
);
|
||||
});
|
||||
|
||||
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a publish rejection is caught (no throw)', async () => {
|
||||
redis.publish.mockRejectedValueOnce(new Error('redis down'));
|
||||
const errorSpy = jest
|
||||
.spyOn(publisher['logger'], 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
import { PageEvent } from '../../database/listeners/page.listener';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
|
||||
/**
|
||||
* Collab-process half of the cross-process tree-update bridge.
|
||||
*
|
||||
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
|
||||
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
|
||||
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
|
||||
* is no listener in this process to broadcast it — the live tree update would be
|
||||
* lost for 2-process (COLLAB_URL set) deployments.
|
||||
*
|
||||
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
|
||||
* Redis pub/sub channel to the API process, which re-broadcasts it via
|
||||
* `WsTreeService` (the single broadcast authority).
|
||||
*
|
||||
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
|
||||
* API process (where `PageWsListener` already broadcasts the same event locally).
|
||||
* That module placement is what prevents a double broadcast. In single-process
|
||||
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PageTreeBridgePublisher {
|
||||
private readonly logger = new Logger(PageTreeBridgePublisher.name);
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_UPDATED)
|
||||
async onPageUpdated(event: PageEvent): Promise<void> {
|
||||
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
|
||||
// Content-only saves leave `treeUpdate` undefined and are ignored.
|
||||
if (!event.treeUpdate) return;
|
||||
|
||||
try {
|
||||
await this.redis.publish(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(event.treeUpdate),
|
||||
);
|
||||
} catch (err) {
|
||||
// A Redis publish failure must not break the store path.
|
||||
this.logger.error(
|
||||
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { CaslModule } from '../../core/casl/casl.module';
|
||||
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -54,6 +55,6 @@ import KeyvRedis from '@keyv/redis';
|
||||
? [CollaborationController]
|
||||
: []),
|
||||
],
|
||||
providers: [AppService],
|
||||
providers: [AppService, PageTreeBridgePublisher],
|
||||
})
|
||||
export class CollabAppModule {}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
ChatIdDto,
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatMessagesDto,
|
||||
RenameChatDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
@@ -316,6 +317,43 @@ export class AiChatController {
|
||||
return { text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from supplied note content (#199). One-shot,
|
||||
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
|
||||
* the same flag that gates the on-page generative AI menu); returns { title }.
|
||||
* The endpoint NEVER writes the page — the client applies the title via the
|
||||
* existing /pages/update route (which enforces edit permission), so access
|
||||
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } })
|
||||
@Post('generate-page-title')
|
||||
async generatePageTitle(
|
||||
@Body() dto: GeneratePageTitleDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ title: string }> {
|
||||
const settings = (workspace.settings ?? {}) as {
|
||||
ai?: { generative?: boolean };
|
||||
};
|
||||
if (settings.ai?.generative !== true) {
|
||||
throw new ForbiddenException('AI title generation is disabled');
|
||||
}
|
||||
try {
|
||||
const title = await this.aiChatService.generatePageTitle(
|
||||
workspace.id,
|
||||
dto.content,
|
||||
);
|
||||
return { title };
|
||||
} catch (err) {
|
||||
// Preserve meaningful HTTP errors (e.g. AiNotConfiguredException -> 503).
|
||||
if (err instanceof HttpException) throw err;
|
||||
// Surface the real provider/transport reason instead of an opaque 500.
|
||||
this.logger.error('AI title generation failed', err as Error);
|
||||
throw new ServiceUnavailableException(describeProviderError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chat exists, belongs to this workspace, AND was created by the
|
||||
* requesting user (per-user isolation). Throws ForbiddenException otherwise.
|
||||
|
||||
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal file
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { cleanGeneratedTitle } from './ai-chat.service';
|
||||
import type { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Pure post-processing of a model-generated title (#199): trims, strips a single
|
||||
* pair of surrounding quotes, drops a trailing period, and hard-caps the length.
|
||||
*/
|
||||
describe('cleanGeneratedTitle', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(cleanGeneratedTitle(' Hello world ')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('strips a single pair of surrounding double quotes', () => {
|
||||
expect(cleanGeneratedTitle('"My note"')).toBe('My note');
|
||||
});
|
||||
|
||||
it('strips surrounding single quotes', () => {
|
||||
expect(cleanGeneratedTitle("'My note'")).toBe('My note');
|
||||
});
|
||||
|
||||
it('drops a trailing period', () => {
|
||||
expect(cleanGeneratedTitle('A complete sentence.')).toBe(
|
||||
'A complete sentence',
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the result at 255 characters (the page-title column bound)', () => {
|
||||
expect(cleanGeneratedTitle('x'.repeat(400))).toHaveLength(255);
|
||||
});
|
||||
|
||||
it('returns an empty string for blank/garbage input', () => {
|
||||
expect(cleanGeneratedTitle(' ')).toBe('');
|
||||
expect(cleanGeneratedTitle('""')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
|
||||
* gate on settings.ai.generative (403 when off), delegate to the service when on,
|
||||
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
|
||||
* any other provider/transport fault to a 503. Exercised by instantiating the
|
||||
* controller with hand-rolled mocks — no Nest graph, no DB.
|
||||
*/
|
||||
describe('AiChatController.generatePageTitle', () => {
|
||||
const enabledWorkspace = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { generative: true } },
|
||||
} as unknown as Workspace;
|
||||
|
||||
function makeController(generate: jest.Mock) {
|
||||
const aiChatService = { generatePageTitle: generate };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatService };
|
||||
}
|
||||
|
||||
it('forbids when the generative AI flag is off', async () => {
|
||||
const generate = jest.fn();
|
||||
const { controller } = makeController(generate);
|
||||
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, disabled),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids when settings.ai.generative is anything but exactly true', async () => {
|
||||
const generate = jest.fn();
|
||||
const { controller } = makeController(generate);
|
||||
const ws = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { generative: 'yes' } },
|
||||
} as unknown as Workspace;
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, ws),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('returns { title } from the service when enabled', async () => {
|
||||
const generate = jest.fn().mockResolvedValue('Generated Title');
|
||||
const { controller } = makeController(generate);
|
||||
const res = await controller.generatePageTitle(
|
||||
{ content: 'some markdown body' },
|
||||
enabledWorkspace,
|
||||
);
|
||||
expect(generate).toHaveBeenCalledWith('ws1', 'some markdown body');
|
||||
expect(res).toEqual({ title: 'Generated Title' });
|
||||
});
|
||||
|
||||
it('rethrows an HttpException from the service verbatim (e.g. 503 not configured)', async () => {
|
||||
const notConfigured = new ServiceUnavailableException('AI not configured');
|
||||
const generate = jest.fn().mockRejectedValue(notConfigured);
|
||||
const { controller } = makeController(generate);
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, enabledWorkspace),
|
||||
).rejects.toBe(notConfigured);
|
||||
});
|
||||
|
||||
it('maps a non-HTTP provider error to a 503', async () => {
|
||||
const generate = jest.fn().mockRejectedValue(new Error('socket hang up'));
|
||||
const { controller } = makeController(generate);
|
||||
// Silence the expected error log.
|
||||
jest
|
||||
.spyOn((controller as unknown as { logger: { error: () => void } }).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const err = await controller
|
||||
.generatePageTitle({ content: 'body' }, enabledWorkspace)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
});
|
||||
});
|
||||
@@ -239,3 +239,32 @@ describe('buildMcpToolingBlock', () => {
|
||||
expect(block).not.toContain('b_*');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Interrupt-resume note (#198). The INTERRUPT_NOTE is injected into the system
|
||||
* prompt ONLY when `interrupted: true` is passed (the server sets it only after
|
||||
* confirming against history). It tells the model its previous answer was cut off
|
||||
* by the user, so it treats the partial assistant message in history as
|
||||
* incomplete. The note lives inside the safety sandwich (the context section).
|
||||
*/
|
||||
describe('buildSystemPrompt interrupt note (#198)', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
const NOTE_MARKER = 'interrupted by the';
|
||||
const SAFETY_MARKER = 'Operating rules (always in effect)';
|
||||
|
||||
it('injects the interrupt note when interrupted is true', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, interrupted: true });
|
||||
expect(prompt).toContain(NOTE_MARKER);
|
||||
// Still inside the safety sandwich: the trailing SAFETY block follows it.
|
||||
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
||||
prompt.indexOf(NOTE_MARKER),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the interrupt note when interrupted is false/absent', () => {
|
||||
expect(buildSystemPrompt({ workspace, interrupted: false })).not.toContain(
|
||||
NOTE_MARKER,
|
||||
);
|
||||
expect(buildSystemPrompt({ workspace })).not.toContain(NOTE_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user