Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c11c69a | |||
| 3a34829e78 | |||
| 424b2628b0 | |||
| 5c734e2d65 | |||
| deca17d9f6 | |||
| 719bcf87db | |||
| 9069700371 | |||
| 8daa6a0588 | |||
| 2b7c8eeda2 | |||
| b401ee0ff8 | |||
| 06c53ba565 | |||
| ea8c46d37e | |||
| b17e1d4a02 | |||
| 0c4aee7467 | |||
| 88b34755f6 | |||
| fa2bce1075 | |||
| f9d74a8701 | |||
| 2967133ebd | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| 4c1ee50dc9 | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| bfcee6dddc | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| 43b11d92ab | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| f759084f41 | |||
| 459d636ffb | |||
| e89ac627dd | |||
| f665f6fdd2 | |||
| 7af85b476e | |||
| 5d8364bb5f | |||
| d3209b5aab | |||
| 68899a2c2e | |||
| b9f3de80f5 | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 |
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
|
|||||||
# Example: https://intranet.example.com,https://portal.example.com
|
# Example: https://intranet.example.com,https://portal.example.com
|
||||||
IFRAME_ALLOWED_ORIGINS=
|
IFRAME_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||||
|
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||||
|
# Leave empty for a same-origin (web-only) deployment.
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||||
|
SWAGGER_ENABLED=false
|
||||||
|
|
||||||
|
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||||
|
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||||
|
# Leave empty for Android bundled mode / local development.
|
||||||
|
CAP_SERVER_URL=
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
@@ -202,6 +215,27 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||||
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||||
|
# active" behavior.
|
||||||
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
|
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||||
|
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||||
|
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||||
|
# it, and a client reconnects/live-follows the run.
|
||||||
|
#
|
||||||
|
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||||
|
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||||
|
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||||
|
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||||
|
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||||
|
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||||
|
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||||
|
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
@@ -235,3 +269,27 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||||
# per rolling day).
|
# per rolling day).
|
||||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||||
|
|
||||||
|
# --- Observability / perf metrics (#355) ---
|
||||||
|
#
|
||||||
|
# Two INDEPENDENT toggles, both OFF by default:
|
||||||
|
#
|
||||||
|
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
||||||
|
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
||||||
|
# collectors, and NOTHING is exposed on the main app port. There is NO
|
||||||
|
# default port — leaving it blank disables it. When set to a port (e.g.
|
||||||
|
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
||||||
|
# only (never on the main :3000 app listener), for a scraper such as
|
||||||
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
|
# METRICS_PORT=9464
|
||||||
|
#
|
||||||
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
||||||
|
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
||||||
|
# endpoint is public and the table has NO app-side retention, so enabling it
|
||||||
|
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
||||||
|
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
||||||
|
# does not exist and the client installs no observers.
|
||||||
|
# CLIENT_TELEMETRY_ENABLED=false
|
||||||
|
|||||||
@@ -18,12 +18,48 @@ env:
|
|||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Run the reusable test suite first so a failing test blocks the image build.
|
# Run the reusable test suite. Together with the e2e jobs below it gates the
|
||||||
|
# publish job (the image push), not the build itself — build runs in parallel.
|
||||||
test:
|
test:
|
||||||
uses: ./.github/workflows/test.yml
|
uses: ./.github/workflows/test.yml
|
||||||
|
|
||||||
|
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
|
||||||
|
# (GHA cache, scope develop-amd64). No push happens here — the publish job
|
||||||
|
# below is the only one that pushes the image.
|
||||||
build:
|
build:
|
||||||
needs: test
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build develop image (warm cache, no push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
|
push: false
|
||||||
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||||
|
|
||||||
|
# The gate: rebuilds from the cache the build job just wrote (near-instant on
|
||||||
|
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
|
||||||
|
# old sequential timing) and pushes :develop only when unit tests AND both
|
||||||
|
# e2e suites AND the build are green.
|
||||||
|
publish:
|
||||||
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -57,13 +93,10 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
cache-from: type=gha,scope=develop-amd64
|
||||||
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:
|
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
# lists them all).
|
||||||
# to the pusher — that red run + email is the intended notification, not a
|
|
||||||
# deploy block.
|
|
||||||
e2e-server:
|
e2e-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||||
@@ -118,15 +151,19 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||||
|
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||||
|
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
- name: Run server e2e
|
- name: Run server e2e
|
||||||
run: pnpm --filter ./apps/server test:e2e
|
run: pnpm --filter ./apps/server test:e2e
|
||||||
|
|
||||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
# Gates the publish too — see the comment above e2e-server.
|
||||||
# `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:
|
e2e-mcp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -72,6 +115,14 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||||
|
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||||
|
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||||
|
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||||
|
# unless it is built first. Build it before the recursive test run.
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -4,12 +4,21 @@
|
|||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
node_modules/
|
node_modules
|
||||||
|
|
||||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||||
# so src/ and prod can never silently diverge).
|
# so src/ and prod can never silently diverge).
|
||||||
packages/git-sync/build/
|
packages/git-sync/build/
|
||||||
|
|
||||||
|
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||||
|
# never committed, so src/ and prod can never silently diverge).
|
||||||
|
packages/prosemirror-markdown/build/
|
||||||
|
|
||||||
|
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||||
|
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||||
|
# markdown convention; the package is private and rebuilt at deploy.
|
||||||
|
packages/mcp/build/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -55,3 +64,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||||
apps/client/public/vad/
|
apps/client/public/vad/
|
||||||
|
|
||||||
|
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
.capacitor
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -213,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
|||||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> for the step-by-step and the traps.
|
||||||
|
>
|
||||||
|
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||||
|
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||||
|
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||||
|
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||||
|
> UI tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -249,7 +256,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -278,11 +288,12 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
@@ -292,8 +303,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). Remember `packages/mcp/build/` is committed — rebuild after editing.
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||||
contain a standalone footnote definition, which canonicalization would drop.
|
contain a standalone footnote definition, which canonicalization would drop.
|
||||||
(#228)
|
(#228)
|
||||||
|
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||||
|
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||||
|
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||||
|
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||||
|
losing the connection no longer aborts the turn — it keeps executing and
|
||||||
|
persisting server-side, and only an explicit Stop ends it. A client can
|
||||||
|
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||||
|
(resolve the latest run + its assistant message for a chat) and
|
||||||
|
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||||
|
enforces one active run per chat, and a startup sweep settles any run left
|
||||||
|
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||||
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
|
deployment. (#184)
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -153,9 +166,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
JSON-compatible schema (no custom tags / no code execution) behind the same
|
JSON-compatible schema (no custom tags / no code execution) behind the same
|
||||||
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
||||||
base-URL contract is unchanged. (#229)
|
base-URL contract is unchanged. (#229)
|
||||||
|
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||||
|
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||||
|
separately-hosted cross-domain client must now be listed in
|
||||||
|
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||||
|
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||||
|
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||||
|
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||||
|
REST/browser client is now rejected until its origin is added to
|
||||||
|
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
|
||||||
|
children, and comments are cached in IndexedDB (TanStack Query persister plus
|
||||||
|
`y-indexeddb` for the page's Yjs document), and a PWA service worker
|
||||||
|
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
|
||||||
|
offline. The two offline stores (the persisted query cache and the Yjs page
|
||||||
|
documents) are cleared on logout AND on sign-in so a previous user's private
|
||||||
|
data does not remain in the browser; the same purge also defensively drops any
|
||||||
|
legacy service-worker `api-get-cache` left by older clients (current builds
|
||||||
|
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
|
||||||
|
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
|
||||||
|
can request the access JWT in the response body (`data.authToken`) in addition
|
||||||
|
to the httpOnly cookie (the web client stays cookie-only); an optional
|
||||||
|
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
|
||||||
|
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
|
strategy was requested (our server never requests one). Unpatched, those
|
||||||
|
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||||
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
+24
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||||
|
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||||
|
# This stage is discarded, so the toolchain can stay installed.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -38,6 +45,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
|
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||||
|
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||||
|
# revisit at step 6 when #119 lands.)
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
@@ -49,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||||
|
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||||
|
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||||
|
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
USER node
|
||||||
|
|
||||||
RUN mkdir -p /app/data/storage
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -169,7 +169,7 @@ roles:
|
|||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
HOW TO WORK
|
HOW TO WORK
|
||||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important".
|
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -170,7 +170,7 @@ roles:
|
|||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
КАК РАБОТАТЬ
|
КАК РАБОТАТЬ
|
||||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное».
|
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ bundles:
|
|||||||
- slug: line-editor
|
- slug: line-editor
|
||||||
version: 4
|
version: 4
|
||||||
- slug: fact-checker
|
- slug: fact-checker
|
||||||
version: 5
|
version: 6
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 7
|
version: 8
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 2
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"hash": "d7769872968109a1ccfb58d71bc3f3564a750b91766156f59031762848de4f24"
|
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 4,
|
"version": 4,
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 7,
|
"version": 8,
|
||||||
"hash": "fdf8e0a443fa3c4102095e024146401363629a3f9015fb938c7bac2642825e56"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-touch-fullscreen" content="yes" />
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||||
|
|||||||
@@ -33,18 +33,22 @@
|
|||||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
"@tabler/icons-react": "3.40.0",
|
"@tabler/icons-react": "3.40.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "5.90.17",
|
||||||
"@tanstack/react-query": "5.90.17",
|
"@tanstack/react-query": "5.90.17",
|
||||||
|
"@tanstack/react-query-persist-client": "5.90.17",
|
||||||
"@tanstack/react-virtual": "3.13.24",
|
"@tanstack/react-virtual": "3.13.24",
|
||||||
"ai": "6.0.207",
|
"ai": "6.0.207",
|
||||||
"alfaaz": "1.1.0",
|
"alfaaz": "1.1.0",
|
||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
"i18next": "25.10.1",
|
"i18next": "25.10.1",
|
||||||
"i18next-http-backend": "3.0.6",
|
"i18next-http-backend": "3.0.6",
|
||||||
|
"idb-keyval": "6.2.5",
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
"jotai-optics": "0.4.0",
|
"jotai-optics": "0.4.0",
|
||||||
"js-cookie": "3.0.7",
|
"js-cookie": "3.0.7",
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "1.0.7",
|
"react-drawio": "1.0.7",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.1.1",
|
||||||
"react-helmet-async": "3.0.0",
|
"react-helmet-async": "3.0.0",
|
||||||
"react-i18next": "16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
@@ -96,6 +101,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.5",
|
"vite": "8.0.5",
|
||||||
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,6 +471,18 @@
|
|||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
"Syncing changes…": "Syncing changes…",
|
||||||
|
"All changes synced": "All changes synced",
|
||||||
|
"Update available": "Update available",
|
||||||
|
"Reload": "Reload",
|
||||||
|
"Make available offline": "Make available offline",
|
||||||
|
"Saving page for offline use...": "Saving page for offline use...",
|
||||||
|
"Page is now available offline": "Page is now available offline",
|
||||||
|
"Failed to make page available offline": "Failed to make page available offline",
|
||||||
|
"You're offline": "You're offline",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
"Retry": "Retry",
|
||||||
"Table of contents": "Table of contents",
|
"Table of contents": "Table of contents",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||||
"Share": "Share",
|
"Share": "Share",
|
||||||
@@ -1373,6 +1385,39 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"No bundles available": "No bundles available",
|
||||||
|
"Content": "Content",
|
||||||
|
"Content language of the roles": "Content language of the roles",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||||
|
"Update all ({{count}})": "Update all ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||||
|
"{{count}} roles": "{{count}} roles",
|
||||||
|
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||||
|
"All installed · up to date": "All installed · up to date",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||||
|
"{{count}} new": "{{count}} new",
|
||||||
|
"{{count}} installed": "{{count}} installed",
|
||||||
|
"{{count}} updates": "{{count}} updates",
|
||||||
|
"Install bundle": "Install bundle",
|
||||||
|
"Install {{count}} selected": "Install {{count}} selected",
|
||||||
|
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||||
|
"Select all": "Select all",
|
||||||
|
"Deselect all": "Deselect all",
|
||||||
|
"Skipped": "Skipped",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "{{count}} roles installed",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||||
|
"{{count}} roles updated": "{{count}} roles updated",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||||
|
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||||
|
"Rename & install": "Rename & install",
|
||||||
|
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"The catalog is empty": "The catalog is empty",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||||
"Already up to date": "Already up to date",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
@@ -1382,5 +1427,8 @@
|
|||||||
"Applied": "Applied",
|
"Applied": "Applied",
|
||||||
"Suggestion applied": "Suggestion applied",
|
"Suggestion applied": "Suggestion applied",
|
||||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,6 +487,18 @@
|
|||||||
"Move page": "Переместить страницу",
|
"Move page": "Переместить страницу",
|
||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||||
|
"Syncing changes…": "Синхронизация изменений…",
|
||||||
|
"All changes synced": "Все изменения синхронизированы",
|
||||||
|
"Update available": "Доступно обновление",
|
||||||
|
"Reload": "Перезагрузить",
|
||||||
|
"Make available offline": "Сделать доступным офлайн",
|
||||||
|
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||||
|
"Page is now available offline": "Страница доступна офлайн",
|
||||||
|
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||||
|
"You're offline": "Вы офлайн",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||||
|
"Retry": "Повторить",
|
||||||
"Table of contents": "Оглавление",
|
"Table of contents": "Оглавление",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||||
"Share": "Поделиться",
|
"Share": "Поделиться",
|
||||||
@@ -1235,6 +1247,39 @@
|
|||||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||||
"Please try again later.": "Попробуйте позже.",
|
"Please try again later.": "Попробуйте позже.",
|
||||||
"No bundles available": "Наборы недоступны",
|
"No bundles available": "Наборы недоступны",
|
||||||
|
"Content": "Язык контента",
|
||||||
|
"Content language of the roles": "Язык контента ролей",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||||
|
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||||
|
"{{count}} roles": "ролей: {{count}}",
|
||||||
|
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||||
|
"All installed · up to date": "Все установлены · актуальны",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||||
|
"{{count}} new": "новых: {{count}}",
|
||||||
|
"{{count}} installed": "установлено: {{count}}",
|
||||||
|
"{{count}} updates": "обновлений: {{count}}",
|
||||||
|
"Install bundle": "Установить набор",
|
||||||
|
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||||
|
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"Deselect all": "Снять выбор",
|
||||||
|
"Skipped": "Пропущено",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||||
|
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||||
|
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||||
|
"Rename & install": "Переименовать и установить",
|
||||||
|
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"The catalog is empty": "Каталог пуст",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
@@ -1245,5 +1290,8 @@
|
|||||||
"Applied": "Применено",
|
"Applied": "Применено",
|
||||||
"Suggestion applied": "Предложение применено",
|
"Suggestion applied": "Предложение применено",
|
||||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
|
"Dismiss": "Не применять",
|
||||||
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Gitmost",
|
"name": "Gitmost",
|
||||||
"short_name": "Gitmost",
|
"short_name": "Gitmost",
|
||||||
|
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||||
|
"lang": "en",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#0E1117",
|
"background_color": "#0E1117",
|
||||||
"theme_color": "#0E1117",
|
"theme_color": "#0E1117",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||||
"src": "icons/favicon-16x16.png",
|
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||||
"type": "image/png",
|
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||||
"sizes": "16x16"
|
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/favicon-32x32.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-192x192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "180x180 192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-512x512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useLocation, useMatch } from "react-router-dom";
|
import { useLocation, useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -41,13 +41,24 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
AI_CHAT_MESSAGES_RQ_KEY,
|
AI_CHAT_MESSAGES_RQ_KEY,
|
||||||
|
AI_CHAT_RUN_RQ_KEY,
|
||||||
useAiChatMessagesQuery,
|
useAiChatMessagesQuery,
|
||||||
|
useAiChatRunQuery,
|
||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import {
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldObserveRun,
|
||||||
|
} from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import {
|
||||||
|
exportAiChat,
|
||||||
|
stopRun,
|
||||||
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -234,6 +245,147 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
|
|
||||||
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
|
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
|
||||||
|
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||||
|
// run); but when the feature is off no runs are ever created, so polling it
|
||||||
|
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const autonomousRunsEnabled =
|
||||||
|
workspace?.settings?.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
|
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||||
|
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||||
|
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||||
|
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||||
|
// mount effect on every chat switch.
|
||||||
|
const [localStreaming, setLocalStreaming] = useState(false);
|
||||||
|
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||||
|
setLocalStreaming(streaming);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
|
||||||
|
// observer merge so the stopping run's still-persisting output does not
|
||||||
|
// re-stream back into view between the moment the user pressed Stop and the run
|
||||||
|
// actually settling as 'aborted' server-side. Polling itself keeps running (so
|
||||||
|
// the terminal transition is still detected) — only the visual merge is gated.
|
||||||
|
// Cleared when the run is observed terminal (below) or the chat is switched.
|
||||||
|
const [stoppingRun, setStoppingRun] = useState(false);
|
||||||
|
// Reset the stopping latch whenever the open chat changes: it is scoped to the
|
||||||
|
// run of the previously-open chat.
|
||||||
|
useEffect(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [activeChatId]);
|
||||||
|
|
||||||
|
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||||
|
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
|
||||||
|
// then request the server stop — the ONLY thing that ends a detached run; a mere
|
||||||
|
// local SSE abort is a client disconnect the server ignores. On failure we
|
||||||
|
// release the latch so the observer resumes (better to show the live run than to
|
||||||
|
// freeze the view) and surface the error.
|
||||||
|
const handleServerStop = useCallback(
|
||||||
|
(chatId: string): void => {
|
||||||
|
setStoppingRun(true);
|
||||||
|
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
|
||||||
|
// until the CURRENT turn's run is fetched fresh. Without this, once the local
|
||||||
|
// stream aborts (localStreaming -> false) the run query re-enables and
|
||||||
|
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
|
||||||
|
// terminal effect would then clear the stopping latch against that STALE run
|
||||||
|
// before the current turn's (still-running, detached, growing) run is ever
|
||||||
|
// observed — re-opening the observer merge and flashing the growing output
|
||||||
|
// over the frozen row. With the cache cleared the terminal effect's
|
||||||
|
// `if (!run) return` holds the latch until the current run itself is observed
|
||||||
|
// terminal (see shouldClearStoppingLatch).
|
||||||
|
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
|
||||||
|
void stopRun(chatId).catch(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to stop the run"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||||
|
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||||
|
// already has the live SSE — polling/merging too would double-render). The
|
||||||
|
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||||
|
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
|
||||||
|
activeChatId ?? undefined,
|
||||||
|
autonomousRunsEnabled && !localStreaming,
|
||||||
|
);
|
||||||
|
const run = runData?.run ?? null;
|
||||||
|
|
||||||
|
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
|
||||||
|
// `run` is null until the current turn's run is fetched fresh, and the terminal
|
||||||
|
// effect below holds the latch via `if (!run) return`. If that refetch instead
|
||||||
|
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
|
||||||
|
// streamer, the run stays null, its status-keyed refetchInterval is off, and
|
||||||
|
// nothing would ever observe a terminal run — freezing the view with the
|
||||||
|
// observer merge suppressed. Release the latch on that error so the live view
|
||||||
|
// resumes rather than stays stuck (the local stopRun may already have succeeded
|
||||||
|
// independently).
|
||||||
|
//
|
||||||
|
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
|
||||||
|
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
|
||||||
|
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
|
||||||
|
// pending/running — releasing then would re-open the observer merge and flash
|
||||||
|
// the growing detached run over the frozen row (the very flash F4 prevents). The
|
||||||
|
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
|
||||||
|
// on the run NOT being active: it cures only the genuine permanent-null-freeze
|
||||||
|
// (`run === null`) and never releases against an active run.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun,
|
||||||
|
isLocalStreaming: localStreaming,
|
||||||
|
runQueryFailed,
|
||||||
|
run,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [stoppingRun, localStreaming, runQueryFailed, run]);
|
||||||
|
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||||
|
// but only while we are an observer (never when we are the streamer — guards
|
||||||
|
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||||
|
// final persisted output is shown on reopen.
|
||||||
|
const observedRow =
|
||||||
|
shouldObserveRun(run, localStreaming) && !stoppingRun
|
||||||
|
? (runData?.message ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// When the observed run reaches a terminal status, do a final messages refetch
|
||||||
|
// so the persisted final state (token/context badge, export source) is shown,
|
||||||
|
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||||
|
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||||
|
const finalizedRunIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run || !activeChatId) return;
|
||||||
|
if (run.status === "pending" || run.status === "running") {
|
||||||
|
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||||
|
finalizedRunIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Terminal: a stop we requested has landed (or the run finished on its own),
|
||||||
|
// so release the stopping latch — the observer merge can now show the final
|
||||||
|
// persisted (aborted/finished) output without any live re-stream. The decision
|
||||||
|
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
|
||||||
|
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
|
||||||
|
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
|
||||||
|
// `run` null (this branch's `if (!run) return` above holds) until the current
|
||||||
|
// turn's run is fetched fresh, so the latch can never clear against a stale
|
||||||
|
// cached run.
|
||||||
|
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
|
||||||
|
setStoppingRun(false);
|
||||||
|
if (finalizedRunIdRef.current === run.id) return;
|
||||||
|
finalizedRunIdRef.current = run.id;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||||
|
});
|
||||||
|
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
|
||||||
|
|
||||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// pathname against the authenticated page route instead so "the current page"
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
@@ -882,6 +1034,18 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
onServerChatId={onServerChatId}
|
||||||
|
// #184: live-follow a still-running run when we reopened the chat as
|
||||||
|
// a passive observer; null when there is nothing to observe or this
|
||||||
|
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||||
|
// while we are the streamer.
|
||||||
|
observedRow={observedRow}
|
||||||
|
onStreamingChange={onStreamingChange}
|
||||||
|
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||||
|
// server stop (a local SSE abort is a client disconnect the server
|
||||||
|
// ignores). onServerStop also arms the "stopping" latch above so the
|
||||||
|
// stopped run's output does not re-stream via the observer merge.
|
||||||
|
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||||
|
onServerStop={handleServerStop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({
|
|||||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
setMessages: vi.fn(),
|
||||||
transport: null as null | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest: (arg: {
|
prepareSendMessagesRequest: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
@@ -30,6 +31,8 @@ vi.mock("@ai-sdk/react", () => ({
|
|||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||||
|
setMessages: h.state.setMessages,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -228,3 +231,56 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #184 passive-observer merge: when reconnecting to a still-running run, the
|
||||||
|
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
|
||||||
|
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
|
||||||
|
// SSE owns the view, so a stale observedRow must never overwrite it).
|
||||||
|
describe("ChatThread — observer run merge (#184)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.setMessages.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const observedRow = {
|
||||||
|
id: "a-run",
|
||||||
|
role: "assistant",
|
||||||
|
content: "step 1\nstep 2",
|
||||||
|
metadata: {
|
||||||
|
parts: [{ type: "text", text: "step 1\nstep 2" }],
|
||||||
|
},
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function renderObserver(status: string) {
|
||||||
|
h.state.status = status;
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<ChatThread
|
||||||
|
chatId="c1"
|
||||||
|
initialRows={[]}
|
||||||
|
onTurnFinished={vi.fn()}
|
||||||
|
observedRow={observedRow as never}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("merges the polled run message when this tab is a passive observer", () => {
|
||||||
|
renderObserver("ready");
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
// The updater replaces/append the observed assistant row by id.
|
||||||
|
const updater = h.state.setMessages.mock.calls[0][0] as (
|
||||||
|
prev: { id: string; parts: { text: string }[] }[],
|
||||||
|
) => { id: string; parts: { text: string }[] }[];
|
||||||
|
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged[1].id).toBe("a-run");
|
||||||
|
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
|
||||||
|
renderObserver("streaming");
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -86,6 +87,29 @@ interface ChatThreadProps {
|
|||||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
onServerChatId?: (serverChatId?: string) => void;
|
||||||
|
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||||
|
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||||
|
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||||
|
* persisted assistant message here; we merge it into the live list so new
|
||||||
|
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||||
|
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||||
|
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||||
|
* fight the local stream when we are the streamer. */
|
||||||
|
observedRow?: IAiChatMessageRow | null;
|
||||||
|
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||||
|
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||||
|
* resume once we go idle. Called from an effect on every transition. */
|
||||||
|
onStreamingChange?: (streaming: boolean) => void;
|
||||||
|
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
||||||
|
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
||||||
|
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
||||||
|
* which the server deliberately ignores, so the detached run would keep going. */
|
||||||
|
autonomousRunsEnabled?: boolean;
|
||||||
|
/** #184: request the server-side stop of this chat's active run (the parent owns
|
||||||
|
* the endpoint call + the "stopping" latch that keeps observer-polling from
|
||||||
|
* immediately re-streaming the stopping run's output). Called with the resolved
|
||||||
|
* chat id when the user presses Stop in autonomous mode. */
|
||||||
|
onServerStop?: (chatId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +155,10 @@ export default function ChatThread({
|
|||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
|
observedRow,
|
||||||
|
onStreamingChange,
|
||||||
|
autonomousRunsEnabled,
|
||||||
|
onServerStop,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -216,6 +244,16 @@ export default function ChatThread({
|
|||||||
const flushOnAbortRef = useRef(false);
|
const flushOnAbortRef = useRef(false);
|
||||||
const interruptNextSendRef = useRef(false);
|
const interruptNextSendRef = useRef(false);
|
||||||
|
|
||||||
|
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
|
||||||
|
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
|
||||||
|
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
|
||||||
|
// autonomous run — it keeps burning tokens and WRITING TO PAGES — so we cannot
|
||||||
|
// just no-op. We latch the stop as PENDING and fire the authoritative server
|
||||||
|
// stop the moment onServerChatId adopts the id (below). Read-and-cleared there;
|
||||||
|
// also defused on every new turn start so it can never fire against a later,
|
||||||
|
// unrelated turn's run.
|
||||||
|
const stopPendingRef = useRef(false);
|
||||||
|
|
||||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
// 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
|
// Returns whether a message was actually sent, so callers can tell an empty
|
||||||
// dequeue (nothing to flush) from a real send.
|
// dequeue (nothing to flush) from a real send.
|
||||||
@@ -274,7 +312,7 @@ export default function ChatThread({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { messages, sendMessage, status, stop, error } = useChat({
|
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// every render mid-stream (see `chatStoreId` above).
|
||||||
@@ -365,7 +403,14 @@ export default function ChatThread({
|
|||||||
return;
|
return;
|
||||||
lastForwardedChatIdRef.current = serverChatId;
|
lastForwardedChatIdRef.current = serverChatId;
|
||||||
onServerChatId(serverChatId);
|
onServerChatId(serverChatId);
|
||||||
}, [messages, onServerChatId]);
|
// #234 F5: if Stop was pressed before the id was known, the authoritative
|
||||||
|
// server stop was deferred to this adoption point — fire it now with the
|
||||||
|
// just-adopted id. One-shot (read-and-clear) so it can't fire twice.
|
||||||
|
if (stopPendingRef.current) {
|
||||||
|
stopPendingRef.current = false;
|
||||||
|
onServerStop?.(serverChatId);
|
||||||
|
}
|
||||||
|
}, [messages, onServerChatId, onServerStop]);
|
||||||
|
|
||||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||||
@@ -378,6 +423,27 @@ export default function ChatThread({
|
|||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
// #184: report our live streaming status up so the parent stops polling the run
|
||||||
|
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||||
|
// Effect (not render) so it never updates parent state during our own render;
|
||||||
|
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||||
|
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||||
|
useEffect(() => {
|
||||||
|
onStreamingChange?.(isStreaming);
|
||||||
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
|
// #184 passive-observer merge: when the parent feeds a polled run message (we
|
||||||
|
// reopened a chat whose run is still going and did NOT start it here), merge it
|
||||||
|
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
|
||||||
|
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
|
||||||
|
// owns the view and a stale observedRow must never overwrite it. `observedRow`
|
||||||
|
// is a stable per-poll object, so this runs once per poll, not per render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming || !observedRow) return;
|
||||||
|
const observed = rowToUiMessage(observedRow);
|
||||||
|
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||||
|
}, [observedRow, isStreaming, setMessages]);
|
||||||
|
|
||||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
// stay queued and flush normally after the new turn. Reuses the existing
|
// stay queued and flush normally after the new turn. Reuses the existing
|
||||||
@@ -409,6 +475,40 @@ export default function ChatThread({
|
|||||||
[setQueue, stop],
|
[setQueue, stop],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||||
|
// returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run:
|
||||||
|
// aborting the local SSE is only a client disconnect, which the server ignores,
|
||||||
|
// so the run would keep executing — we ADDITIONALLY request the authoritative
|
||||||
|
// server-side stop (the parent owns that call + the "stopping" latch that keeps
|
||||||
|
// observer-polling from re-streaming the stopping run's output). The chat id is
|
||||||
|
// read live from chatIdRef (adopted early at the stream's `start` chunk); if it
|
||||||
|
// is not known yet — a brand-new chat in the first moment of its first turn —
|
||||||
|
// only the local abort happens (there is no server-side run handle to stop yet).
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
if (!autonomousRunsEnabled) return;
|
||||||
|
if (chatIdRef.current) {
|
||||||
|
onServerStop?.(chatIdRef.current);
|
||||||
|
} else {
|
||||||
|
// #234 F5: no chat id yet (brand-new chat in the first moment of its first
|
||||||
|
// turn, before the `start` chunk adopted the id). Latch the stop as pending;
|
||||||
|
// the onServerChatId adoption effect fires the deferred server stop as soon
|
||||||
|
// as the id appears, so the detached run is still authoritatively stopped
|
||||||
|
// instead of left running by a silent local-only abort.
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (#234 F5 review): `stop()` above has already aborted the
|
||||||
|
// local SSE reader. In the rare sub-window where Stop is pressed while still
|
||||||
|
// `submitted` (request sent, not one chunk read yet), that abort can cancel
|
||||||
|
// the reader BEFORE the `start` chunk is applied to `messages`, so the
|
||||||
|
// adoption effect never runs and this pending stop never fires. The detached
|
||||||
|
// run then keeps going for that turn. This is not a regression (the pre-fix
|
||||||
|
// behavior sent no server stop at all); closing it fully would require
|
||||||
|
// deferring the local abort until adoption, which is riskier and out of scope
|
||||||
|
// for this fix. Documented so a future change can address the abort-ordering.
|
||||||
|
stopPendingRef.current = true;
|
||||||
|
}
|
||||||
|
}, [stop, autonomousRunsEnabled, onServerStop]);
|
||||||
|
|
||||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
// 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
|
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||||
@@ -420,6 +520,11 @@ export default function ChatThread({
|
|||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
flushOnAbortRef.current = false;
|
flushOnAbortRef.current = false;
|
||||||
interruptNextSendRef.current = false;
|
interruptNextSendRef.current = false;
|
||||||
|
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
|
||||||
|
// previous turn that never adopted an id, so it can never fire against this
|
||||||
|
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
|
||||||
|
// set AFTER this effect (on the Stop click), so this does not clobber it.
|
||||||
|
stopPendingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
@@ -539,7 +644,7 @@ export default function ChatThread({
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => sendMessage({ text })}
|
onSend={(text) => sendMessage({ text })}
|
||||||
onQueue={enqueue}
|
onQueue={enqueue}
|
||||||
onStop={stop}
|
onStop={handleStop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
deleteAiChat,
|
deleteAiChat,
|
||||||
deleteAiRole,
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
|
getAiChatRun,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
getAiRoleCatalog,
|
getAiRoleCatalog,
|
||||||
getAiRoleCatalogBundle,
|
getAiRoleCatalogBundle,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
IAiRoleUpdateFromCatalogResult,
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
|
|
||||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||||
@@ -51,16 +55,18 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
chatId,
|
||||||
];
|
];
|
||||||
|
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||||
|
|
||||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHATS_RQ_KEY,
|
queryKey: AI_CHATS_RQ_KEY,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||||
@@ -90,7 +96,9 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
enabled: !!chatId,
|
enabled: !!chatId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +139,34 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||||
|
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||||
|
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||||
|
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||||
|
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||||
|
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||||
|
*
|
||||||
|
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||||
|
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||||
|
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||||
|
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||||
|
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||||
|
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||||
|
* failed fetch can never spin a tight loop.
|
||||||
|
*/
|
||||||
|
export function useAiChatRunQuery(
|
||||||
|
chatId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery<IAiChatRunResponse, Error>({
|
||||||
|
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||||
|
queryFn: () => getAiChatRun(chatId as string),
|
||||||
|
enabled: !!chatId && enabled,
|
||||||
|
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRenameAiChatMutation() {
|
export function useRenameAiChatMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -272,6 +308,29 @@ export function useAiRoleCatalogBundleQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||||
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||||
|
* which needs every role's install state up front — so contents can no longer be
|
||||||
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||||
|
* cached read per bundle, sharing the same cache keys as
|
||||||
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||||
|
* resolved language) so nothing fetches while the modal is closed.
|
||||||
|
*/
|
||||||
|
export function useAiRoleCatalogBundlesQueries(
|
||||||
|
bundleIds: string[],
|
||||||
|
language: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQueries({
|
||||||
|
queries: bundleIds.map((bundleId) => ({
|
||||||
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||||
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||||
|
enabled: enabled && !!language,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportAiRolesFromCatalogMutation() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -280,11 +339,14 @@ export function useImportAiRolesFromCatalogMutation() {
|
|||||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
message: t(
|
||||||
|
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||||
|
{
|
||||||
created: result.created,
|
created: result.created,
|
||||||
renamed: result.renamed,
|
renamed: result.renamed,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||||
|
// use it); stub it so the module imports cleanly in this hook test.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||||
|
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
getAiChatRun: vi.fn(),
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningResponse: IAiChatRunResponse = {
|
||||||
|
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||||
|
message: {
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "working...",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useAiChatRunQuery — enable gating", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||||
|
expect(result.current.data?.run?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
// Give any errant fetch a chance to fire, then assert none did.
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when there is no chat id", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
await runMutation({
|
||||||
|
created: 3,
|
||||||
|
renamed: 1,
|
||||||
|
skipped: 2,
|
||||||
|
errors: [],
|
||||||
|
createdRoles: [],
|
||||||
|
skippedRoles: [],
|
||||||
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IAiChatListParams,
|
IAiChatListParams,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -42,6 +43,38 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||||
|
* persisted lifecycle state and the assistant message it materializes (the
|
||||||
|
* partial output while the run is in-flight, the final output once it finished).
|
||||||
|
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||||
|
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||||
|
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||||
|
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||||
|
* runs, so the endpoint returns `{ run: null }`.
|
||||||
|
*/
|
||||||
|
export async function getAiChatRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<IAiChatRunResponse> {
|
||||||
|
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||||
|
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||||
|
* is deliberately ignored server-side, so the client must call this to actually
|
||||||
|
* stop an autonomous run. Targeted by `chatId` (the server resolves whatever run
|
||||||
|
* is active on it); owner-gated server-side. Returns `{ stopped }` — false when
|
||||||
|
* there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
export async function stopRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<{ stopped: boolean }> {
|
||||||
|
const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
|
|||||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
|||||||
conflict: "skip" | "rename";
|
conflict: "skip" | "rename";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
/**
|
||||||
|
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||||
|
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||||
|
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||||
|
* result plaque — which roles were installed (and any rename) and which were
|
||||||
|
* skipped and why (so the plaque can name the conflicting role and offer
|
||||||
|
* "Rename & install").
|
||||||
|
*/
|
||||||
export interface IAiRoleImportResult {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: "name-conflict" | "already-installed";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +213,38 @@ export interface IAiChatMessageRow {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||||
|
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||||
|
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||||
|
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||||
|
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRun {
|
||||||
|
id: string;
|
||||||
|
chatId: string;
|
||||||
|
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||||
|
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||||
|
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||||
|
error?: string | null;
|
||||||
|
stepCount?: number;
|
||||||
|
assistantMessageId?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||||
|
* assistant message it materializes (the partial/final output, projected from the
|
||||||
|
* persisted rows). Both are `null` when the chat has never had a run.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRunResponse {
|
||||||
|
run: IAiChatRun | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAiChatListParams extends QueryParams {}
|
export interface IAiChatListParams extends QueryParams {}
|
||||||
|
|
||||||
export interface IAiChatMessagesParams {
|
export interface IAiChatMessagesParams {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleCounts,
|
||||||
|
bundlePhase,
|
||||||
|
installedLangForRole,
|
||||||
|
mapBundleRolesToView,
|
||||||
|
mapCatalogRoleToView,
|
||||||
|
nameConflictSlugs,
|
||||||
|
partialOffersRename,
|
||||||
|
type CatalogViewRole,
|
||||||
|
} from "./catalog-bundle-model.ts";
|
||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogRole(
|
||||||
|
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||||
|
): IAiRoleCatalogRole {
|
||||||
|
return {
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
instructions: "be a writer",
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
version: 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal view role for bundlePhase tests.
|
||||||
|
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||||
|
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bundlePhase", () => {
|
||||||
|
it("empty bundle -> empty", () => {
|
||||||
|
expect(bundlePhase([])).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all importable, none installed -> allNew", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||||
|
"allNew",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing to import or update -> allInstalled", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||||
|
"allInstalled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates present, nothing to import -> updates", () => {
|
||||||
|
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||||
|
"updates",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + installed (no updates) -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + update -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||||
|
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||||
|
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||||
|
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||||
|
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleCounts", () => {
|
||||||
|
it("tallies each status once", () => {
|
||||||
|
expect(
|
||||||
|
bundleCounts([
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("installed"),
|
||||||
|
viewRole("update"),
|
||||||
|
viewRole("skipped"),
|
||||||
|
]),
|
||||||
|
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||||
|
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installedLangForRole", () => {
|
||||||
|
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no install of the slug exists", () => {
|
||||||
|
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source)", () => {
|
||||||
|
const roles = [
|
||||||
|
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||||
|
source: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCatalogRoleToView", () => {
|
||||||
|
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
status: "import",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
expect(view.installedRoleId).toBeUndefined();
|
||||||
|
expect(view.installedLang).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import with the slug installed in another language -> installedLang set", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||||
|
expect(view.status).toBe("import");
|
||||||
|
expect(view.installedLang).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "installed",
|
||||||
|
version: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "update",
|
||||||
|
version: 1,
|
||||||
|
newVersion: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||||
|
const view = mapCatalogRoleToView(
|
||||||
|
catalogRole({ emoji: null, description: null }),
|
||||||
|
[],
|
||||||
|
"en",
|
||||||
|
);
|
||||||
|
expect(view.emoji).toBeUndefined();
|
||||||
|
expect(view.description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapBundleRolesToView", () => {
|
||||||
|
it("maps a bundle's roles preserving order", () => {
|
||||||
|
const roles = [
|
||||||
|
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||||
|
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||||
|
];
|
||||||
|
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||||
|
const view = mapBundleRolesToView(roles, installed, "en");
|
||||||
|
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||||
|
expect(view[0].status).toBe("installed");
|
||||||
|
expect(view[1].status).toBe("import");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||||
|
* (readable without expanding) and a single primary action. The per-role and
|
||||||
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||||
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||||
|
* computation are unit-testable without mounting the component (mirrors the
|
||||||
|
* `catalogRoleInstallState` precedent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A role's status in the catalog view model.
|
||||||
|
* - `import` — not installed in the current content language.
|
||||||
|
* - `installed` — installed and up to date.
|
||||||
|
* - `update` — installed, but the catalog ships a newer version.
|
||||||
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||||
|
* (a name collision under `conflict:'skip'`); never from the
|
||||||
|
* backend.
|
||||||
|
*/
|
||||||
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||||
|
|
||||||
|
/** A catalog role mapped into the modal's view model. */
|
||||||
|
export interface CatalogViewRole {
|
||||||
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||||
|
// `slugs[]` payload for import.
|
||||||
|
slug: string;
|
||||||
|
// Optional in the catalog — the row reserves space and renders nothing when
|
||||||
|
// absent.
|
||||||
|
emoji?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||||
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||||
|
version: number;
|
||||||
|
newVersion?: number;
|
||||||
|
status: RoleStatus;
|
||||||
|
// The language a same-slug role is installed under, when it differs from the
|
||||||
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||||
|
installedLang?: string;
|
||||||
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||||
|
// update-from-catalog mutation.
|
||||||
|
installedRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||||
|
* the collapsed-header summary and the bundle's single primary action.
|
||||||
|
* - `empty` — the bundle has no roles.
|
||||||
|
* - `allNew` — everything is importable, nothing installed.
|
||||||
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||||
|
* - `updates` — updates available and nothing left to import.
|
||||||
|
* - `mixed` — any other combination.
|
||||||
|
*/
|
||||||
|
export type BundlePhase =
|
||||||
|
| "empty"
|
||||||
|
| "allNew"
|
||||||
|
| "allInstalled"
|
||||||
|
| "updates"
|
||||||
|
| "mixed";
|
||||||
|
|
||||||
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||||
|
export interface BundleCounts {
|
||||||
|
importable: number;
|
||||||
|
installed: number;
|
||||||
|
update: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||||
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||||
|
*/
|
||||||
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||||
|
const counts: BundleCounts = {
|
||||||
|
importable: 0,
|
||||||
|
installed: 0,
|
||||||
|
update: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r.status === "import") counts.importable += 1;
|
||||||
|
else if (r.status === "installed") counts.installed += 1;
|
||||||
|
else if (r.status === "update") counts.update += 1;
|
||||||
|
else if (r.status === "skipped") counts.skipped += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||||
|
if (roles.length === 0) return "empty";
|
||||||
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||||
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||||
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||||
|
// would make the collapsed green "up to date" header contradict the open
|
||||||
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||||
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||||
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||||
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||||
|
return "allNew";
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||||
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||||
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||||
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||||
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||||
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||||
|
*/
|
||||||
|
export function nameConflictSlugs(
|
||||||
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): string[] {
|
||||||
|
return skipped
|
||||||
|
.filter((s) => s.reason === "name-conflict")
|
||||||
|
.map((s) => s.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partial-import result should offer the "Rename & install" action:
|
||||||
|
* only when at least one skip is a name conflict (renameable). An
|
||||||
|
* `already-installed`-only partial is informational.
|
||||||
|
*/
|
||||||
|
export function partialOffersRename(
|
||||||
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): boolean {
|
||||||
|
return skipped.some((s) => s.reason === "name-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a role NOT installed in the current `language`, find a workspace role with
|
||||||
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||||
|
* language. Drives the "installed in another language" hint (Р5): a different
|
||||||
|
* language of the same slug is a separate install and appears as `import`.
|
||||||
|
*/
|
||||||
|
export function installedLangForRole(
|
||||||
|
slug: string,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): string | undefined {
|
||||||
|
const other = workspaceRoles.find(
|
||||||
|
(r) =>
|
||||||
|
r.source?.slug === slug &&
|
||||||
|
!!r.source?.language &&
|
||||||
|
r.source.language !== language,
|
||||||
|
);
|
||||||
|
return other?.source?.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one catalog role to the view model, computing its install status against
|
||||||
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||||
|
* the other-language hint.
|
||||||
|
*/
|
||||||
|
export function mapCatalogRoleToView(
|
||||||
|
role: IAiRoleCatalogRole,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole {
|
||||||
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||||
|
const base = {
|
||||||
|
slug: role.slug,
|
||||||
|
emoji: role.emoji ?? undefined,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
};
|
||||||
|
if (state.state === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "update",
|
||||||
|
version: state.fromVersion,
|
||||||
|
newVersion: state.toVersion,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.state === "installed") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "installed",
|
||||||
|
version: role.version,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "import",
|
||||||
|
version: role.version,
|
||||||
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||||
|
*/
|
||||||
|
export function mapBundleRolesToView(
|
||||||
|
roles: IAiRoleCatalogRole[],
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole[] {
|
||||||
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
RUN_POLL_INTERVAL_MS,
|
||||||
|
isRunActive,
|
||||||
|
runPollInterval,
|
||||||
|
shouldObserveRun,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
mergeObservedMessage,
|
||||||
|
} from "./run-polling.ts";
|
||||||
|
|
||||||
|
function makeRun(status: string): IAiChatRun {
|
||||||
|
return { id: "run-1", chatId: "c1", status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
} as UIMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isRunActive", () => {
|
||||||
|
it("treats pending and running as active", () => {
|
||||||
|
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||||
|
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats terminal / unknown / nullish as not active", () => {
|
||||||
|
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||||
|
expect(isRunActive(null)).toBe(false);
|
||||||
|
expect(isRunActive(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||||
|
it("returns 2000ms while the run is pending/running", () => {
|
||||||
|
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (stop polling) once the run is terminal", () => {
|
||||||
|
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no polling) when there is no run", () => {
|
||||||
|
expect(runPollInterval(null)).toBe(false);
|
||||||
|
expect(runPollInterval(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||||
|
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||||
|
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when there is no run", () => {
|
||||||
|
expect(shouldObserveRun(null, false)).toBe(false);
|
||||||
|
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
||||||
|
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
||||||
|
// observer (not streaming), and the CURRENT run is terminal.
|
||||||
|
it("clears only when stopping, observing, and the run is terminal", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("failed"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
||||||
|
// re-open the flash for the current turn the moment we switch to observer role.
|
||||||
|
// A predicate lacking the streaming gate would (wrongly) return true here.
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detached run keeps growing after a local abort — while it is still
|
||||||
|
// active the latch MUST hold so the observer merge stays suppressed.
|
||||||
|
it("does NOT clear while the run is still active", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
||||||
|
// observed `run` is null until the current turn's run is fetched fresh. A null
|
||||||
|
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
||||||
|
// only against the current turn's own terminal run once observed.
|
||||||
|
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: null,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: undefined,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: false,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
||||||
|
// This guards the REAL anti-flash decision the component's run-query-error
|
||||||
|
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
||||||
|
// not a copy — so the test is non-vacuous vs the live code).
|
||||||
|
|
||||||
|
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
||||||
|
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
||||||
|
// runQueryFailed can be true while the held run is still pending/running.
|
||||||
|
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
||||||
|
// true — so this assertion fails on the buggy code (non-vacuous).
|
||||||
|
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
||||||
|
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
||||||
|
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
||||||
|
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
||||||
|
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
||||||
|
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
||||||
|
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear without an actual query error", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: false,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: true,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: false,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeObservedMessage", () => {
|
||||||
|
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
expect(next[0]).toBe(prev[0]); // untouched
|
||||||
|
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends when the observed message is not yet present", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
const observed = makeMsg("a1", "first token");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||||
|
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||||
|
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||||
|
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||||
|
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||||
|
* assistant message into the rendered thread. These are the small pure decisions
|
||||||
|
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||||
|
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||||
|
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||||
|
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||||
|
// so a stale/odd value never polls forever).
|
||||||
|
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||||
|
|
||||||
|
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||||
|
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||||
|
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||||
|
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||||
|
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||||
|
* reaching a terminal status — no separate timeout cap is needed.
|
||||||
|
*/
|
||||||
|
export function runPollInterval(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
): number | false {
|
||||||
|
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||||
|
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||||
|
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||||
|
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||||
|
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||||
|
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||||
|
* stopped separately by {@link runPollInterval}.
|
||||||
|
*/
|
||||||
|
export function shouldObserveRun(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
localStreaming: boolean,
|
||||||
|
): boolean {
|
||||||
|
return !!run && !localStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch — which suppresses the observer re-stream flash
|
||||||
|
* after the user pressed Stop — be RELEASED now? All three must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
|
||||||
|
* streamer the run query is disabled, so the observed `run` is not the run we
|
||||||
|
* are following — releasing the latch then would re-open the flash for the
|
||||||
|
* current turn the instant we switch to observer role;
|
||||||
|
* - the observed `run` EXISTS and has reached a TERMINAL status.
|
||||||
|
*
|
||||||
|
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
|
||||||
|
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
|
||||||
|
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
|
||||||
|
* therefore HOLDS the latch, so it can only ever clear against the current turn's
|
||||||
|
* OWN terminal run — never a stale cached one. (The cache removal itself is
|
||||||
|
* integration-level in AiChatWindow; this predicate encodes the decision given
|
||||||
|
* whatever run is currently observed, and a stale terminal run is
|
||||||
|
* indistinguishable from a current terminal run at the predicate level — hence
|
||||||
|
* the cache removal is what guarantees only the current run is ever passed here.)
|
||||||
|
*/
|
||||||
|
export function shouldClearStoppingLatch(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, run, isLocalStreaming } = args;
|
||||||
|
if (!stoppingRun || isLocalStreaming) return false;
|
||||||
|
return !!run && !isRunActive(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
|
||||||
|
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
|
||||||
|
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
|
||||||
|
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
|
||||||
|
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
|
||||||
|
* its status-keyed refetchInterval is off, and nothing would ever observe a
|
||||||
|
* terminal run — freezing the view with the observer merge suppressed. This
|
||||||
|
* safety-net cures ONLY that genuine permanent-null-freeze.
|
||||||
|
*
|
||||||
|
* All four must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
|
||||||
|
* {@link shouldClearStoppingLatch});
|
||||||
|
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
|
||||||
|
* retry:false — isError);
|
||||||
|
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
|
||||||
|
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
|
||||||
|
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
|
||||||
|
* ACTIVE run (a single transient GET-run failure in the window between Stop and
|
||||||
|
* settle). Without this gate a transient error would release the latch early —
|
||||||
|
* re-opening the observer merge and flashing the growing detached run over the
|
||||||
|
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
|
||||||
|
* only ever cure the permanent-null-freeze (`run === null`, so
|
||||||
|
* `isRunActive(null)` is false), never release against an active run.
|
||||||
|
*
|
||||||
|
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
|
||||||
|
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
|
||||||
|
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
|
||||||
|
*
|
||||||
|
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
|
||||||
|
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
|
||||||
|
* polling when the data is empty — so after we clear on null+error there is no
|
||||||
|
* subsequent auto-poll that could return a still-active detached run and re-open the
|
||||||
|
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
|
||||||
|
* error, this null-branch clear would re-open the F7 flash through the null path.
|
||||||
|
* Do not change the run query's refetchInterval without re-checking this path.
|
||||||
|
*/
|
||||||
|
export function shouldClearLatchOnQueryError(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
runQueryFailed: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
|
||||||
|
return (
|
||||||
|
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an observed assistant message into the rendered list: replace the message
|
||||||
|
* with the same id in place (the in-progress assistant row is already seeded from
|
||||||
|
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||||
|
* new array; the input is never mutated.
|
||||||
|
*/
|
||||||
|
export function mergeObservedMessage(
|
||||||
|
messages: UIMessage[],
|
||||||
|
observed: UIMessage | null | undefined,
|
||||||
|
): UIMessage[] {
|
||||||
|
if (!observed) return messages;
|
||||||
|
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||||
|
if (idx === -1) return [...messages, observed];
|
||||||
|
const next = messages.slice();
|
||||||
|
next[idx] = observed;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// react-router-dom: only useNavigate is used by the hook.
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The auth service is the network boundary; stub login/logout per test.
|
||||||
|
const loginMock = vi.fn();
|
||||||
|
const logoutMock = vi.fn();
|
||||||
|
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||||
|
login: (...args: unknown[]) => loginMock(...args),
|
||||||
|
logout: (...args: unknown[]) => logoutMock(...args),
|
||||||
|
forgotPassword: vi.fn(),
|
||||||
|
passwordReset: vi.fn(),
|
||||||
|
setupWorkspace: vi.fn(),
|
||||||
|
verifyUserToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||||
|
acceptInvitation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The offline cache purge is the unit under test — assert it is invoked.
|
||||||
|
const clearOfflineCacheMock = vi.fn();
|
||||||
|
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||||
|
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// app-route helpers are pure config; provide deterministic values.
|
||||||
|
vi.mock("@/lib/app-route.ts", () => ({
|
||||||
|
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||||
|
getPostLoginRedirect: () => "/home",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import useAuth from "./use-auth";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
loginMock.mockReset();
|
||||||
|
loginMock.mockResolvedValue(undefined);
|
||||||
|
logoutMock.mockReset();
|
||||||
|
logoutMock.mockResolvedValue(undefined);
|
||||||
|
clearOfflineCacheMock.mockReset();
|
||||||
|
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleSignIn", () => {
|
||||||
|
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
loginMock.mockImplementation(async () => {
|
||||||
|
order.push("login");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
// The purge must run before the new session's login resolves.
|
||||||
|
expect(order).toEqual(["clear", "login"]);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login still proceeds despite the cleanup failure.
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleLogout", () => {
|
||||||
|
const replaceMock = vi.fn();
|
||||||
|
let originalLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
replaceMock.mockReset();
|
||||||
|
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
||||||
|
// is a non-configurable method that warns "not implemented", so swap the
|
||||||
|
// whole location object for one whose `replace` we can capture.
|
||||||
|
originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { replace: replaceMock },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
replaceMock.mockImplementation((url: string) => {
|
||||||
|
order.push(`replace:${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
// Purge must complete before the redirect (which would otherwise interrupt
|
||||||
|
// the async cleanup).
|
||||||
|
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The thrown purge error is swallowed and the redirect still fires.
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
|||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
|
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,6 +35,20 @@ export default function useAuth() {
|
|||||||
const handleSignIn = async (data: ILogin) => {
|
const handleSignIn = async (data: ILogin) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||||
|
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||||
|
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||||
|
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||||
|
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||||
|
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||||
|
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||||
|
// never block sign-in on cache cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block sign-in on cache cleanup
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -129,6 +144,13 @@ export default function useAuth() {
|
|||||||
// remain.)
|
// remain.)
|
||||||
clearPersistedTreeCaches();
|
clearPersistedTreeCaches();
|
||||||
await logout();
|
await logout();
|
||||||
|
// Purge the previous user's offline data while the page is still alive —
|
||||||
|
// window.location.replace below would otherwise interrupt async cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block logout on cache cleanup
|
||||||
|
}
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { collabTokenRetry } from "./auth-query";
|
||||||
|
|
||||||
|
// Regression for the offline white-screen (#237/#238): offline the collab-token
|
||||||
|
// POST rejects as an axios NETWORK error (isAxiosError === true but
|
||||||
|
// error.response === undefined). The old predicate read `error.response.status`
|
||||||
|
// without a guard and threw an uncaught TypeError inside the React Query retryer
|
||||||
|
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
|
||||||
|
describe("collabTokenRetry", () => {
|
||||||
|
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
|
||||||
|
// An axios error with no `response` is exactly the offline/network-failure shape.
|
||||||
|
const networkError = new AxiosError("Network Error");
|
||||||
|
expect(networkError.response).toBeUndefined();
|
||||||
|
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, networkError);
|
||||||
|
}).not.toThrow();
|
||||||
|
// Network failures stay retryable (truthy), matching the original intent.
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no retry) for a real 404 response", () => {
|
||||||
|
const notFound = new AxiosError("Not Found");
|
||||||
|
notFound.response = { status: 404 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, notFound)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries for a non-404 response (e.g. 500)", () => {
|
||||||
|
const serverError = new AxiosError("Server Error");
|
||||||
|
serverError.response = { status: 500 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, serverError)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw and retries for a non-axios error", () => {
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, new Error("boom"));
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
|||||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry predicate for the collab-token query.
|
||||||
|
*
|
||||||
|
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||||
|
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||||
|
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||||
|
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||||
|
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||||
|
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||||
|
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||||
|
*/
|
||||||
|
export function collabTokenRetry(
|
||||||
|
_failureCount: number,
|
||||||
|
error: Error,
|
||||||
|
): boolean {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVerifyUserTokenQuery(
|
export function useVerifyUserTokenQuery(
|
||||||
verify: IVerifyUserToken,
|
verify: IVerifyUserToken,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
|||||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||||
//refetchIntervalInBackground: true,
|
//refetchIntervalInBackground: true,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
//@ts-ignore
|
retry: collabTokenRetry,
|
||||||
retry: (failureCount, error) => {
|
|
||||||
if (isAxiosError(error) && error.response.status === 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return 10;
|
|
||||||
},
|
|
||||||
retryDelay: (retryAttempt) => {
|
retryDelay: (retryAttempt) => {
|
||||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||||
|
// safety valve fired without pulling in the editor stack.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||||
|
// case renders in isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CommentContentView } from "./comment-content-view";
|
||||||
|
|
||||||
|
function renderView(content: string | object) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentContentView content={content} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||||
|
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||||
|
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||||
|
|
||||||
|
describe("CommentContentView", () => {
|
||||||
|
it("renders paragraphs as <p> with text", () => {
|
||||||
|
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||||
|
expect(screen.getByText("Hello world")).toBeDefined();
|
||||||
|
expect(container.querySelector("p")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||||
|
const { container } = renderView(doc([para([text("x")])]));
|
||||||
|
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||||
|
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||||
|
expect(globalPm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bold mark as <strong>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||||
|
);
|
||||||
|
const el = container.querySelector("strong");
|
||||||
|
expect(el?.textContent).toBe("bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the italic mark as <em>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("it", [{ type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the strike mark as <s>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("st", [{ type: "strike" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("un", [{ type: "underline" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||||
|
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the code mark as <code>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("co", [{ type: "code" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "https://example.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||||
|
expect(a?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
// No navigable javascript: href — attribute is absent (or empty).
|
||||||
|
expect(a?.getAttribute("href")).toBeFalsy();
|
||||||
|
// The link text is still rendered.
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a data: link href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("mail", [
|
||||||
|
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"mailto:a@b.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"/some/path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests multiple marks on one text node", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||||
|
expect(container.querySelector("strong")).not.toBeNull();
|
||||||
|
expect(container.querySelector("em")).not.toBeNull();
|
||||||
|
expect(screen.getByText("x")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hardBreak as <br/>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("br")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user mention as a styled span", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("@Alice")).toBeDefined();
|
||||||
|
// No fallback to the editor.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page mention as a link", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
label: "Some Page",
|
||||||
|
entityType: "page",
|
||||||
|
slugId: "pg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Some Page")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||||
|
renderView("just a legacy string");
|
||||||
|
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for an unknown node type", () => {
|
||||||
|
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for malformed JSON", () => {
|
||||||
|
renderView('{"type":"doc","content":[');
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from "react";
|
||||||
|
import classes from "./comment.module.css";
|
||||||
|
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
|
||||||
|
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||||
|
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||||
|
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||||
|
//
|
||||||
|
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||||
|
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||||
|
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||||
|
// so we never show a half-rendered comment.
|
||||||
|
|
||||||
|
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||||
|
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||||
|
class UnknownNodeError extends Error {}
|
||||||
|
|
||||||
|
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||||
|
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||||
|
// renderer must apply the SAME sanitization because the backend stores comment
|
||||||
|
// content verbatim and React does not neutralize javascript: in an href.
|
||||||
|
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||||
|
|
||||||
|
function safeHref(href: unknown): string | undefined {
|
||||||
|
if (typeof href !== "string") return undefined;
|
||||||
|
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||||
|
// (e.g. "java\tscript:").
|
||||||
|
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||||
|
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||||
|
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: PMNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: PMMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||||
|
function renderMarks(
|
||||||
|
text: React.ReactNode,
|
||||||
|
marks: PMMark[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!marks || marks.length === 0) return text;
|
||||||
|
|
||||||
|
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||||
|
const key = `${keyPrefix}-m${i}`;
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return <strong key={key}>{acc}</strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={key}>{acc}</em>;
|
||||||
|
case "strike":
|
||||||
|
return <s key={key}>{acc}</s>;
|
||||||
|
case "underline":
|
||||||
|
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||||
|
// CommentEditor does not disable it, so real comments can carry this
|
||||||
|
// mark. Render it here rather than degrading the whole comment.
|
||||||
|
return <u key={key}>{acc}</u>;
|
||||||
|
case "code":
|
||||||
|
return <code key={key}>{acc}</code>;
|
||||||
|
case "link": {
|
||||||
|
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||||
|
// safe rel semantics the editor produces. Sanitize the href against the
|
||||||
|
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||||
|
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||||
|
// its text, matching how extension-link blanks a bad href.
|
||||||
|
const href = safeHref(mark.attrs?.href);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{acc}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||||
|
switch (node.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{renderMarks(node.text ?? "", node.marks, key)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case "hardBreak":
|
||||||
|
return <br key={key} />;
|
||||||
|
case "mention":
|
||||||
|
return (
|
||||||
|
<span key={key} style={{ display: "inline" }}>
|
||||||
|
<MentionContent attrs={node.attrs as any} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(
|
||||||
|
content: PMNode[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!content) return null;
|
||||||
|
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||||
|
// scoped CSS in comment.module.css (which targets
|
||||||
|
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||||
|
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={classes.commentEditor}>
|
||||||
|
<div className={classes.ProseMirror}>
|
||||||
|
<div className="ProseMirror">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentContentViewProps {
|
||||||
|
content: string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||||
|
// Degrade this single comment to the old editor-based render (safety valve).
|
||||||
|
const fallback = () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
"CommentContentView: unsupported comment content, falling back to editor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <CommentEditor defaultContent={content} editable={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{content}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||||
|
if (typeof doc === "string") {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{doc}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pmDoc = doc as PMNode;
|
||||||
|
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||||
|
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||||
|
}
|
||||||
|
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnknownNodeError) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentContentView;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
useApplySuggestionMutation: () => ({
|
useApplySuggestionMutation: () => ({
|
||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||||
|
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||||
|
const EDITED_DOC = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// so the edit->save/cancel flow can be driven without a live editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: ({ onUpdate, onSave }: any) => (
|
||||||
|
<div data-testid="comment-editor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-update"
|
||||||
|
onClick={() => onUpdate?.(doc)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-save"
|
||||||
|
onClick={() => onSave?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CommentContentView (used for the read-only body) imports the mention view,
|
||||||
|
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||||
|
// renders in isolation without the app entry side-effect.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment, canEdit = true) {
|
function renderItem(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={comment}
|
comment={comment}
|
||||||
pageId="page-1"
|
pageId="page-1"
|
||||||
canComment={true}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
renderItem(suggestion(), true);
|
const { container } = renderItem(suggestion(), true);
|
||||||
// Old text appears both as the selection quote and as the struck diff row.
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
// Apply button is present.
|
// Apply button is present.
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
// No Applied badge yet.
|
// No Applied badge yet.
|
||||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
renderItem(suggestion(), false);
|
const { container } = renderItem(suggestion(), false);
|
||||||
// Diff still renders...
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
expect(container.textContent).toContain("new wording here");
|
||||||
// ...but no Apply button.
|
// ...but no Apply button.
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -157,6 +216,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||||
|
// regardless of who authored the comment; the tests below use it as the lever
|
||||||
|
// since the currentUser atom is unseeded (null) in this harness.
|
||||||
|
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderItem(suggestion(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderItem(suggestion(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||||
|
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||||
|
// currentUser atom is null while the comment is authored by user-1), so the
|
||||||
|
// server would 403 a dismiss — the button must not be shown at all.
|
||||||
|
renderItem(suggestion(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("canShowApply predicate", () => {
|
describe("canShowApply predicate", () => {
|
||||||
const c = (over?: Partial<IComment>): IComment =>
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
({ suggestedText: "x", ...over }) as IComment;
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
|||||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canShowDismiss predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without comment permission", () => {
|
||||||
|
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when not owner and not admin (#338 F5)", () => {
|
||||||
|
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||||
|
const body = (t: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit menu item is gated on the viewer owning the comment
|
||||||
|
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||||
|
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMutateAsync.mockClear();
|
||||||
|
localStorage.setItem(
|
||||||
|
"currentUser",
|
||||||
|
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||||
|
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||||
|
fireEvent.click(await screen.findByText("Edit comment"));
|
||||||
|
// Edit form (mocked editor + actions) is now mounted.
|
||||||
|
await screen.findByTestId("comment-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||||
|
const { rerender } = renderItem(
|
||||||
|
baseComment({ content: body("original body") }),
|
||||||
|
);
|
||||||
|
// Static body first.
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
// mutateAsync is called with the stringified edited doc.
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(EDITED_DOC),
|
||||||
|
});
|
||||||
|
|
||||||
|
// On success the form closes (isEditing -> false); the static body renders
|
||||||
|
// from the comment.content prop again.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the cache invalidation swapping in a new comment object with the
|
||||||
|
// updated content — the static body reflects it.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem
|
||||||
|
comment={baseComment({ content: body("updated body after save") })}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||||
|
expect(screen.queryByText("original body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Type something (editContentRef set), then cancel.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
// Editor unmounts, static body restored, no save happened.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
|
||||||
|
// Cancel path clears editContentRef...
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(body("original body")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — read-only body renders statically", () => {
|
||||||
|
it("renders the comment body as static text without a TipTap editor", () => {
|
||||||
|
renderItem(
|
||||||
|
baseComment({
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello static world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Body text is present...
|
||||||
|
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||||
|
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
useApplySuggestionMutation,
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const applySuggestionMutation = useApplySuggestionMutation();
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
useEffect(() => {
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
setContent(comment.content);
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
}, [comment]);
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||||
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||||
|
// render an action the server will 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
editContentRef.current = null;
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -115,6 +133,19 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDismissSuggestion() {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent races are reconciled to success in the mutation's onError;
|
||||||
|
// anything else surfaces there as a notification.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{isOwnerOrAdmin && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
|||||||
{!comment.parentCommentId && comment.suggestedText && (
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
<Box className={classes.suggestionBlock}>
|
<Box className={classes.suggestionBlock}>
|
||||||
{comment.selection && (
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
{comment.selection}
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
{comment.suggestedText}
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
{comment.suggestionAppliedAt ? (
|
||||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
|||||||
{t("Applied")}
|
{t("Applied")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
canShowApply(comment, canEdit) && (
|
(canShowApply(comment, canEdit) ||
|
||||||
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||||
|
<Group gap="xs" mt={6}>
|
||||||
|
{canShowApply(comment, canEdit) && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
mt={6}
|
|
||||||
onClick={handleApplySuggestion}
|
onClick={handleApplySuggestion}
|
||||||
loading={applySuggestionMutation.isPending}
|
loading={applySuggestionMutation.isPending}
|
||||||
disabled={applySuggestionMutation.isPending}
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("Apply")}
|
{t("Apply")}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||||
|
without changing the page text. Gated on canComment. */}
|
||||||
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentListItem;
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||||
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||||
|
export default React.memo(CommentListItem);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||||
|
// the lazy reply editor's mount transition can be observed without the editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||||
|
// pulled in transitively so importing the module is side-effect free.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChildrenByParent,
|
||||||
|
CommentEditorWithActions,
|
||||||
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
|
expect(buildChildrenByParent([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||||
|
const map = buildChildrenByParent([c("p1", null)]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(map.has("p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||||
|
const p1 = c("p1", null);
|
||||||
|
const r1 = c("r1", "p1");
|
||||||
|
const r2 = c("r2", "r1"); // a reply to a reply
|
||||||
|
const map = buildChildrenByParent([p1, r1, r2]);
|
||||||
|
expect(map.get("p1")).toEqual([r1]);
|
||||||
|
expect(map.get("r1")).toEqual([r2]);
|
||||||
|
// The top-level comment itself is never a key.
|
||||||
|
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still groups a reply whose parent is not present in items", () => {
|
||||||
|
const orphan = c("o1", "missing-parent");
|
||||||
|
const map = buildChildrenByParent([orphan]);
|
||||||
|
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order among sibling replies", () => {
|
||||||
|
const map = buildChildrenByParent([
|
||||||
|
c("a", "p1"),
|
||||||
|
c("b", "p1"),
|
||||||
|
c("d", "p1"),
|
||||||
|
]);
|
||||||
|
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyEditor() {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||||
|
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
expect(screen.getByRole("button")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
// The stub button is replaced by the editor subtree.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor when the stub receives focus", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.focus(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor on Enter keydown of the stub", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||||
|
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||||
|
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function buildChildrenByParent(
|
||||||
|
items: IComment[] | undefined,
|
||||||
|
): Map<string, IComment[]> {
|
||||||
|
const m = new Map<string, IComment[]>();
|
||||||
|
for (const c of items ?? []) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = m.get(c.parentCommentId);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else m.set(c.parentCommentId, [c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||||
|
// mutation object) so the reply/comment callbacks stay stable.
|
||||||
|
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
|
const childrenByParent = useMemo(
|
||||||
|
() => buildChildrenByParent(comments?.items),
|
||||||
|
[comments?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
|
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||||
|
// does not churn renderComments and re-render the whole list.
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
comments,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
isLoading,
|
page?.id,
|
||||||
space?.membership?.role,
|
space?.membership?.role,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||||
|
// unmounted while Open is shown). The Open panel overrides this with its
|
||||||
|
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||||
|
// Open -> Resolved -> Open switch.
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||||
|
active, so a lazily-mounted reply editor's draft (and an
|
||||||
|
in-progress edit) is not discarded on tab switch. */}
|
||||||
|
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
|||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
|||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
placeholder = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||||
|
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||||
|
// mounted so the draft is preserved.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
setContent("");
|
setContent("");
|
||||||
commentEditorRef.current?.clearContent();
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={activate}
|
||||||
|
onFocus={activate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--mantine-color-placeholder)",
|
||||||
|
cursor: "text",
|
||||||
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder || t("Reply...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||||
|
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||||
|
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||||
|
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
applySuggestion,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
suggestedText: "new",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
suggestionAppliedAt: resolvedAt,
|
||||||
|
suggestionAppliedById: "u-1",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||||
|
// silently drop the comment.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||||
|
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||||
|
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||||
|
// and keep the comment in the cache.
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 400 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Comment NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// A real (red) error, never the success message.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ color: "red" }),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||||
|
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||||
|
// error (restores the #315 apply idempotency).
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||||
|
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||||
|
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||||
|
// real error surfacing the server message, and must NOT drop the live thread.
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// The live thread is NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// Surfaces the server's specific message as a red error, never a success.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applySuggestion,
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
dismissSuggestion,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -16,11 +17,13 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
@@ -51,7 +54,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: query.isLoading || query.hasNextPage,
|
// Paint the first page as soon as it arrives instead of blocking until every
|
||||||
|
// page has loaded; the background effect above keeps streaming the rest
|
||||||
|
// (tab counts grow as pages arrive).
|
||||||
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,9 @@ export function useCreateCommentMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
|
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||||
|
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.createComment,
|
||||||
mutationFn: (data) => createComment(data),
|
mutationFn: (data) => createComment(data),
|
||||||
onSuccess: (newComment) => {
|
onSuccess: (newComment) => {
|
||||||
const cache = queryClient.getQueryData(
|
const cache = queryClient.getQueryData(
|
||||||
@@ -177,27 +186,47 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApplySuggestionMutation() {
|
function removeCommentFromCache(
|
||||||
const queryClient = useQueryClient();
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
const { t } = useTranslation();
|
commentId: string,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||||
// so we only mutate the cache once the server confirms.
|
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
// state through (which relocates it to the resolved tab).
|
||||||
onSuccess: (data, variables) => {
|
function applySuggestionOutcomeToCache(
|
||||||
const cache = queryClient.getQueryData(
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
RQ_KEY(variables.pageId),
|
pageId: string,
|
||||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
commentId: string,
|
||||||
|
data: ISuggestionOutcome,
|
||||||
|
) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
if (cache) {
|
if (data.outcome === "deleted") {
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||||
|
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
RQ_KEY(variables.pageId),
|
RQ_KEY(pageId),
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
updateCommentInCache(cache, commentId, (comment) => ({
|
||||||
...comment,
|
...comment,
|
||||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
suggestionAppliedById: data.suggestionAppliedById,
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
// The server auto-resolves the thread on apply — carry that through.
|
|
||||||
resolvedAt: data.resolvedAt,
|
resolvedAt: data.resolvedAt,
|
||||||
resolvedById: data.resolvedById,
|
resolvedById: data.resolvedById,
|
||||||
resolvedBy: data.resolvedBy,
|
resolvedBy: data.resolvedBy,
|
||||||
@@ -205,12 +234,73 @@ export function useApplySuggestionMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useApplySuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
|
// so we only mutate the cache once the server confirms.
|
||||||
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||||
|
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
notifications.show({ message: t("Suggestion applied") });
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any, variables) => {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||||
|
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||||
|
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||||
|
// and report success, the user's intent is already satisfied (restores the
|
||||||
|
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||||
|
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||||
|
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||||
|
// applied" is a false success that also drops a live thread from the cache.
|
||||||
|
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||||
|
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||||
|
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||||
|
// the server's specific message when it carries one.
|
||||||
|
if (status === 400) {
|
||||||
|
const serverMsg = err?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
typeof serverMsg === "string" && serverMsg.length > 0
|
||||||
|
? serverMsg
|
||||||
|
: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 409 => the commented text changed since the suggestion was made. Surface
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
// a specific message (with the current text) rather than a generic error.
|
// a specific message (with the current text) rather than a generic error.
|
||||||
const status = err?.response?.status;
|
|
||||||
const currentText = err?.response?.data?.currentText;
|
const currentText = err?.response?.data?.currentText;
|
||||||
if (status === 409 && typeof currentText === "string") {
|
if (status === 409 && typeof currentText === "string") {
|
||||||
const shortText =
|
const shortText =
|
||||||
@@ -234,6 +324,58 @@ export function useApplySuggestionMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDismissSuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||||
|
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||||
|
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||||
|
// report success, the user's intent (make it disappear) is satisfied.
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||||
|
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||||
|
// a live thread from the cache. Show a real error and keep the comment.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to dismiss suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
export async function applySuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
return req.data.data ?? req.data;
|
return req.data.data ?? req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||||
|
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||||
|
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
|||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||||
|
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||||
|
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||||
|
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||||
|
// take (drop the comment vs. move it to the resolved tab).
|
||||||
|
export type ISuggestionOutcome = IComment & {
|
||||||
|
outcome?: "deleted" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ICommentParams extends QueryParams {
|
export interface ICommentParams extends QueryParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
|||||||
!comment.parentCommentId,
|
!comment.parentCommentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||||
|
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||||
|
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||||
|
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||||
|
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||||
|
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||||
|
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||||
|
export function canShowDismiss(
|
||||||
|
comment: IComment,
|
||||||
|
canComment?: boolean,
|
||||||
|
isOwnerOrAdmin?: boolean,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canComment &&
|
||||||
|
isOwnerOrAdmin &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
|
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
|
||||||
|
export const isLocalSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
|
||||||
|
export const isRemoteSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|
||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
slugId?: string;
|
||||||
|
anchorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||||
|
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||||
|
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||||
|
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||||
|
const { label, entityType, slugId, anchorId } = attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
|
||||||
|
// Shared collaboration providers lifted above the title/body editors so that
|
||||||
|
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
|
||||||
|
// in a dedicated 'title' fragment of the same doc as the body.
|
||||||
|
export interface EditorProvidersContextValue {
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorProvidersContext =
|
||||||
|
createContext<EditorProvidersContextValue | null>(null);
|
||||||
|
|
||||||
|
// Returns the shared providers, or null when rendered outside of a provider.
|
||||||
|
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
|
||||||
|
export function useEditorProviders(): EditorProvidersContextValue | null {
|
||||||
|
return useContext(EditorProvidersContext);
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||||
|
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||||
|
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
@@ -80,16 +82,24 @@ export function FullEditor({
|
|||||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||||
// that enables the chat agent); the server enforces it too (#199).
|
// that enables the chat agent); the server enforces it too (#199).
|
||||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||||
|
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||||
|
// teardown render does not throw "Cannot read properties of null (reading
|
||||||
|
// 'settings')".
|
||||||
|
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user?.settings?.preferences?.editorToolbar ?? false;
|
||||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
);
|
);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||||
|
|
||||||
|
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||||
|
// editors (title lives in the 'title' fragment of the same doc).
|
||||||
|
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||||
|
|
||||||
// Apply the user's saved preference only once on initial load, not on every
|
// Apply the user's saved preference only once on initial load, not on every
|
||||||
// page navigation — so the mode sticks across navigations within a session.
|
// page navigation — so the mode sticks across navigations within a session.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +120,9 @@ export function FullEditor({
|
|||||||
)}
|
)}
|
||||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||||
|
<EditorProvidersContext.Provider
|
||||||
|
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||||
|
>
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
slugId={slugId}
|
slugId={slugId}
|
||||||
@@ -132,6 +145,7 @@ export function FullEditor({
|
|||||||
content={content}
|
content={content}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
/>
|
/>
|
||||||
|
</EditorProvidersContext.Provider>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||||
|
// below the just-inserted audio node. The audio insert already
|
||||||
|
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||||
|
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||||
|
// non-string transcript is a no-op inside the helper (audio only).
|
||||||
|
try {
|
||||||
|
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gitmost] transcript insert failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, pageId: page.id };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
|
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||||
|
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||||
|
* regression would be caught), asserting the resulting document rather than
|
||||||
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
|
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||||
|
* paragraphs; absent/empty/non-string -> no-op.
|
||||||
|
*/
|
||||||
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
|
const makeEditor = () =>
|
||||||
|
new Editor({
|
||||||
|
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||||
|
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||||
|
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||||
|
// text assertions below would fail.
|
||||||
|
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||||
|
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||||
|
// helper appends at the end of the doc, i.e. below existing content.
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||||
|
// A level-2 "Transcript" heading is present.
|
||||||
|
const heading = nodes.find((n) => n.type === "heading");
|
||||||
|
expect(heading?.attrs?.level).toBe(2);
|
||||||
|
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||||
|
|
||||||
|
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||||
|
// the blank line between them is dropped.
|
||||||
|
const texts = nodes
|
||||||
|
.filter((n) => n.type === "paragraph")
|
||||||
|
.map((n) => n.content?.[0]?.text)
|
||||||
|
.filter((t) => typeof t === "string");
|
||||||
|
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const line =
|
||||||
|
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const paras = (editor.getJSON().content ?? []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
) as any[];
|
||||||
|
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||||
|
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||||
|
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||||
|
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||||
|
const content = paras[paras.length - 1].content;
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[0].marks ?? []).toEqual([]);
|
||||||
|
expect(content[0].text).toBe(line);
|
||||||
|
// And no bold/italic/link mark exists anywhere in the document.
|
||||||
|
const html = editor.getHTML();
|
||||||
|
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||||
|
// The angle brackets survived as escaped entities (literal text), not a live
|
||||||
|
// <script>/<b> element.
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
[
|
||||||
|
"- dash",
|
||||||
|
" > quote", // leading indent must be trimmed then neutralized
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"You: normal line",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const texts = (editor.getJSON().content ?? [])
|
||||||
|
.filter((n: any) => n.type === "paragraph")
|
||||||
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
|
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||||
|
// trimmed first); the normal `You:` line is left byte-exact.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
ZWSP + "- dash",
|
||||||
|
ZWSP + "> quote",
|
||||||
|
ZWSP + "# hash",
|
||||||
|
ZWSP + "1. one",
|
||||||
|
ZWSP + "> [!info] note",
|
||||||
|
ZWSP + "```js",
|
||||||
|
ZWSP + "---",
|
||||||
|
ZWSP + "***",
|
||||||
|
ZWSP + "___",
|
||||||
|
"You: normal line",
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||||
|
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const before = JSON.stringify(editor.getJSON());
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||||
|
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
// Document is untouched (audio-only behavior preserved).
|
||||||
|
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
|||||||
base64: string;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||||
|
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||||
|
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||||
|
// audio only.
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitmostCreatePageResult {
|
export interface GitmostCreatePageResult {
|
||||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||||
|
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||||
|
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||||
|
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||||
|
const GITMOST_ZWSP = "";
|
||||||
|
|
||||||
|
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||||
|
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||||
|
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||||
|
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||||
|
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||||
|
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||||
|
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||||
|
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||||
|
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||||
|
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||||
|
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||||
|
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||||
|
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||||
|
// and never matches, so prefixed lines are left byte-exact.
|
||||||
|
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
|
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||||
|
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||||
|
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||||
|
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||||
|
// serializer's missing block-escape). This is best-effort and meant to run
|
||||||
|
// AFTER the audio has already been inserted; the caller must guard against a
|
||||||
|
// throw so a transcript failure never fails the (already successful) recording.
|
||||||
|
// Returns true when a block was inserted, false when there was nothing to
|
||||||
|
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||||
|
// no-op, not an error.
|
||||||
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor: Editor,
|
||||||
|
transcript: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (typeof transcript !== "string") return false;
|
||||||
|
const lines = transcript
|
||||||
|
.split("\n")
|
||||||
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||||
|
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||||
|
// `Speaker N:`) never match and stay byte-exact.
|
||||||
|
.map((line) =>
|
||||||
|
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||||
|
);
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Transcript" }],
|
||||||
|
},
|
||||||
|
...lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: line }],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append at the end of the document. On a freshly-created recording page the
|
||||||
|
// audio node is the last block, so the end position places the transcript
|
||||||
|
// directly below it.
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// jwt-decode is mocked so we can drive the four token states deterministically
|
||||||
|
// (decode success with a chosen exp, or a thrown decode error).
|
||||||
|
const decodeMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("jwt-decode", () => ({
|
||||||
|
jwtDecode: decodeMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { collabTokenNeedsRefresh } from "./collab-token";
|
||||||
|
|
||||||
|
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
decodeMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collabTokenNeedsRefresh", () => {
|
||||||
|
it("returns true when there is no token (fetch a fresh one)", () => {
|
||||||
|
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
|
||||||
|
// jwtDecode must not even be called for a missing token.
|
||||||
|
expect(decodeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the token is malformed (jwtDecode throws)", () => {
|
||||||
|
decodeMock.mockImplementation(() => {
|
||||||
|
throw new Error("invalid token");
|
||||||
|
});
|
||||||
|
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
|
||||||
|
// exp is in the future relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a valid but expired token (refresh + reconnect)", () => {
|
||||||
|
// exp is in the past relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats exp exactly equal to now as expired (>= boundary)", () => {
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
|
||||||
|
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a collab token must be refreshed before reconnecting after an
|
||||||
|
* onAuthenticationFailed event. Pure and side-effect free so the four token
|
||||||
|
* states can be unit-tested directly:
|
||||||
|
* - no token -> true (fetch a fresh one and reconnect)
|
||||||
|
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
|
||||||
|
* - valid, not expired -> false (token is still good; do NOT reconnect)
|
||||||
|
* - valid, expired -> true (refresh + reconnect)
|
||||||
|
*
|
||||||
|
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
|
||||||
|
*/
|
||||||
|
export function collabTokenNeedsRefresh(
|
||||||
|
token: string | undefined,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
try {
|
||||||
|
const payload = jwtDecode<{ exp: number }>(token);
|
||||||
|
return nowMs / 1000 >= payload.exp;
|
||||||
|
} catch {
|
||||||
|
// malformed/undecodable token -> refresh
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor();
|
const titleEditor = makeTitleEditor();
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
|
|||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||||
"Generated Title",
|
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||||
);
|
// its own. Writing here too would double/garble the title, so the hook must
|
||||||
|
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||||
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
expect(localEmitMock).toHaveBeenCalled();
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
|
|||||||
pageId: "pageA",
|
pageId: "pageA",
|
||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
// ...the hook never writes the editor regardless of navigation...
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
// The change is still broadcast to other clients.
|
// ...and the change is still broadcast to other clients.
|
||||||
expect(emitMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
|
||||||
const store = createStore();
|
|
||||||
const titleEditor = makeTitleEditor();
|
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
||||||
store.set(titleEditorAtom as never, titleEditor);
|
|
||||||
|
|
||||||
// Resolve generation under our control so we can mark the live title editor
|
|
||||||
// as focused before the post-generation write runs.
|
|
||||||
let resolveTitle!: (t: string) => void;
|
|
||||||
generatePageTitleMock.mockReturnValue(
|
|
||||||
new Promise<string>((res) => {
|
|
||||||
resolveTitle = res;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
|
||||||
const { result } = setup("pageA", store);
|
|
||||||
|
|
||||||
let pending!: Promise<void>;
|
|
||||||
act(() => {
|
|
||||||
pending = result.current.mutateAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user clicked into the title field while the model ran — overwriting it
|
|
||||||
// now would clobber what they are actively typing.
|
|
||||||
act(() => {
|
|
||||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveTitle("Generated Title");
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The DB write still persists the value...
|
|
||||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
|
||||||
pageId: "pageA",
|
|
||||||
title: "Generated Title",
|
|
||||||
});
|
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
|
||||||
// ...but the visible field is left alone while it is focused.
|
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
|
||||||
// The change is still broadcast to other clients.
|
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
pageEditorAtom,
|
|
||||||
titleEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import {
|
import {
|
||||||
updatePageData,
|
updatePageData,
|
||||||
useUpdateTitlePageMutation,
|
useUpdateTitlePageMutation,
|
||||||
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
|
|||||||
export function useGeneratePageTitle(pageId: string) {
|
export function useGeneratePageTitle(pageId: string) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const titleEditor = useAtomValue(titleEditorAtom);
|
|
||||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
|
||||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
|
||||||
// its closure captures the editors from the render that started it. Keep a live
|
|
||||||
// reference so the post-generation write targets whatever page is on screen
|
|
||||||
// *now*, not the page the generation was started from.
|
|
||||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
|
||||||
editorsRef.current = { pageEditor, titleEditor };
|
|
||||||
|
|
||||||
return useMutation<void, Error, void>({
|
return useMutation<void, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||||
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
|
|||||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||||
updatePageData(page); // refresh the react-query cache
|
updatePageData(page); // refresh the react-query cache
|
||||||
|
|
||||||
// Reflect the new title in the field immediately. The button lives in the
|
// Do NOT write the title into the editor here. The title editor is bound to
|
||||||
// byline, so the title editor is not focused — setContent is safe and stays
|
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||||
//
|
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||||
// Guard against navigation during generation: if the user switched pages
|
// its own as a remote provider update. The old REST-era setContent here
|
||||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||||
// page, so writing here would drop page A's title into page B's visible
|
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
// the captured `pageId`, so it stays correct even if the user navigated
|
||||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
// away during generation.
|
||||||
// pageId` guard — bail the visible write unless that live editor still
|
|
||||||
// belongs to the page this title was generated for. The DB write above is
|
|
||||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
|
||||||
// still propagates page A's change to other clients.
|
|
||||||
const livePageEditor = editorsRef.current.pageEditor;
|
|
||||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
|
||||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
|
||||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
|
||||||
?.pageId;
|
|
||||||
const stillOnPage = livePageId === pageId;
|
|
||||||
if (
|
|
||||||
stillOnPage &&
|
|
||||||
liveTitleEditor &&
|
|
||||||
!liveTitleEditor.isDestroyed &&
|
|
||||||
!liveTitleEditor.isFocused
|
|
||||||
) {
|
|
||||||
liveTitleEditor.commands.setContent(page.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||||
const event: UpdateEvent = {
|
const event: UpdateEvent = {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import {
|
||||||
|
HocuspocusProvider,
|
||||||
|
onStatusParameters,
|
||||||
|
WebSocketStatus,
|
||||||
|
HocuspocusProviderWebsocket,
|
||||||
|
onSyncedParameters,
|
||||||
|
onStatelessParameters,
|
||||||
|
} from "@hocuspocus/provider";
|
||||||
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
|
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
|
||||||
|
export interface PageCollabProviders {
|
||||||
|
ydoc: Y.Doc | null;
|
||||||
|
remote: HocuspocusProvider | null;
|
||||||
|
socket: HocuspocusProviderWebsocket | null;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the full collaboration provider lifecycle for a page so that the title
|
||||||
|
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
|
||||||
|
* is relocated verbatim from page-editor.tsx: it creates the providers once per
|
||||||
|
* pageId, connects/disconnects on idle/visibility, attaches each render,
|
||||||
|
* destroys on unmount, refreshes the collab token on auth failure, and applies
|
||||||
|
* the onStateless 'page.updated' cache update.
|
||||||
|
*/
|
||||||
|
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
||||||
|
const collaborationURL = useCollaborationUrl();
|
||||||
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
);
|
||||||
|
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
||||||
|
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
||||||
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
|
// The provider-creating effect runs only once per pageId, so any token read
|
||||||
|
// inside its handlers would be captured STALE (the old token at first render).
|
||||||
|
// Mirror the latest token into a ref the auth-failure handler can read live.
|
||||||
|
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
collabTokenRef.current = collabQuery?.token;
|
||||||
|
}, [collabQuery?.token]);
|
||||||
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
|
const documentState = useDocumentVisibility();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
|
|
||||||
|
// Providers only created once per pageId
|
||||||
|
const providersRef = useRef<{
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
local: IndexeddbPersistence;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
socket: HocuspocusProviderWebsocket;
|
||||||
|
} | null>(null);
|
||||||
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersRef.current) {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
const socket = new HocuspocusProviderWebsocket({
|
||||||
|
url: collaborationURL,
|
||||||
|
});
|
||||||
|
const onLocalSyncedHandler = () => {
|
||||||
|
setIsLocalSyncedAtom(true);
|
||||||
|
};
|
||||||
|
const onStatusHandler = (event: onStatusParameters) => {
|
||||||
|
setYjsConnectionStatus(event.status);
|
||||||
|
};
|
||||||
|
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||||
|
setIsRemoteSyncedAtom(event.state);
|
||||||
|
};
|
||||||
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(payload);
|
||||||
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
|
const pageData = queryClient.getQueryData<IPage>(
|
||||||
|
pageKeys.detail(slugId),
|
||||||
|
);
|
||||||
|
if (pageData) {
|
||||||
|
queryClient.setQueryData(pageKeys.detail(slugId), {
|
||||||
|
...pageData,
|
||||||
|
updatedAt: message.updatedAt,
|
||||||
|
...(message.lastUpdatedBy && {
|
||||||
|
lastUpdatedBy: message.lastUpdatedBy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unrelated stateless messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAuthenticationFailedHandler = () => {
|
||||||
|
// Read the token from the ref, not the closed-over `collabQuery`: this
|
||||||
|
// handler is created once and would otherwise decode a stale token after
|
||||||
|
// a refetch. A missing/malformed token must NOT crash the handler —
|
||||||
|
// jwtDecode(undefined) throws — so treat any decode failure as "needs
|
||||||
|
// refresh" and proceed to refetch + reconnect instead of getting stuck.
|
||||||
|
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
socket.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
socket.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remote = new HocuspocusProvider({
|
||||||
|
websocketProvider: socket,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token: collabQuery?.token,
|
||||||
|
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||||
|
onStatus: onStatusHandler,
|
||||||
|
onSynced: onSyncedHandler,
|
||||||
|
onStateless: onStatelessHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
local.on("synced", onLocalSyncedHandler);
|
||||||
|
providersRef.current = { ydoc, socket, local, remote };
|
||||||
|
setProvidersReady(true);
|
||||||
|
} else {
|
||||||
|
setProvidersReady(true);
|
||||||
|
}
|
||||||
|
// Only destroy on final unmount
|
||||||
|
return () => {
|
||||||
|
providersRef.current?.socket.destroy();
|
||||||
|
providersRef.current?.remote.destroy();
|
||||||
|
providersRef.current?.local.destroy();
|
||||||
|
providersRef.current = null;
|
||||||
|
// Reset shared sync state on page change/unmount.
|
||||||
|
setIsLocalSyncedAtom(false);
|
||||||
|
setIsRemoteSyncedAtom(false);
|
||||||
|
};
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersReady || !providersRef.current) return;
|
||||||
|
const socket = providersRef.current.socket;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
socket.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
|
// Attach here, to make sure the connection gets properly established
|
||||||
|
providersRef.current?.remote.attach();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ydoc: providersRef.current?.ydoc ?? null,
|
||||||
|
remote: providersRef.current?.remote ?? null,
|
||||||
|
socket: providersRef.current?.socket ?? null,
|
||||||
|
providersReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,16 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import * as Y from "yjs";
|
|
||||||
import {
|
|
||||||
HocuspocusProvider,
|
|
||||||
onStatusParameters,
|
|
||||||
WebSocketStatus,
|
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
onStatelessParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@@ -28,14 +19,16 @@ import {
|
|||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -60,10 +53,8 @@ import {
|
|||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -74,9 +65,7 @@ import {
|
|||||||
GitmostInsertRecordingResult,
|
GitmostInsertRecordingResult,
|
||||||
gitmostInsertRecordingIntoEditor,
|
gitmostInsertRecordingIntoEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||||
@@ -93,6 +82,11 @@ import {
|
|||||||
isBodyEditable,
|
isBodyEditable,
|
||||||
isCollabSynced,
|
isCollabSynced,
|
||||||
} from "@/features/editor/editor-sync-state";
|
} from "@/features/editor/editor-sync-state";
|
||||||
|
import {
|
||||||
|
isVitalsActive,
|
||||||
|
measurePageOpen,
|
||||||
|
reportEditorTx,
|
||||||
|
} from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -108,7 +102,6 @@ export default function PageEditor({
|
|||||||
canComment,
|
canComment,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const collaborationURL = useCollaborationUrl();
|
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
@@ -122,22 +115,10 @@ export default function PageEditor({
|
|||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
|
||||||
// Always holds the latest collab token. The provider effect below runs once
|
|
||||||
// per pageId, so a handler created inside it would otherwise close over a
|
|
||||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
|
||||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
collabTokenRef.current = collabQuery?.token;
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
|
||||||
const documentState = useDocumentVisibility();
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
@@ -147,141 +128,27 @@ export default function PageEditor({
|
|||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
);
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
|
||||||
const providersRef = useRef<{
|
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
socket: HocuspocusProviderWebsocket;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||||
if (!providersRef.current) {
|
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||||
const documentName = `page.${pageId}`;
|
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||||
const ydoc = new Y.Doc();
|
// the context (defensive) — in practice full-editor always provides it.
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const editorProviders = useEditorProviders();
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
const remote = editorProviders?.remote ?? null;
|
||||||
url: collaborationURL,
|
const providersReady = editorProviders?.providersReady ?? false;
|
||||||
});
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
const onLocalSyncedHandler = () => {
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(payload);
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
updatedAt: message.updatedAt,
|
|
||||||
...(message.lastUpdatedBy && {
|
|
||||||
lastUpdatedBy: message.lastUpdatedBy,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore unrelated stateless messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
|
||||||
// may be stale). Guard the decode: a missing or unparseable token must
|
|
||||||
// not throw "Invalid token specified" and should trigger a refresh so
|
|
||||||
// the editor reconnects even when the initial token fetch failed.
|
|
||||||
const token = collabTokenRef.current;
|
|
||||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
|
||||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
|
||||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
|
||||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
|
||||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
|
||||||
} catch {
|
|
||||||
needsRefresh = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!needsRefresh) return;
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
|
||||||
document: ydoc,
|
|
||||||
token: collabQuery?.token,
|
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
|
||||||
onStatus: onStatusHandler,
|
|
||||||
onSynced: onSyncedHandler,
|
|
||||||
onStateless: onStatelessHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
|
||||||
providersRef.current = { socket, local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
|
||||||
providersRef.current?.socket.destroy();
|
|
||||||
providersRef.current?.remote.destroy();
|
|
||||||
providersRef.current?.local.destroy();
|
|
||||||
providersRef.current = null;
|
|
||||||
};
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const socket = providersRef.current.socket;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
socket.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!providersReady || !remote || !currentUser?.user) {
|
||||||
return mainExtensions;
|
return mainExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remote, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [providersReady, remote, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -351,6 +218,40 @@ export default function PageEditor({
|
|||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
// #355 — perf instrumentation. Skip ALL of it when telemetry is
|
||||||
|
// disabled (F1 flag off) or this session isn't sampled: no page-open
|
||||||
|
// measure, and crucially NO dispatch wrapping, so a non-collecting
|
||||||
|
// session pays zero per-transaction cost.
|
||||||
|
if (isVitalsActive()) {
|
||||||
|
// page_open_ms: this is the first editor-content render, so measure
|
||||||
|
// against any page-open mark set on the tree-row/link click.
|
||||||
|
measurePageOpen();
|
||||||
|
|
||||||
|
// editor_tx_ms: time the SYNCHRONOUS part of applying each
|
||||||
|
// transaction (state.apply + updateState) by wrapping the view's
|
||||||
|
// dispatch. Only slow syncs (>8ms) are reported (see reportEditorTx),
|
||||||
|
// so the common path adds just one performance.now() pair. Passive:
|
||||||
|
// the original dispatch still runs unchanged.
|
||||||
|
try {
|
||||||
|
const view = editor.view as unknown as {
|
||||||
|
dispatch: (tr: unknown) => void;
|
||||||
|
};
|
||||||
|
const originalDispatch = view.dispatch.bind(view);
|
||||||
|
view.dispatch = (tr: unknown) => {
|
||||||
|
const started = performance.now();
|
||||||
|
originalDispatch(tr);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
try {
|
||||||
|
reportEditorTx(elapsed, editor.state.doc.content.size);
|
||||||
|
} catch {
|
||||||
|
// never let telemetry break editing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// if the view shape changes, skip editor_tx instrumentation
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -612,7 +513,7 @@ export default function PageEditor({
|
|||||||
{editor &&
|
{editor &&
|
||||||
!editorIsEditable &&
|
!editorIsEditable &&
|
||||||
(editable || canComment) &&
|
(editable || canComment) &&
|
||||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||||
{showCommentPopup && (
|
{showCommentPopup && (
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
<CommentDialog editor={editor} pageId={pageId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for the IndexedDB / Hocuspocus document name of a
|
||||||
|
* page's collaborative Yjs doc.
|
||||||
|
*
|
||||||
|
* The `page.<id>` convention is shared knowledge across three call sites: the
|
||||||
|
* live editor providers (`use-page-collab-providers`), the offline warm path
|
||||||
|
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
|
||||||
|
* the databases to delete by this prefix). Centralizing it here stops those
|
||||||
|
* sites from silently drifting apart.
|
||||||
|
*/
|
||||||
|
export const PAGE_YDOC_NAME_PREFIX = "page.";
|
||||||
|
|
||||||
|
export const pageYdocName = (pageId: string): string =>
|
||||||
|
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
|
||||||
|
// transactions without constructing a real ProseMirror/Yjs transaction.
|
||||||
|
const isChangeOriginMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@tiptap/extension-collaboration", () => ({
|
||||||
|
isChangeOrigin: isChangeOriginMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { shouldPropagateTitleChange } from "./title-collab";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
isChangeOriginMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldPropagateTitleChange", () => {
|
||||||
|
it("propagates a genuine local edit (isChangeOrigin false)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(false);
|
||||||
|
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
|
||||||
|
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(true);
|
||||||
|
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates when there is no transaction (treated as local)", () => {
|
||||||
|
expect(shouldPropagateTitleChange(undefined)).toBe(true);
|
||||||
|
// isChangeOrigin must not be called for a missing transaction.
|
||||||
|
expect(isChangeOriginMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
|
||||||
|
*
|
||||||
|
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
|
||||||
|
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
|
||||||
|
* re-broadcast back, which would create a feedback loop. A missing transaction
|
||||||
|
* is treated as a local edit (propagate).
|
||||||
|
*
|
||||||
|
* Extracted as a pure helper so the skip decision is unit-testable without
|
||||||
|
* mounting the full collaborative editor.
|
||||||
|
*/
|
||||||
|
export function shouldPropagateTitleChange(transaction: unknown): boolean {
|
||||||
|
return !(
|
||||||
|
transaction &&
|
||||||
|
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
|
||||||
|
// !!ydoc) by controlling what the editor-providers context returns.
|
||||||
|
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
|
||||||
|
ydoc: null,
|
||||||
|
providersReady: false,
|
||||||
|
};
|
||||||
|
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
|
||||||
|
useEditorProviders: () => editorProvidersValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the tiptap React bindings so the test does not mount a real editor:
|
||||||
|
// useEditor returns a minimal stub and EditorContent renders a marker.
|
||||||
|
vi.mock("@tiptap/react", () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
isInitialized: true,
|
||||||
|
commands: { focus: vi.fn() },
|
||||||
|
setEditable: vi.fn(),
|
||||||
|
getText: () => "",
|
||||||
|
}),
|
||||||
|
EditorContent: () => <div data-testid="collab-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||||
|
useQueryEmit: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query transitively imports @/main.tsx; mock it to a pure stub.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
updatePageData: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TitleEditor } from "./title-editor";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
pageId: "p1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
title: "My Page Title",
|
||||||
|
spaceSlug: "space",
|
||||||
|
editable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TitleEditor fallback vs collaborative switch", () => {
|
||||||
|
it("renders a static <h1> with the title before the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(heading.textContent).toBe("My Page Title");
|
||||||
|
// The collaborative editor must NOT mount until the doc is ready.
|
||||||
|
expect(screen.queryByTestId("collab-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the collaborative editor once the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = {}; // truthy shared doc
|
||||||
|
editorProvidersValue.providersReady = true;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("collab-editor")).toBeDefined();
|
||||||
|
// The static fallback <h1> is gone — Yjs is the single source of truth and
|
||||||
|
// the prop is never seeded into the collaborative editor.
|
||||||
|
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { Document } from "@tiptap/extension-document";
|
import { Document } from "@tiptap/extension-document";
|
||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
|
||||||
useUpdateTitlePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -29,6 +26,9 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { platformModifierKey } from "@/lib";
|
import { platformModifierKey } from "@/lib";
|
||||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
||||||
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -46,16 +46,26 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
|
||||||
useUpdateTitlePageMutation();
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
||||||
|
// 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: [
|
extensions: [
|
||||||
Document.extend({
|
Document.extend({
|
||||||
content: "heading",
|
content: "heading",
|
||||||
@@ -68,23 +78,28 @@ export function TitleEditor({
|
|||||||
placeholder: t("Untitled"),
|
placeholder: t("Untitled"),
|
||||||
showOnlyWhenEditable: false,
|
showOnlyWhenEditable: false,
|
||||||
}),
|
}),
|
||||||
History.configure({
|
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
||||||
depth: 20,
|
// 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,
|
EmojiCommand,
|
||||||
],
|
],
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTitleEditor(editor);
|
setTitleEditor(editor);
|
||||||
setActivePageId(pageId);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor, transaction }) {
|
||||||
debounceUpdate();
|
// Drive URL + tree propagation only on genuine local edits; skip
|
||||||
|
// remote/collab-origin Yjs updates to avoid feedback loops.
|
||||||
|
if (!shouldPropagateTitleChange(transaction)) return;
|
||||||
|
debouncedPropagateTitle(editor.getText());
|
||||||
},
|
},
|
||||||
editable: editable,
|
editable: editable,
|
||||||
content: title,
|
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: false,
|
shouldRerenderOnTransaction: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -104,7 +119,9 @@ export function TitleEditor({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
[pageId, ydoc],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchorId = window.location.hash
|
const anchorId = window.location.hash
|
||||||
@@ -114,69 +131,48 @@ export function TitleEditor({
|
|||||||
navigate(pageSlug, { replace: true });
|
navigate(pageSlug, { replace: true });
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
const saveTitle = useCallback(() => {
|
// On a local title change: update the URL slug and propagate the change to
|
||||||
if (!titleEditor || activePageId !== pageId) return;
|
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
||||||
|
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
const page =
|
||||||
titleEditor.getText() === title ||
|
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||||
(titleEditor.getText() === "" && title === null)
|
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
) {
|
if (!page) return;
|
||||||
return;
|
|
||||||
}
|
const updatedPage: IPage = { ...page, title: titleText };
|
||||||
|
|
||||||
updateTitlePageMutationAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
title: titleEditor.getText(),
|
|
||||||
}).then((page) => {
|
|
||||||
const event: UpdateEvent = {
|
const event: UpdateEvent = {
|
||||||
operation: "updateOne",
|
operation: "updateOne",
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: {
|
payload: {
|
||||||
title: page.title,
|
title: titleText,
|
||||||
slugId: page.slugId,
|
slugId: page.slugId,
|
||||||
parentPageId: page.parentPageId,
|
parentPageId: page.parentPageId,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
updatePageData(updatedPage);
|
||||||
|
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||||
updatePageData(page);
|
// refresh is handled server-side: the collab process extracts the renamed
|
||||||
|
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
||||||
|
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
||||||
localEmitter.emit("message", event);
|
localEmitter.emit("message", event);
|
||||||
emit(event);
|
}, 500);
|
||||||
});
|
|
||||||
}, [pageId, title, titleEditor]);
|
|
||||||
|
|
||||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useTitleAutofocus(titleEditor, pageId);
|
useTitleAutofocus(titleEditor, pageId);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// force-save title on navigation
|
|
||||||
saveTitle();
|
|
||||||
};
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!titleEditor) return;
|
if (!titleEditor) return;
|
||||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||||
@@ -243,6 +239,7 @@ export function TitleEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
|
{titleReady ? (
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={titleEditor}
|
editor={titleEditor}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -253,6 +250,11 @@ export function TitleEditor({
|
|||||||
handleTitleKeyDown(event);
|
handleTitleKeyDown(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Static, non-editable fallback so the title is visible before Yjs
|
||||||
|
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
||||||
|
<h1>{title}</h1>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
|||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
|
|
||||||
const createNote = async (space: ISpace) => {
|
const createNote = async (space: ISpace) => {
|
||||||
try {
|
|
||||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||||
// signature.
|
// signature.
|
||||||
const createdPage = await createPageMutation.mutateAsync({
|
const variables = {
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
...(temporary ? { temporary: true } : {}),
|
...(temporary ? { temporary: true } : {}),
|
||||||
} as any);
|
} as any;
|
||||||
|
|
||||||
|
if (!onlineManager.isOnline()) {
|
||||||
|
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||||
|
// until we are back online, so awaiting it here would spin the button
|
||||||
|
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||||
|
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||||
|
createPageMutation.mutate(variables);
|
||||||
|
notifications.show({
|
||||||
|
color: "blue",
|
||||||
|
message: t("You're offline. This note will be created once you reconnect."),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||||
} catch {
|
} catch {
|
||||||
// useCreatePageMutation already surfaces a red notification on error.
|
// useCreatePageMutation already surfaces a red notification on error.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = createPageMutation.isPending;
|
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||||
|
// being paused — otherwise the button would spin forever after an offline
|
||||||
|
// create. The offline path above gives its own "saved offline" feedback.
|
||||||
|
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||||
|
|
||||||
// Exactly one writable space → create directly, no picker needed.
|
// Exactly one writable space → create directly, no picker needed.
|
||||||
if (writableSpaces.length === 1) {
|
if (writableSpaces.length === 1) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { del } from "idb-keyval";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
OFFLINE_CACHE_KEY,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort purge of all of the current user's offline data from the browser.
|
||||||
|
*
|
||||||
|
* On logout the previous user's private data would otherwise linger locally and
|
||||||
|
* be readable by the next person on the device. This clears the three offline
|
||||||
|
* stores the app writes:
|
||||||
|
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
|
||||||
|
* `OFFLINE_CACHE_KEY`),
|
||||||
|
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
|
||||||
|
* y-indexeddb in make-offline.ts), and
|
||||||
|
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
|
||||||
|
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
|
||||||
|
* rule was removed — offline reads come from the persisted RQ cache), so
|
||||||
|
* this is now a defensive cleanup for caches left by older app versions.
|
||||||
|
*
|
||||||
|
* Fully best-effort: every step is isolated so a single failure neither blocks
|
||||||
|
* the remaining steps nor throws to the caller (logout must never be blocked on
|
||||||
|
* cache cleanup). Callers may ignore the resolved value.
|
||||||
|
*
|
||||||
|
* Limitations:
|
||||||
|
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
|
||||||
|
* is unavailable in some browsers (notably Firefox). There we skip silently;
|
||||||
|
* those `page.<id>` databases are then left in place.
|
||||||
|
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
|
||||||
|
* service-worker-capable browsers).
|
||||||
|
*/
|
||||||
|
export async function clearOfflineCache(): Promise<void> {
|
||||||
|
// Freeze the throttled persister BEFORE touching the cache so the
|
||||||
|
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||||
|
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||||
|
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||||
|
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1a. Drop the in-memory query cache immediately.
|
||||||
|
try {
|
||||||
|
queryClient.clear();
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore in-memory cache reset failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||||
|
try {
|
||||||
|
await del(OFFLINE_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore persisted-cache deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||||
|
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||||
|
// it is missing we cannot enumerate the page databases, so we skip silently.
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof indexedDB !== "undefined" &&
|
||||||
|
typeof indexedDB.databases === "function"
|
||||||
|
) {
|
||||||
|
const dbs = await indexedDB.databases();
|
||||||
|
for (const db of dbs) {
|
||||||
|
const name = db?.name;
|
||||||
|
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||||
|
// not race the page teardown, but never reject on it.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const request = indexedDB.deleteDatabase(name);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => resolve();
|
||||||
|
request.onblocked = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort per database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore enumeration/deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear any legacy service worker API cache. Current builds no longer
|
||||||
|
// create it, but an older client may have left an "api-get-cache" entry
|
||||||
|
// (Workbox may prefix the name), so match by substring rather than exact name.
|
||||||
|
try {
|
||||||
|
if ("caches" in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.includes("api-get-cache"))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore Cache Storage failures
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Re-enable persistence for the next session (sign-in continues running in
|
||||||
|
// the same tab; logout reloads via window.location.replace, so this is a
|
||||||
|
// harmless no-op there).
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||||
|
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||||
|
// inspected by the assertions below.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
ydocDestroy: vi.fn(),
|
||||||
|
idbDestroy: vi.fn(),
|
||||||
|
providerOn: vi.fn(),
|
||||||
|
providerOff: vi.fn(),
|
||||||
|
providerDestroy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The module under test imports the app entry at load time — it must be mocked.
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
getPageById: vi.fn(),
|
||||||
|
getPageBreadcrumbs: vi.fn(),
|
||||||
|
getSidebarPages: vi.fn(),
|
||||||
|
getAllSidebarPages: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||||
|
getSpaceById: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||||
|
// value when the module under test calls `new Y.Doc()` etc.
|
||||||
|
vi.mock("yjs", () => ({
|
||||||
|
Doc: vi.fn(function () {
|
||||||
|
return { destroy: h.ydocDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("y-indexeddb", () => ({
|
||||||
|
IndexeddbPersistence: vi.fn(function () {
|
||||||
|
return { destroy: h.idbDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@hocuspocus/provider", () => ({
|
||||||
|
HocuspocusProvider: vi.fn(function () {
|
||||||
|
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
warmInfiniteAll,
|
||||||
|
warmPageYdoc,
|
||||||
|
makePageAvailableOffline,
|
||||||
|
} from "./make-offline";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
|
||||||
|
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||||
|
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||||
|
// objects and break the provider/idb/yjs spies).
|
||||||
|
setQueryData.mockClear();
|
||||||
|
prefetchQuery.mockReset();
|
||||||
|
prefetchQuery.mockResolvedValue(undefined);
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
h.ydocDestroy.mockClear();
|
||||||
|
h.idbDestroy.mockClear();
|
||||||
|
h.providerOn.mockClear();
|
||||||
|
h.providerOff.mockClear();
|
||||||
|
h.providerDestroy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmInfiniteAll", () => {
|
||||||
|
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||||
|
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||||
|
pages: [res],
|
||||||
|
pageParams: [undefined],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the cursor chain across multiple pages", async () => {
|
||||||
|
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||||
|
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||||
|
const r2 = { items: [], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(r0)
|
||||||
|
.mockResolvedValueOnce(r1)
|
||||||
|
.mockResolvedValueOnce(r2);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
undefined,
|
||||||
|
"c1",
|
||||||
|
"c2",
|
||||||
|
]);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||||
|
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
|
||||||
|
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||||
|
// (partial) cache is still written, but the result is reported as false.
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toHaveLength(2);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on success", async () => {
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports errors (returns false) and never writes the cache on failure", async () => {
|
||||||
|
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(setQueryData).not.toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makePageAvailableOffline", () => {
|
||||||
|
const okPage = {
|
||||||
|
id: "uuid-1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
space: { slug: "space-slug" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns ok:true with no failures when every step succeeds", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false with the failed step label when a warm step fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Comments warm fails -> labeled "comments".
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("comments");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||||
|
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||||
|
const warmedSidebarIds = () =>
|
||||||
|
prefetchQuery.mock.calls
|
||||||
|
.map((c) => c[0])
|
||||||
|
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||||
|
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||||
|
|
||||||
|
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||||
|
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||||
|
// malformed entry with no id (also skipped).
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "uuid-1" }, // === pageId -> guard
|
||||||
|
{ id: "anc-2" },
|
||||||
|
{}, // no id -> skipped
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = warmedSidebarIds();
|
||||||
|
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||||
|
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||||
|
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||||
|
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||||
|
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "anc-2" },
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||||
|
// failures); the currentUser/space prefetches still resolve.
|
||||||
|
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||||
|
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three node warms failed but the contract collapses them to one "tree".
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["tree"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||||
|
// failure is the breadcrumbs lookup.
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmPageYdoc", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Grab the synced handler the provider registered.
|
||||||
|
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
const handler = h.providerOn.mock.calls.find(
|
||||||
|
(c) => c[0] === "synced",
|
||||||
|
)![1] as () => void;
|
||||||
|
|
||||||
|
handler();
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Listener detached and everything cleaned up.
|
||||||
|
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||||
|
handler();
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves and cleans up after the timeout when synced never fires", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||||
|
await vi.advanceTimersByTimeAsync(8000);
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import {
|
||||||
|
pageKeys,
|
||||||
|
sidebarPagesQueryOptions,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
|
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||||
|
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
|
||||||
|
*
|
||||||
|
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
|
||||||
|
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
|
||||||
|
* spinning forever offline, and silently truncates large lists. This walks the
|
||||||
|
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||||
|
*
|
||||||
|
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
|
||||||
|
* but it is reported — the error is logged with context and `false` is returned
|
||||||
|
* so the caller can record the failed step instead of silently succeeding.
|
||||||
|
*
|
||||||
|
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
|
||||||
|
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||||
|
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||||
|
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||||
|
* that case is logged and returns false too — the caller records it as a failed
|
||||||
|
* warm instead of a silent truncated success. The (partial) cache is still
|
||||||
|
* written so what we did fetch is usable.
|
||||||
|
*
|
||||||
|
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||||
|
*/
|
||||||
|
export async function warmInfiniteAll<T>(
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||||
|
maxPages = 50,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pages: IPagination<T>[] = [];
|
||||||
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
let cursor: string | undefined = undefined;
|
||||||
|
let exhausted = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxPages; i++) {
|
||||||
|
const res = await fetchPage(cursor);
|
||||||
|
pages.push(res);
|
||||||
|
pageParams.push(cursor);
|
||||||
|
cursor = res?.meta?.nextCursor ?? undefined;
|
||||||
|
if (!cursor) {
|
||||||
|
exhausted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||||
|
|
||||||
|
if (!exhausted) {
|
||||||
|
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||||
|
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||||
|
// as a failed warm rather than a silent truncated success.
|
||||||
|
console.error("warmInfiniteAll truncated at maxPages", {
|
||||||
|
queryKey,
|
||||||
|
maxPages,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakePageAvailableOfflineParams {
|
||||||
|
pageId: string;
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
|
||||||
|
* step succeeded; `failed` lists the labels of the steps that failed (a subset
|
||||||
|
* of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments").
|
||||||
|
*/
|
||||||
|
export interface MakePageAvailableOfflineResult {
|
||||||
|
ok: boolean;
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort prefetch of a page's read queries so they get persisted to
|
||||||
|
* IndexedDB and become readable offline.
|
||||||
|
*
|
||||||
|
* Each step is isolated and this function does NOT throw — a partial warm is
|
||||||
|
* still useful. Instead of silently succeeding, every failed step is logged
|
||||||
|
* with a label and recorded in the returned result: `{ ok, failed }` where
|
||||||
|
* `ok` is true only if no step failed and `failed` lists the failed step
|
||||||
|
* labels. Only meaningful while online (the underlying requests must succeed).
|
||||||
|
*/
|
||||||
|
export async function makePageAvailableOffline({
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
|
||||||
|
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
|
||||||
|
// no data, and the offline POST /api/users/me fails as a network error, so
|
||||||
|
// without a persisted user a pinned page still white-screens after relaunch
|
||||||
|
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
|
||||||
|
// cache actually has an entry to restore.
|
||||||
|
try {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: userKeys.currentUser(),
|
||||||
|
queryFn: () => getMyInfo(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: currentUser step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("currentUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
|
||||||
|
// like usePageQuery's onData effect. Every page consumer reads
|
||||||
|
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
|
||||||
|
// so warming only the uuid key would leave the offline page blank.
|
||||||
|
let page: IPage | undefined;
|
||||||
|
try {
|
||||||
|
page = await getPageById({ pageId });
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.id), page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: page step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the space — page.tsx renders nothing until the space query resolves
|
||||||
|
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
|
||||||
|
// the space is actually persisted before the caller fires its toast. Shares
|
||||||
|
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
|
||||||
|
try {
|
||||||
|
const spaceSlug = page?.space?.slug;
|
||||||
|
if (spaceSlug) {
|
||||||
|
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: space step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
|
||||||
|
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
|
||||||
|
// Fully paginated so large root levels are not truncated at 100.
|
||||||
|
if (spaceId) {
|
||||||
|
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
|
||||||
|
getSidebarPages({ spaceId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!ok) failed.push("tree");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the children of the page and of every ancestor so the path to this
|
||||||
|
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
|
||||||
|
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
|
||||||
|
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
|
||||||
|
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
|
||||||
|
// would never be read by the offline tree.
|
||||||
|
const warmSidebarChildren = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
|
||||||
|
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
|
||||||
|
const params = { pageId: id, spaceId };
|
||||||
|
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: tree node step failed", {
|
||||||
|
pageId: id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The page's own children.
|
||||||
|
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
|
||||||
|
|
||||||
|
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
|
||||||
|
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
|
||||||
|
// (the UI derives the path from the tree).
|
||||||
|
try {
|
||||||
|
const ancestors = (await getPageBreadcrumbs(pageId)) as
|
||||||
|
| Array<{ id?: string }>
|
||||||
|
| undefined;
|
||||||
|
for (const ancestor of ancestors ?? []) {
|
||||||
|
const ancestorId = ancestor?.id;
|
||||||
|
if (!ancestorId || ancestorId === pageId) continue;
|
||||||
|
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: breadcrumbs step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("breadcrumbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
|
||||||
|
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
|
||||||
|
// only the first page leaves the offline comments panel spinning forever on
|
||||||
|
// pages with >100 comments. Fully paginate so the last cached page has no
|
||||||
|
// nextCursor and the panel settles offline.
|
||||||
|
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
|
||||||
|
getPageComments({ pageId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!commentsOk) failed.push("comments");
|
||||||
|
|
||||||
|
// Dedupe — the tree label can be recorded once per failed node/ancestor.
|
||||||
|
const uniqueFailed = [...new Set(failed)];
|
||||||
|
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
|
||||||
|
* can open offline.
|
||||||
|
*
|
||||||
|
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
|
||||||
|
* pull the server state into IndexedDB, then tears both down once synced (or
|
||||||
|
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
|
||||||
|
*
|
||||||
|
* Only meaningful when online at warm time; offline it is a no-op that resolves.
|
||||||
|
*/
|
||||||
|
export async function warmPageYdoc(
|
||||||
|
pageId: string,
|
||||||
|
collabUrl: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let ydoc: Y.Doc | null = null;
|
||||||
|
let local: IndexeddbPersistence | null = null;
|
||||||
|
let remote: HocuspocusProvider | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
ydoc = new Y.Doc();
|
||||||
|
local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
remote = new HocuspocusProvider({
|
||||||
|
url: collabUrl,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = remote;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
// Clear the pending timeout and detach the listener so neither leaks
|
||||||
|
// after we resolve.
|
||||||
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
|
try {
|
||||||
|
provider.off("synced", finish);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve once the server state has synced into the local doc...
|
||||||
|
provider.on("synced", finish);
|
||||||
|
// ...or give up after a short timeout so we never hang.
|
||||||
|
timeoutId = setTimeout(finish, 8000);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
remote?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
local?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ydoc?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the authenticated app shell cannot hydrate because the current
|
||||||
|
* user is unavailable AND there is no cached user to fall back on (e.g. an
|
||||||
|
* offline cold boot of a page that was never warmed for offline).
|
||||||
|
*
|
||||||
|
* Previously UserProvider returned a bare `<></>` in this situation, which
|
||||||
|
* white-screened the whole app on any offline reload (#237/#238). Rendering an
|
||||||
|
* explicit "you're offline" state with a retry instead gives the user a clear,
|
||||||
|
* non-blank fallback and a way to recover once the network returns.
|
||||||
|
*/
|
||||||
|
export function OfflineFallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("You're offline")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
<Container size="sm" py={80}>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
{t("You're offline")}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="lg" ta="center">
|
||||||
|
{t(
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||||
|
{t("Retry")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
|
||||||
|
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerOfflineMutationDefaults", () => {
|
||||||
|
it("registers a default mutationFn for every offline mutation key", () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(qc);
|
||||||
|
|
||||||
|
for (const key of Object.values(offlineMutationKeys)) {
|
||||||
|
const defaults = qc.getMutationDefaults(key);
|
||||||
|
expect(typeof defaults?.mutationFn).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The headline durability guarantee: a paused mutation dehydrated into
|
||||||
|
// IndexedDB while offline must, after a reload, have a mutationFn so
|
||||||
|
// resumePausedMutations() actually replays the write on reconnect.
|
||||||
|
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
|
||||||
|
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
// Force the dehydrate-worthy paused state (offline = isPaused) with the
|
||||||
|
// payload the user submitted before losing connectivity.
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
expect(dehydrated.mutations).toHaveLength(1);
|
||||||
|
// The dehydrated mutation carries NO mutationFn (functions aren't
|
||||||
|
// serializable) — only its key + variables survive the reload.
|
||||||
|
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
|
||||||
|
|
||||||
|
// 2) Simulate the fresh page after reload: register defaults, then hydrate
|
||||||
|
// the persisted paused mutation back in.
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
|
||||||
|
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// 3) Reconnect: replay the paused mutations.
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
// The default mutationFn ran with the persisted variables — the write is
|
||||||
|
// NOT silently dropped.
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
expect(h.movePage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.movePage).toHaveBeenCalledWith({
|
||||||
|
pageId: "p1",
|
||||||
|
parentPageId: null,
|
||||||
|
position: "a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { createPage, movePage } from "@/features/page/services/page-service";
|
||||||
|
import { createComment } from "@/features/comment/services/comment-service";
|
||||||
|
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
|
||||||
|
import type {
|
||||||
|
IMovePage,
|
||||||
|
IPage,
|
||||||
|
IPageInput,
|
||||||
|
} from "@/features/page/types/page.types";
|
||||||
|
import type { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable mutation keys for the offline-relevant structural mutations.
|
||||||
|
*
|
||||||
|
* When the browser goes offline, React Query PAUSES these mutations and the
|
||||||
|
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
|
||||||
|
* reload-while-offline the mutation is restored, but a restored mutation has NO
|
||||||
|
* observer (no component is mounted) — so its replay relies entirely on the
|
||||||
|
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
|
||||||
|
* Without that, `resumePausedMutations()` finds a paused mutation with no
|
||||||
|
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
|
||||||
|
* (#237/#238). Each offline mutation hook tags itself with the matching key so
|
||||||
|
* the rehydrated paused mutation can find its default `mutationFn` and replay.
|
||||||
|
*/
|
||||||
|
export const offlineMutationKeys = {
|
||||||
|
createPage: ["create-page"] as const,
|
||||||
|
movePage: ["move-page"] as const,
|
||||||
|
createComment: ["create-comment"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default `mutationFn`s (and the minimal success side effects safe to
|
||||||
|
* run without a mounted component) for the offline-relevant mutation keys, so a
|
||||||
|
* paused mutation restored from IndexedDB after an offline reload is replayable
|
||||||
|
* by `resumePausedMutations()` on reconnect.
|
||||||
|
*
|
||||||
|
* Called once when the QueryClient is created (see main.tsx). The hooks still
|
||||||
|
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
|
||||||
|
* these defaults only take over for a rehydrated paused mutation that lost its
|
||||||
|
* observer across the reload.
|
||||||
|
*/
|
||||||
|
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
|
||||||
|
mutationFn: (data: Partial<IPageInput>) => createPage(data),
|
||||||
|
// Re-converge the sidebar tree / recent-changes from the authoritative
|
||||||
|
// create response. Pure cache writes — safe with no component mounted.
|
||||||
|
onSuccess: (data: IPage) => {
|
||||||
|
invalidateOnCreatePage(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
|
||||||
|
// Replay the server-side move. The tree re-converges from the next online
|
||||||
|
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
|
||||||
|
// needed here (the optimistic tree state was local-only anyway).
|
||||||
|
mutationFn: (data: IMovePage) => movePage(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
|
||||||
|
// Replay the server-side comment create. The comments list refetches on the
|
||||||
|
// online reload, so the replay only needs to persist the write.
|
||||||
|
mutationFn: (data: Partial<IComment>) => createComment(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
persistQueryClientRestore,
|
||||||
|
persistQueryClientSave,
|
||||||
|
} from "@tanstack/react-query-persist-client";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||||
|
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.mock("idb-keyval", () => ({
|
||||||
|
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||||
|
set: vi.fn((k: string, v: string) => {
|
||||||
|
store.set(k, v);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
del: vi.fn((k: string) => {
|
||||||
|
store.delete(k);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryPersister } from "./query-persister";
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
const BUSTER = "test-buster";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// onlineManager is a global singleton; leave it in the default online state.
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offline paused-mutation resume across a reload", () => {
|
||||||
|
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||||
|
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||||
|
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||||
|
// boot-time fixes the app wiring relies on:
|
||||||
|
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||||
|
// is a true offline->online transition that auto-resumes, and
|
||||||
|
// (b) resumePausedMutations() called after the persister restores (what the
|
||||||
|
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||||
|
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||||
|
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||||
|
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||||
|
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||||
|
|
||||||
|
const client1 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client1);
|
||||||
|
const observer = client1.getMutationCache().build(client1, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
await persistQueryClientSave({
|
||||||
|
// Cast: persist-client-core and react-query may resolve to different
|
||||||
|
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||||
|
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||||
|
queryClient: client1 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||||
|
});
|
||||||
|
// The paused mutation is now in the persisted store.
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
|
||||||
|
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||||
|
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||||
|
const client2 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client2);
|
||||||
|
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||||
|
|
||||||
|
await persistQueryClientRestore({
|
||||||
|
queryClient: client2 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
});
|
||||||
|
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||||
|
// the mutation must stay paused and NOT fire yet.
|
||||||
|
await client2.resumePausedMutations();
|
||||||
|
expect(h.createPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||||
|
// mutation and its registered default mutationFn finally fires. ---
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
|
||||||
|
client2.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||||
|
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||||
|
import { vi } from "vitest";
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||||
|
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||||
|
* factory's root is renamed without updating the persist registry — or vice
|
||||||
|
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||||
|
* live queries). This turns that silent regression into a red build.
|
||||||
|
*
|
||||||
|
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||||
|
*/
|
||||||
|
function rootOf(key: readonly unknown[]): string {
|
||||||
|
return String(key[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTORY_ROOTS = new Set<string>([
|
||||||
|
rootOf(pageKeys.detail("x")),
|
||||||
|
rootOf(pageKeys.sidebar({})),
|
||||||
|
rootOf(pageKeys.rootSidebar("x")),
|
||||||
|
rootOf(pageKeys.breadcrumbs("x")),
|
||||||
|
rootOf(pageKeys.recentChanges("x")),
|
||||||
|
rootOf(spaceKeys.detail("x")),
|
||||||
|
rootOf(spaceKeys.list()),
|
||||||
|
rootOf(RQ_KEY("x")),
|
||||||
|
rootOf(userKeys.currentUser()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||||
|
it("maps every persisted root to an exported factory root", () => {
|
||||||
|
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||||
|
(root) => !FACTORY_ROOTS.has(root),
|
||||||
|
);
|
||||||
|
expect(unbacked).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(() => Promise.resolve(undefined)),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
del: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
vi.mock("idb-keyval", () => h);
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
OFFLINE_PERSIST_ROOTS,
|
||||||
|
queryPersister,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
|
||||||
|
// Small helper to build the structural query shape the predicate reads.
|
||||||
|
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||||
|
({ state: { status }, queryKey }) as any;
|
||||||
|
|
||||||
|
describe("shouldDehydrateOfflineQuery", () => {
|
||||||
|
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(
|
||||||
|
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||||
|
).toBe(true);
|
||||||
|
// currentUser is persisted so the auth-gated Layout can hydrate offline.
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the status is not success (status gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an empty/undefined queryKey", () => {
|
||||||
|
// String(undefined) is not a member of the allowlist.
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||||
|
it("contains exactly the expected 9 navigation/read roots", () => {
|
||||||
|
const expected = [
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
];
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
|
||||||
|
for (const root of expected) {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT contain volatile/auth keys", () => {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||||
|
const dummyClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: "",
|
||||||
|
clientState: { mutations: [], queries: [] },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
h.set.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT write to storage while frozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes writing to storage once unfrozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { get, set, del } from "idb-keyval";
|
||||||
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
|
|
||||||
|
// Structural subset of a TanStack Query we read when deciding what to persist.
|
||||||
|
// We avoid importing the branded `Query` class because the persist-client and
|
||||||
|
// react-query may resolve to different `@tanstack/query-core` copies, whose
|
||||||
|
// `Query` types are nominally incompatible (private brand). This structural
|
||||||
|
// shape stays assignable to whichever copy the persister expects.
|
||||||
|
type DehydratableQuery = {
|
||||||
|
state: { status: string };
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// idb-keyval key under which TanStack Query persists its dehydrated cache.
|
||||||
|
// Exported so the logout cache-clear logic deletes the exact same key (no
|
||||||
|
// magic-string drift between persist and purge).
|
||||||
|
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
|
||||||
|
|
||||||
|
// IndexedDB-backed storage adapter for TanStack Query's async persister.
|
||||||
|
const idbStorage = {
|
||||||
|
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||||
|
setItem: (key: string, value: string) => set(key, value),
|
||||||
|
removeItem: (key: string) => del(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const basePersister = createAsyncStoragePersister({
|
||||||
|
storage: idbStorage,
|
||||||
|
key: OFFLINE_CACHE_KEY,
|
||||||
|
throttleTime: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
|
||||||
|
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
|
||||||
|
// (queryClient.clear()) fires `removed` cache events, each of which the persist
|
||||||
|
// subscription turns into a throttled persistClient call. The FIRST such call
|
||||||
|
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
|
||||||
|
// del() that clears the key, resurrecting the previous user's data (~180KB) in
|
||||||
|
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
|
||||||
|
// afterwards so the next (sign-in) session persists normally. See
|
||||||
|
// clear-offline-cache.ts.
|
||||||
|
let persistFrozen = false;
|
||||||
|
|
||||||
|
export function freezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unfreezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryPersister = {
|
||||||
|
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
|
||||||
|
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
|
||||||
|
restoreClient: () => basePersister.restoreClient(),
|
||||||
|
removeClient: () => basePersister.removeClient(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only navigation/read query roots are persisted for offline reading.
|
||||||
|
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||||
|
//
|
||||||
|
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
|
||||||
|
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
|
||||||
|
// network error. Without the persisted/hydrated user the gate blanked every
|
||||||
|
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
|
||||||
|
// user's own profile (already mirrored to localStorage["currentUser"]), so
|
||||||
|
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
|
||||||
|
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||||
|
return (
|
||||||
|
query.state.status === "success" &&
|
||||||
|
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconCloud,
|
||||||
|
IconCloudCheck,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
function ConnectionWarning() {
|
function ConnectionWarning() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||||
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||||
yjsConnectionStatus,
|
yjsConnectionStatus,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (isDisconnected) {
|
if (isDisconnected) {
|
||||||
if (!timeoutRef.current) {
|
if (!timeoutRef.current) {
|
||||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||||
@@ -430,7 +436,7 @@ function ConnectionWarning() {
|
|||||||
}
|
}
|
||||||
setShowWarning(false);
|
setShowWarning(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus]);
|
}, [isDisconnected]);
|
||||||
|
|
||||||
// Cleanup only on unmount
|
// Cleanup only on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -441,19 +447,21 @@ function ConnectionWarning() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||||
|
// existing >5s debounce before surfacing this state.
|
||||||
|
if (isDisconnected) {
|
||||||
if (!showWarning) return null;
|
if (!showWarning) return null;
|
||||||
|
|
||||||
|
const offlineLabel = t(
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
c="red"
|
c="red"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
aria-label={offlineLabel}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
<IconWifiOff size={20} stroke={2} />
|
<IconWifiOff size={20} stroke={2} />
|
||||||
@@ -461,3 +469,38 @@ function ConnectionWarning() {
|
|||||||
</Tooltip>
|
</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={syncedLabel} openDelay={250} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="dimmed"
|
||||||
|
role="status"
|
||||||
|
aria-label={syncedLabel}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconCloudCheck size={20} stroke={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryResult,
|
UseInfiniteQueryResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
|||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factories for page queries. The hooks below and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||||
|
* runtime keys can never silently drift apart.
|
||||||
|
*/
|
||||||
|
export const pageKeys = {
|
||||||
|
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
|
||||||
|
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
|
||||||
|
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
|
||||||
|
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
|
||||||
|
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
|
||||||
|
* fetchAllAncestorChildren and the offline warm path consume this so the key,
|
||||||
|
* queryFn and staleTime stay identical.
|
||||||
|
*/
|
||||||
|
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: pageKeys.sidebar(params),
|
||||||
|
queryFn: () => getAllSidebarPages(params),
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
): UseQueryResult<IPage, Error> {
|
): UseQueryResult<IPage, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["pages", pageInput.pageId],
|
queryKey: pageKeys.detail(pageInput.pageId),
|
||||||
queryFn: () => getPageById(pageInput),
|
queryFn: () => getPageById(pageInput),
|
||||||
enabled: !!pageInput.pageId,
|
enabled: !!pageInput.pageId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
@@ -56,9 +83,9 @@ export function usePageQuery(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(pageInput.pageId)) {
|
if (isValidUuid(pageInput.pageId)) {
|
||||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -69,6 +96,10 @@ export function usePageQuery(
|
|||||||
export function useCreatePageMutation() {
|
export function useCreatePageMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
|
// Stable key so a paused create restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
|
||||||
|
// is replayed by resumePausedMutations() on reconnect instead of being lost.
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
invalidateOnCreatePage(data);
|
invalidateOnCreatePage(data);
|
||||||
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function updatePageData(data: IPage) {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
pageKeys.detail(data.slugId),
|
||||||
|
);
|
||||||
|
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||||
|
|
||||||
if (pageBySlug) {
|
if (pageBySlug) {
|
||||||
queryClient.setQueryData(["pages", data.slugId], {
|
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||||
...pageBySlug,
|
...pageBySlug,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageById) {
|
if (pageById) {
|
||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(
|
||||||
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const stamped = { ...cached, deletedAt: new Date() };
|
const stamped = { ...cached, deletedAt: new Date() };
|
||||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
|
|||||||
|
|
||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
|
// Stable key so a paused move restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,8 +303,11 @@ export function useRestorePageMutation() {
|
|||||||
// Replace would strip space/permissions/content and break the editor.
|
// Replace would strip space/permissions/content and break the editor.
|
||||||
const merge = (cached: IPage | undefined) =>
|
const merge = (cached: IPage | undefined) =>
|
||||||
cached ? { ...cached, ...restoredPage } : cached;
|
cached ? { ...cached, ...restoredPage } : cached;
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
queryClient.setQueryData<IPage>(
|
||||||
|
pageKeys.detail(restoredPage.slugId),
|
||||||
|
merge,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
data: SidebarPagesParams | null,
|
data: SidebarPagesParams | null,
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: pageKeys.sidebar(data),
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||||
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
|
|
||||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({
|
return getSidebarPages({
|
||||||
spaceId: data.spaceId,
|
spaceId: data.spaceId,
|
||||||
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["breadcrumbs", pageId],
|
queryKey: pageKeys.breadcrumbs(pageId),
|
||||||
queryFn: () => getPageBreadcrumbs(pageId),
|
queryFn: () => getPageBreadcrumbs(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
});
|
});
|
||||||
@@ -332,10 +371,12 @@ export async function fetchAllAncestorChildren(
|
|||||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||||
opts?: { fresh?: boolean },
|
opts?: { fresh?: boolean },
|
||||||
) {
|
) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
|
||||||
|
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
|
||||||
|
// this fetch never drift, but override staleTime for the `fresh` reconnect
|
||||||
|
// refresh (#159 #8), which must force a server refetch (staleTime 0).
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
...sidebarPagesQueryOptions(params),
|
||||||
queryFn: () => getAllSidebarPages(params),
|
|
||||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
|
|||||||
|
|
||||||
export function useRecentChangesQuery(spaceId?: string) {
|
export function useRecentChangesQuery(spaceId?: string) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (data.parentPageId === null) {
|
if (data.parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = [
|
queryKey = pageKeys.sidebar({
|
||||||
"sidebar-pages",
|
pageId: data.parentPageId,
|
||||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
spaceId: data.spaceId,
|
||||||
];
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update root sidebar pages haschildern
|
//update root sidebar pages haschildern
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
const rootSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", data.spaceId],
|
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
|
|||||||
invalidatePageTree();
|
invalidatePageTree();
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if (parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = pageKeys.rootSidebar(spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||||
}
|
}
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
oldQueryKey,
|
oldQueryKey,
|
||||||
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
|
|||||||
if (oldParentId !== null) {
|
if (oldParentId !== null) {
|
||||||
const oldParentCache = queryClient.getQueryData<
|
const oldParentCache = queryClient.getQueryData<
|
||||||
InfiniteData<IPagination<IPage>>
|
InfiniteData<IPagination<IPage>>
|
||||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||||
|
|
||||||
const remainingChildren =
|
const remainingChildren =
|
||||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||||
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Add page to new parent's cache
|
// Add page to new parent's cache
|
||||||
const newQueryKey =
|
const newQueryKey =
|
||||||
newParentId === null
|
newParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||||
newQueryKey,
|
newQueryKey,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
|
IconCloudDownload,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@@ -35,6 +36,12 @@ import {
|
|||||||
useToggleTemplateMutation,
|
useToggleTemplateMutation,
|
||||||
useToggleTemporaryMutation,
|
useToggleTemporaryMutation,
|
||||||
} from "@/features/page-embed/queries/page-embed-query";
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
makePageAvailableOffline,
|
||||||
|
warmPageYdoc,
|
||||||
|
} from "@/features/offline/make-offline";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
@@ -72,6 +79,46 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const isTemplate = !!node.isTemplate;
|
const isTemplate = !!node.isTemplate;
|
||||||
const toggleTemporary = useToggleTemporaryMutation();
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
const isTemporary = !!node.temporaryExpiresAt;
|
const isTemporary = !!node.temporaryExpiresAt;
|
||||||
|
const { data: collabQuery } = useCollabToken();
|
||||||
|
|
||||||
|
const handleMakeAvailableOffline = async () => {
|
||||||
|
notifications.show({ message: t("Saving page for offline use...") });
|
||||||
|
try {
|
||||||
|
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||||
|
// reports whether every warm step succeeded.
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: node.id,
|
||||||
|
spaceId: node.spaceId,
|
||||||
|
});
|
||||||
|
// Best-effort: warm the page's Yjs document into IndexedDB.
|
||||||
|
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
notifications.show({ message: t("Page is now available offline") });
|
||||||
|
} else {
|
||||||
|
// Partial warm — the page may still be partly usable offline, but some
|
||||||
|
// queries failed to cache, so surface it as an error rather than a
|
||||||
|
// silent success. Name the failed step(s) (AGENTS.md: errors must be
|
||||||
|
// specific, never a bare generic string); `result.failed` carries them.
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${result.failed.join(", ")}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||||
|
// unexpected failures stay guarded here. Log the raw error and surface the
|
||||||
|
// real cause to the user instead of a bare generic string (AGENTS.md).
|
||||||
|
console.error("handleMakeAvailableOffline failed", err);
|
||||||
|
const reason =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? (err instanceof Error ? err.message : String(err));
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${reason}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleTemplate = async () => {
|
const handleToggleTemplate = async () => {
|
||||||
const next = !isTemplate;
|
const next = !isTemplate;
|
||||||
@@ -228,6 +275,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
{t("Export")}
|
{t("Export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconCloudDownload size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMakeAvailableOffline();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Make available offline")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAtom, useStore } from "jotai";
|
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -15,11 +15,11 @@ import {
|
|||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
|
||||||
updateCacheOnMovePage,
|
updateCacheOnMovePage,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
|
||||||
export type UseTreeMutation = {
|
export type UseTreeMutation = {
|
||||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||||
@@ -27,7 +27,6 @@ export type UseTreeMutation = {
|
|||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
opts?: { temporary?: boolean },
|
opts?: { temporary?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
handleRename: (id: string, name: string) => Promise<void>;
|
|
||||||
handleDelete: (id: string) => Promise<void>;
|
handleDelete: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,10 +38,10 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
// children) and then immediately invokes a handler.
|
// children) and then immediately invokes a handler.
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
|
||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
@@ -201,22 +200,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
createdPage.title,
|
createdPage.title,
|
||||||
);
|
);
|
||||||
navigate(pageUrl);
|
navigate(pageUrl);
|
||||||
|
// On mobile the create action is triggered from inside the off-canvas
|
||||||
|
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||||
|
// alone leaves that drawer open on top of the freshly created page, so the
|
||||||
|
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||||
|
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||||
|
// No-op on desktop, where the mobile drawer atom is already false.
|
||||||
|
setMobileSidebar(false);
|
||||||
},
|
},
|
||||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
[
|
||||||
);
|
spaceId,
|
||||||
|
createPageMutation,
|
||||||
const handleRename = useCallback(
|
setData,
|
||||||
async (id: string, name: string) => {
|
store,
|
||||||
setData((prev) =>
|
navigate,
|
||||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
spaceSlug,
|
||||||
);
|
setMobileSidebar,
|
||||||
try {
|
],
|
||||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating page title:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updatePageMutation, setData],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -264,7 +264,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
return { handleMove, handleCreate, handleDelete };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
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(
|
export function useGetSpacesQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["spaces", params],
|
queryKey: spaceKeys.list(params),
|
||||||
queryFn: () => getSpaces(params),
|
queryFn: () => getSpaces(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
|||||||
|
|
||||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["space", spaceId],
|
queryKey: spaceKeys.detail(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(spaceId)) {
|
if (isValidUuid(spaceId)) {
|
||||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
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({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["space", spaceSlug],
|
queryKey: spaceKeys.detail(spaceSlug),
|
||||||
queryFn: () => getSpaceById(spaceSlug),
|
queryFn: () => getSpaceById(spaceSlug),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
): UseQueryResult<ISpace, Error> {
|
): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["space", spaceId],
|
...spaceByIdQueryOptions(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
|||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: t("Space updated successfully") });
|
notifications.show({ message: t("Space updated successfully") });
|
||||||
|
|
||||||
const space = queryClient.getQueryData([
|
const space = queryClient.getQueryData(
|
||||||
"space",
|
spaceKeys.detail(variables.spaceId),
|
||||||
variables.spaceId,
|
) as ISpace;
|
||||||
]) as ISpace;
|
|
||||||
if (space) {
|
if (space) {
|
||||||
const updatedSpace = { ...space, ...data };
|
const updatedSpace = { ...space, ...data };
|
||||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
queryClient.setQueryData(
|
||||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
spaceKeys.detail(variables.spaceId),
|
||||||
|
updatedSpace,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
|
|
||||||
if (variables.slug) {
|
if (variables.slug) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.slug],
|
queryKey: spaceKeys.detail(variables.slug),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
// Remove space-specific queries
|
// Remove space-specific queries
|
||||||
if (variables.id) {
|
if (variables.id) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.id],
|
queryKey: spaceKeys.detail(variables.id),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
|||||||
query?: string,
|
query?: string,
|
||||||
) {
|
) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["spaceMembers", spaceId, query],
|
queryKey: spaceKeys.members(spaceId, query),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|||||||
import { getMyInfo } from "@/features/user/services/user-service";
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factory for current-user queries. This hook and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share it so the
|
||||||
|
* runtime key can never silently drift, and the OFFLINE_PERSIST_ROOTS guard
|
||||||
|
* test can assert the persisted "currentUser" root maps to a real factory.
|
||||||
|
*/
|
||||||
|
export const userKeys = {
|
||||||
|
currentUser: () => ["currentUser"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["currentUser"],
|
queryKey: userKeys.currentUser(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await getMyInfo();
|
return await getMyInfo();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
|
||||||
|
// Control useCurrentUser per test; stub the rest of UserProvider's network/
|
||||||
|
// socket dependencies so we only exercise its render-gating logic.
|
||||||
|
const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/features/user/hooks/use-current-user", () => ({
|
||||||
|
default: h.useCurrentUser,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||||
|
useCollabToken: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-query-subscription.ts", () => ({
|
||||||
|
useQuerySubscription: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-tree-socket.ts", () => ({
|
||||||
|
useTreeSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({
|
||||||
|
useNotificationSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||||
|
vi.mock("@/features/user/connect-resync.ts", () => ({
|
||||||
|
makeConnectHandler: () => () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("socket.io-client", () => ({
|
||||||
|
io: () => ({ on: vi.fn(), disconnect: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string) => k,
|
||||||
|
i18n: {
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
language: "en-US",
|
||||||
|
resolvedLanguage: "en-US",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { UserProvider } from "./user-provider";
|
||||||
|
|
||||||
|
const networkError = { message: "Network Error" }; // axios network error: no `response`
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<div data-testid="app-child">app content</div>
|
||||||
|
</UserProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.useCurrentUser.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UserProvider offline render-gating", () => {
|
||||||
|
it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => {
|
||||||
|
// Offline reload: the persisted ['currentUser'] cache hydrates `data`, but
|
||||||
|
// the background POST /api/users/me refetch fails as a network error.
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
|
||||||
|
// The cached app must render — NOT a blank fragment (#237/#238).
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
expect(screen.queryByText("You're offline")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = renderProvider();
|
||||||
|
|
||||||
|
// Previously this returned `<></>` — a blank white screen. Now it must show
|
||||||
|
// an explicit offline fallback.
|
||||||
|
expect(screen.getByText("You're offline")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("app-child")).toBeNull();
|
||||||
|
expect(container.textContent?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app normally on a successful currentUser load", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
|||||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
|
||||||
@@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||||
}, [i18n.language, i18n.resolvedLanguage]);
|
}, [i18n.language, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
if (isLoading) return <></>;
|
// First load with no cached user yet: render nothing briefly while the
|
||||||
|
// persisted ['currentUser'] cache hydrates (avoids flashing the offline
|
||||||
|
// fallback before restore). Once we have a user we render the app even if a
|
||||||
|
// refetch is still in flight.
|
||||||
|
if (isLoading && !data) return <></>;
|
||||||
|
|
||||||
if (isError && error?.["response"]?.status === 404) {
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
return <Error404 />;
|
return <Error404 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We have a (possibly cached/stale) user — render the app. Offline, the
|
||||||
|
// POST /api/users/me refetch fails as a network error, but the persisted/
|
||||||
|
// hydrated user is enough to render the cached UI. Previously `if (error)
|
||||||
|
// return <></>` blanked every authenticated route on an offline reload even
|
||||||
|
// though the cached data was present (#237/#238).
|
||||||
|
if (data) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No user AND an error (offline cold boot of a page never warmed for offline,
|
||||||
|
// or no persisted cache to restore): show an explicit offline fallback rather
|
||||||
|
// than a blank white screen.
|
||||||
if (error) {
|
if (error) {
|
||||||
return <></>;
|
return <OfflineFallback />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+966
-265
File diff suppressed because it is too large
Load Diff
+62
@@ -394,6 +394,10 @@ export default function AiProviderSettings() {
|
|||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||||
);
|
);
|
||||||
|
// #184: detached/autonomous agent runs (settings.ai.autonomousRuns).
|
||||||
|
const [autonomousRunsEnabled, setAutonomousRunsEnabled] = useState<boolean>(
|
||||||
|
workspace?.settings?.ai?.autonomousRuns ?? false,
|
||||||
|
);
|
||||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||||
@@ -403,6 +407,8 @@ export default function AiProviderSettings() {
|
|||||||
publicShareAssistantToggleLoading,
|
publicShareAssistantToggleLoading,
|
||||||
setPublicShareAssistantToggleLoading,
|
setPublicShareAssistantToggleLoading,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Whether a key is currently stored server-side (drives the placeholder).
|
// Whether a key is currently stored server-side (drives the placeholder).
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
const [hasApiKey, setHasApiKey] = useState(false);
|
||||||
@@ -730,6 +736,37 @@ export default function AiProviderSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic toggle for detached/autonomous agent runs
|
||||||
|
// (settings.ai.autonomousRuns). When on, a chat turn becomes a server-side run
|
||||||
|
// that survives a browser disconnect and can be reconnected to / live-followed;
|
||||||
|
// only an explicit Stop ends it. Off by default; single-instance-only in phase 1.
|
||||||
|
async function handleToggleAutonomousRuns(value: boolean) {
|
||||||
|
setAutonomousRunsToggleLoading(true);
|
||||||
|
const previous = autonomousRunsEnabled;
|
||||||
|
setAutonomousRunsEnabled(value);
|
||||||
|
try {
|
||||||
|
const updated = await updateWorkspace({ autonomousRuns: value });
|
||||||
|
setWorkspace({
|
||||||
|
...updated,
|
||||||
|
settings: {
|
||||||
|
...updated.settings,
|
||||||
|
ai: { ...updated.settings?.ai, autonomousRuns: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notifications.show({ message: t("Updated successfully") });
|
||||||
|
} catch (err) {
|
||||||
|
setAutonomousRunsEnabled(previous);
|
||||||
|
const message = (err as { response?: { data?: { message?: string } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAutonomousRunsToggleLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Admins only — match the previous behavior.
|
// Admins only — match the previous behavior.
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -960,6 +997,31 @@ export default function AiProviderSettings() {
|
|||||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Detached/autonomous agent runs: a chat turn becomes a server-side run
|
||||||
|
that survives a browser disconnect; only an explicit Stop ends it.
|
||||||
|
Single-instance-only in phase 1. */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("Autonomous agent runs")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Keep an agent turn running server-side even if the browser disconnects; reconnect and follow it on reopen. Single-instance deployments only.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Switch
|
||||||
|
label={t("Enabled")}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={autonomousRunsEnabled}
|
||||||
|
disabled={autonomousRunsToggleLoading}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleToggleAutonomousRuns(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group mt="md" align="center">
|
<Group mt="md" align="center">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface IWorkspace {
|
|||||||
aiDictation?: boolean;
|
aiDictation?: boolean;
|
||||||
aiDictationStreaming?: boolean;
|
aiDictationStreaming?: boolean;
|
||||||
aiPublicShareAssistant?: boolean;
|
aiPublicShareAssistant?: boolean;
|
||||||
|
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||||
|
// settings.ai.autonomousRuns.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||||
temporaryNoteHours?: number;
|
temporaryNoteHours?: number;
|
||||||
@@ -65,6 +68,9 @@ export interface IWorkspaceAiSettings {
|
|||||||
dictation?: boolean;
|
dictation?: boolean;
|
||||||
dictationStreaming?: boolean;
|
dictationStreaming?: boolean;
|
||||||
publicShareAssistant?: boolean;
|
publicShareAssistant?: boolean;
|
||||||
|
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||||
|
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
|||||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — operator toggle for client perf-telemetry. DEFAULT OFF: the server
|
||||||
|
// mirrors CLIENT_TELEMETRY_ENABLED into window.CONFIG; when off the client
|
||||||
|
// installs no observers and sends nothing (the sink endpoint doesn't exist).
|
||||||
|
export function isClientTelemetryEnabled(): boolean {
|
||||||
|
return castToBoolean(getConfigValue("CLIENT_TELEMETRY_ENABLED", "false"));
|
||||||
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { templateRoute } from "./route-template";
|
||||||
|
|
||||||
|
describe("templateRoute", () => {
|
||||||
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
|
const t = templateRoute("/s/engineering/p/design-doc-abc123");
|
||||||
|
expect(t).toBe("/s/:space/p/:slug");
|
||||||
|
expect(t).not.toContain("engineering");
|
||||||
|
expect(t).not.toContain("design-doc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("templates share, redirect and space paths", () => {
|
||||||
|
expect(templateRoute("/share/abc/p/xyz")).toBe("/share/:shareId/p/:slug");
|
||||||
|
expect(templateRoute("/share/p/xyz")).toBe("/share/p/:slug");
|
||||||
|
expect(templateRoute("/p/some-slug")).toBe("/p/:slug");
|
||||||
|
expect(templateRoute("/s/team")).toBe("/s/:space");
|
||||||
|
expect(templateRoute("/s/team/trash")).toBe("/s/:space/trash");
|
||||||
|
expect(templateRoute("/labels/urgent")).toBe("/labels/:label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps known static routes verbatim", () => {
|
||||||
|
expect(templateRoute("/home")).toBe("/home");
|
||||||
|
expect(templateRoute("/settings/members")).toBe("/settings/members");
|
||||||
|
expect(templateRoute("/")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises a trailing slash", () => {
|
||||||
|
expect(templateRoute("/s/team/p/slug/")).toBe("/s/:space/p/:slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses unknown paths to 'other' (bounded cardinality)", () => {
|
||||||
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
||||||
|
*
|
||||||
|
* Perf metrics must be labelled by route template only — never a raw path with
|
||||||
|
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
||||||
|
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
||||||
|
* does not match a known pattern collapses to `other`.
|
||||||
|
*
|
||||||
|
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
||||||
|
*/
|
||||||
|
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
||||||
|
// Share pages (public).
|
||||||
|
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
||||||
|
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
||||||
|
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
||||||
|
// Page redirect.
|
||||||
|
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
||||||
|
// Space + page.
|
||||||
|
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
||||||
|
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
||||||
|
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
||||||
|
// Misc dynamic.
|
||||||
|
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
||||||
|
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
||||||
|
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Static routes we accept verbatim (finite set).
|
||||||
|
const STATIC_ROUTES = new Set<string>([
|
||||||
|
'/home',
|
||||||
|
'/spaces',
|
||||||
|
'/favorites',
|
||||||
|
'/login',
|
||||||
|
'/forgot-password',
|
||||||
|
'/password-reset',
|
||||||
|
'/setup/register',
|
||||||
|
'/settings/account/profile',
|
||||||
|
'/settings/account/preferences',
|
||||||
|
'/settings/workspace',
|
||||||
|
'/settings/ai',
|
||||||
|
'/settings/members',
|
||||||
|
'/settings/groups',
|
||||||
|
'/settings/spaces',
|
||||||
|
'/settings/sharing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function templateRoute(pathname: string): string {
|
||||||
|
// Normalise a trailing slash (except root).
|
||||||
|
const path =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/')
|
||||||
|
? pathname.slice(0, -1)
|
||||||
|
: pathname;
|
||||||
|
|
||||||
|
if (path === '' || path === '/') return '/';
|
||||||
|
if (STATIC_ROUTES.has(path)) return path;
|
||||||
|
|
||||||
|
for (const { re, template } of ROUTE_PATTERNS) {
|
||||||
|
if (re.test(path)) return template;
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template for the current window location. */
|
||||||
|
export function currentRouteTemplate(): string {
|
||||||
|
try {
|
||||||
|
return templateRoute(window.location.pathname);
|
||||||
|
} catch {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
onCLS,
|
||||||
|
onINP,
|
||||||
|
onLCP,
|
||||||
|
onTTFB,
|
||||||
|
type CLSMetricWithAttribution,
|
||||||
|
type INPMetricWithAttribution,
|
||||||
|
type LCPMetricWithAttribution,
|
||||||
|
type TTFBMetricWithAttribution,
|
||||||
|
} from "web-vitals/attribution";
|
||||||
|
import { isClientTelemetryEnabled } from "@/lib/config";
|
||||||
|
import { currentRouteTemplate } from "./route-template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client perf-telemetry (#355): web-vitals + custom metrics buffered and posted
|
||||||
|
* to POST /api/telemetry/vitals via sendBeacon.
|
||||||
|
*
|
||||||
|
* Design constraints from the issue:
|
||||||
|
* - Sampling is decided ONCE per session (25%), cached in sessionStorage,
|
||||||
|
* BEFORE any observer is subscribed. Non-sampled sessions send nothing.
|
||||||
|
* - Route labels are TEMPLATES only; attr is truncated to 120 chars; no page
|
||||||
|
* titles/slugs/text ever leave the browser.
|
||||||
|
* - Observers are passive and reporting is best-effort — telemetry must not
|
||||||
|
* degrade the perf it measures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ENDPOINT = "/api/telemetry/vitals";
|
||||||
|
const SAMPLE_RATE = 0.25;
|
||||||
|
const SAMPLE_KEY = "gm_vitals_sampled";
|
||||||
|
const FLUSH_INTERVAL_MS = 15_000;
|
||||||
|
const MAX_BUFFER = 40; // flush early if the buffer fills between timers
|
||||||
|
const MAX_ATTR_LENGTH = 120;
|
||||||
|
const EDITOR_TX_MIN_MS = 8; // only report editor transactions slower than this
|
||||||
|
|
||||||
|
const ALLOWED_NAMES = new Set([
|
||||||
|
"INP",
|
||||||
|
"LCP",
|
||||||
|
"CLS",
|
||||||
|
"TTFB",
|
||||||
|
"editor_tx_ms",
|
||||||
|
"page_open_ms",
|
||||||
|
"longtask_ms",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface VitalEvent {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating?: string;
|
||||||
|
route?: string;
|
||||||
|
attr?: string;
|
||||||
|
docSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampledCache: boolean | null = null;
|
||||||
|
let initialised = false;
|
||||||
|
let buffer: VitalEvent[] = [];
|
||||||
|
let longtaskSum = 0; // accumulated longtask duration (ms) for the current window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide once per session whether this session is sampled. Cached in
|
||||||
|
* sessionStorage so the choice is stable across reloads within the session and
|
||||||
|
* identical for every observer/custom-metric caller.
|
||||||
|
*/
|
||||||
|
export function isVitalsSampled(): boolean {
|
||||||
|
if (sampledCache !== null) return sampledCache;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(SAMPLE_KEY);
|
||||||
|
if (stored === "1") return (sampledCache = true);
|
||||||
|
if (stored === "0") return (sampledCache = false);
|
||||||
|
const sampled = Math.random() < SAMPLE_RATE;
|
||||||
|
sessionStorage.setItem(SAMPLE_KEY, sampled ? "1" : "0");
|
||||||
|
return (sampledCache = sampled);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / SSR): default to not sampled.
|
||||||
|
return (sampledCache = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when telemetry is BOTH enabled by the operator (F1 flag) AND this
|
||||||
|
* session is sampled. Callers outside initVitals (e.g. the editor dispatch
|
||||||
|
* wrapper) use this to skip ALL instrumentation cost on disabled/non-sampled
|
||||||
|
* sessions — no observers, no per-transaction timing.
|
||||||
|
*/
|
||||||
|
export function isVitalsActive(): boolean {
|
||||||
|
return isClientTelemetryEnabled() && isVitalsSampled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAttr(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||||
|
return value.slice(0, MAX_ATTR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(event: VitalEvent): void {
|
||||||
|
if (!ALLOWED_NAMES.has(event.name)) return;
|
||||||
|
if (!Number.isFinite(event.value)) return;
|
||||||
|
buffer.push(event);
|
||||||
|
if (buffer.length >= MAX_BUFFER) flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
// Fold any pending longtask total into the batch first.
|
||||||
|
if (longtaskSum > 0) {
|
||||||
|
buffer.push({
|
||||||
|
name: "longtask_ms",
|
||||||
|
value: Math.round(longtaskSum),
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
});
|
||||||
|
longtaskSum = 0;
|
||||||
|
}
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ events: buffer });
|
||||||
|
buffer = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = new Blob([payload], { type: "application/json" });
|
||||||
|
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
|
||||||
|
// Fallback for browsers without sendBeacon: keepalive fetch.
|
||||||
|
void fetch(ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: never throw out of telemetry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a custom client metric (editor_tx_ms, page_open_ms). No-op unless the
|
||||||
|
* session is sampled. Route is always the current TEMPLATE.
|
||||||
|
*/
|
||||||
|
export function reportClientMetric(
|
||||||
|
name: "editor_tx_ms" | "page_open_ms",
|
||||||
|
value: number,
|
||||||
|
extra?: { docSize?: number },
|
||||||
|
): void {
|
||||||
|
if (!isVitalsActive()) return;
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
enqueue({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
docSize: extra?.docSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threshold-gated editor transaction reporter (only reports slow syncs). */
|
||||||
|
export function reportEditorTx(ms: number, docSize: number): void {
|
||||||
|
if (ms <= EDITOR_TX_MIN_MS) return;
|
||||||
|
reportClientMetric("editor_tx_ms", ms, { docSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_OPEN_MARK = "gm_page_open_start";
|
||||||
|
|
||||||
|
/** Mark the start of a page-open interaction (tree-row / link click). */
|
||||||
|
export function markPageOpenStart(): void {
|
||||||
|
try {
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
performance.mark(PAGE_OPEN_MARK);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure page_open_ms at first editor-content render, if a start mark exists.
|
||||||
|
* Consumes the mark so a later render doesn't double-count.
|
||||||
|
*/
|
||||||
|
export function measurePageOpen(): void {
|
||||||
|
try {
|
||||||
|
const marks = performance.getEntriesByName(PAGE_OPEN_MARK, "mark");
|
||||||
|
if (marks.length === 0) return;
|
||||||
|
const started = marks[0].startTime;
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
if (elapsed > 0 && Number.isFinite(elapsed)) {
|
||||||
|
reportClientMetric("page_open_ms", elapsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrTarget(
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
): string | undefined {
|
||||||
|
const a = metric.attribution as Record<string, unknown> | undefined;
|
||||||
|
if (!a) return undefined;
|
||||||
|
// Different vitals expose their culprit element under different keys; only a
|
||||||
|
// CSS-selector-ish target string is taken (no text content / titles).
|
||||||
|
return (
|
||||||
|
truncateAttr(a.interactionTarget) ??
|
||||||
|
truncateAttr(a.element) ??
|
||||||
|
truncateAttr(a.largestShiftTarget) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise client telemetry. Safe to call multiple times (idempotent). Returns
|
||||||
|
* immediately without subscribing when the session is not sampled — so a
|
||||||
|
* non-sampled session subscribes to NO observers and sends nothing.
|
||||||
|
*/
|
||||||
|
export function initVitals(): void {
|
||||||
|
if (initialised) return;
|
||||||
|
initialised = true;
|
||||||
|
|
||||||
|
// Operator flag gate (F1, default OFF): when telemetry is disabled the sink
|
||||||
|
// endpoint does not even exist server-side, so install ZERO observers.
|
||||||
|
if (!isClientTelemetryEnabled()) return;
|
||||||
|
|
||||||
|
// Sampling gate is evaluated BEFORE any observer subscription.
|
||||||
|
if (!isVitalsSampled()) return;
|
||||||
|
|
||||||
|
const report = (
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution
|
||||||
|
| TTFBMetricWithAttribution,
|
||||||
|
) => {
|
||||||
|
enqueue({
|
||||||
|
name: metric.name,
|
||||||
|
value: metric.value,
|
||||||
|
rating: metric.rating,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
attr:
|
||||||
|
metric.name === "TTFB"
|
||||||
|
? undefined
|
||||||
|
: attrTarget(
|
||||||
|
metric as
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onINP(report);
|
||||||
|
onLCP(report);
|
||||||
|
onCLS(report);
|
||||||
|
onTTFB(report);
|
||||||
|
|
||||||
|
// Long tasks: aggregate the total blocking time per flush window (a passive
|
||||||
|
// observer; individual entries are summed, never stored/sent individually).
|
||||||
|
try {
|
||||||
|
if (typeof PerformanceObserver !== "undefined") {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
longtaskSum += entry.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe({ type: "longtask", buffered: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// longtask entry type unsupported: skip silently.
|
||||||
|
}
|
||||||
|
|
||||||
|
// page_open_ms start: mark when the user clicks a page link/tree-row (any
|
||||||
|
// anchor navigating to a page URL). Passive capture listener; the matching
|
||||||
|
// measure fires at first editor-content render (measurePageOpen). No page
|
||||||
|
// titles/slugs are read — only the click timing is marked.
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href") ?? "";
|
||||||
|
// A page link is `/s/:space/p/:slug`, `/p/:slug` or a share page path.
|
||||||
|
if (/\/p\//.test(href)) markPageOpenStart();
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flush on tab hide (most reliable delivery point) and periodically.
|
||||||
|
const onHidden = () => {
|
||||||
|
if (document.visibilityState === "hidden") flush();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onHidden);
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
|
||||||
|
setInterval(flush, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
@@ -21,7 +22,15 @@ import {
|
|||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
queryPersister,
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
} from "@/features/offline/query-persister";
|
||||||
|
import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations";
|
||||||
|
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||||
|
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -30,10 +39,30 @@ export const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||||
|
gcTime: 1000 * 60 * 60 * 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register default mutationFns for the offline-relevant structural mutations so
|
||||||
|
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||||
|
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||||
|
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||||
|
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||||
|
registerOfflineMutationDefaults(queryClient);
|
||||||
|
|
||||||
|
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||||
|
// It defaults to `online: true` and only flips on window online/offline events,
|
||||||
|
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||||
|
// mutations restored from IndexedDB would never get a later offline->online
|
||||||
|
// transition to trigger their replay, and the offline UI affordances could not
|
||||||
|
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||||
|
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||||
|
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||||
|
onlineManager.setOnline(navigator.onLine);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
if (isCloud() && isPostHogEnabled) {
|
||||||
posthog.init(getPostHogKey(), {
|
posthog.init(getPostHogKey(), {
|
||||||
api_host: getPostHogHost(),
|
api_host: getPostHogHost(),
|
||||||
@@ -43,6 +72,10 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
|
initVitals();
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
@@ -50,15 +83,44 @@ root.render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24,
|
||||||
|
buster: APP_VERSION,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// After the persister finishes rehydrating, replay any paused
|
||||||
|
// mutations restored from IndexedDB. If we are back online this fires
|
||||||
|
// them immediately; if still offline they stay paused and TanStack's
|
||||||
|
// onlineManager auto-resumes them on the next online transition (which
|
||||||
|
// is now a true transition thanks to the onlineManager seeding above).
|
||||||
|
// Without this, a paused mutation persisted while offline and then
|
||||||
|
// reloaded would never resume and the user's work would be lost (#120).
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.resumePausedMutations();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<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>
|
<HelmetProvider>
|
||||||
<PostHogProvider client={posthog}>
|
<PostHogProvider client={posthog}>
|
||||||
<App />
|
<App />
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</BrowserRouter>,
|
</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.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !page) {
|
if (isError || !page) {
|
||||||
if ([401, 403, 404].includes(error?.["status"])) {
|
// An offline fetch of a page that was never saved for offline use yields a
|
||||||
|
// network error with NO HTTP status (status is undefined), which would
|
||||||
|
// otherwise fall through to the generic "Error fetching page data." state.
|
||||||
|
// When we are offline (or the failure is a network error with no status),
|
||||||
|
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||||
|
// fallback instead, so the user understands why the page won't load.
|
||||||
|
const httpStatus = error?.["status"];
|
||||||
|
const isOffline =
|
||||||
|
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||||
|
if (isOffline || (isError && httpStatus == null)) {
|
||||||
|
return <OfflineFallback />;
|
||||||
|
}
|
||||||
|
if ([401, 403, 404].includes(httpStatus)) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconFileOff}
|
icon={IconFileOff}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+2
@@ -1,2 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
/// <reference types="vite-plugin-pwa/info" />
|
||||||
declare const APP_VERSION: string
|
declare const APP_VERSION: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
@@ -53,7 +54,55 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
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`, `/l`, 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, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||||
|
// (a server 302 to a share page) are served by server controllers, so
|
||||||
|
// the SW must never shadow them with the precached index.html app shell.
|
||||||
|
// For `/l/:alias` the client router has NO matching route, so serving
|
||||||
|
// the app shell would dead-end on Error404 and break the public link;
|
||||||
|
// it must reach the server to perform the redirect.
|
||||||
|
navigateFallbackDenylist: [
|
||||||
|
/^\/api(\/|$)/,
|
||||||
|
/^\/collab(\/|$)/,
|
||||||
|
/^\/socket\.io(\/|$)/,
|
||||||
|
/^\/share(\/|$)/,
|
||||||
|
/^\/mcp(\/|$)/,
|
||||||
|
/^\/l(\/|$)/,
|
||||||
|
/^\/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: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.19",
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.19",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
@@ -111,6 +113,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"postmark": "^4.0.7",
|
"postmark": "^4.0.7",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-email": "6.0.8",
|
"react-email": "6.0.8",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -174,7 +177,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -192,7 +195,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -203,7 +206,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user