Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b476a205 | |||
| 834684c37a | |||
| 080d1b6051 | |||
| 7f88b0b441 | |||
| 8f7664eb04 | |||
| cb445f0966 | |||
| cafe29b153 | |||
| 35f2c06f42 | |||
| ce9f1a8980 | |||
| 15fe998d3d | |||
| 54f0ba681e | |||
| 27f7791a0e | |||
| 15859e3b9f | |||
| ebf132d5ac | |||
| 05d3456f5c | |||
| d910180772 | |||
| 89a4bf28ce | |||
| da94b1589c | |||
| 40227bbf51 | |||
| a26803a1bc | |||
| 18eee98b7f | |||
| 067fc46170 | |||
| ab1da408e5 | |||
| da7bb95d4f | |||
| 6ab2e989b9 | |||
| 169e34d766 | |||
| 10d5220f5e | |||
| 52ee3c1f3e | |||
| ccee32cb0b | |||
| 79a461f79d | |||
| 24946ad820 | |||
| 84334a1f34 |
+12
-13
@@ -92,19 +92,6 @@ IFRAME_EMBED_ALLOWED=false
|
||||
# Example: https://intranet.example.com,https://portal.example.com
|
||||
IFRAME_ALLOWED_ORIGINS=
|
||||
|
||||
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||
# Leave empty for a same-origin (web-only) deployment.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||
SWAGGER_ENABLED=false
|
||||
|
||||
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||
# Leave empty for Android bundled mode / local development.
|
||||
CAP_SERVER_URL=
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
@@ -235,6 +222,18 @@ MCP_DOCMOST_PASSWORD=
|
||||
# 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.
|
||||
#
|
||||
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
||||
# durable run tees its SSE frames into an in-memory registry, and a
|
||||
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
||||
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
||||
# (default) the registry is never populated and attach always answers 204, so a
|
||||
# reopened tab of an active run silently falls back to degraded 2.5s history
|
||||
# polling — every wire path stays byte-for-byte identical to a build without the
|
||||
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||
# registry is process-local).
|
||||
# AI_CHAT_RESUMABLE_STREAM=false
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
name: Nightly property fuzz
|
||||
|
||||
# The daily heavy property run for the ProseMirror<->Markdown converter
|
||||
# (packages/prosemirror-markdown). The PR/CI test run keeps NUM_RUNS modest to
|
||||
# stay under budget; this cron cranks up total coverage with random seeds to hunt
|
||||
# for deeper round-trip counterexamples than a fixed-seed PR run can reach.
|
||||
#
|
||||
# WHY SHARDING: a single mega-run (~10000 fast-check runs) OOMs the vitest worker
|
||||
# (empirically ~1625 runs -> "JS heap out of memory", ~2GB) because heap
|
||||
# accumulates across the whole property run in one process. Instead this job runs
|
||||
# SHARDS fresh vitest processes, each a MODERATE per-shard count with a DISTINCT
|
||||
# derived seed, so total coverage ~= SHARDS x PER_SHARD_NUM_RUNS across processes
|
||||
# that never accumulate heap. On the first failing shard we stop and keep that
|
||||
# shard's output for triage.
|
||||
#
|
||||
# Counterexample -> fixture workflow: when a shard fails, fast-check prints the
|
||||
# SHRUNK minimal counterexample plus the reproducing seed. This job files a Gitea
|
||||
# issue containing that seed + counterexample ONLY when the output actually holds
|
||||
# a fast-check counterexample; an infra failure (OOM/tsc/install, no
|
||||
# counterexample) is filed under a DISTINCT title so it can never poison the
|
||||
# counterexample dedup. A human then commits the shrunk doc as a PERMANENT fixture
|
||||
# under packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
||||
# counterexamples.test.ts, and FIXES the converter (never weakens a property to
|
||||
# hide the bug). See packages/prosemirror-markdown/README.md.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:00 UTC daily.
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
num_runs:
|
||||
description: 'fast-check runs PER SHARD (8 shards run in sequence)'
|
||||
required: false
|
||||
default: '600'
|
||||
seed:
|
||||
description: 'base fast-check seed (empty = random); shard i uses base+i'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
property-fuzz:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# No build step: the generative suite imports the converter from src/
|
||||
# directly (e.g. `from '../../src/lib/markdown-converter.js'`), so it runs
|
||||
# against source without the package's build/. Skipping the build also
|
||||
# keeps a tsc build error from masquerading as a property-test failure and
|
||||
# filing a bogus counterexample issue.
|
||||
- name: Resolve base seed and per-shard run count
|
||||
id: params
|
||||
# Dispatch inputs are read via env (NOT interpolated into the shell body)
|
||||
# to avoid script injection through a crafted input value.
|
||||
env:
|
||||
SEED_INPUT: ${{ inputs.seed }}
|
||||
NUM_RUNS_INPUT: ${{ inputs.num_runs }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEED="${SEED_INPUT:-}"
|
||||
# Empty seed (cron, or a dispatch that left it blank) -> random. Combine
|
||||
# two RANDOMs so the seed spans more than RANDOM's 0..32767 range.
|
||||
[ -z "$SEED" ] && SEED=$(( (RANDOM << 15) | RANDOM ))
|
||||
NUM_RUNS="${NUM_RUNS_INPUT:-}"
|
||||
[ -z "$NUM_RUNS" ] && NUM_RUNS=600
|
||||
echo "seed=$SEED" >> "$GITHUB_OUTPUT"
|
||||
echo "num_runs=$NUM_RUNS" >> "$GITHUB_OUTPUT"
|
||||
echo "Sharded property fuzz: BASE_SEED=$SEED PER_SHARD_NUM_RUNS=$NUM_RUNS SHARDS=8"
|
||||
|
||||
- name: Run generative property suite (sharded)
|
||||
id: fuzz
|
||||
env:
|
||||
BASE_SEED: ${{ steps.params.outputs.seed }}
|
||||
PER_SHARD_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
SHARDS: '8'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Give each fresh process headroom, but rely on SHARDING (not a big heap)
|
||||
# to avoid OOM: a moderate per-shard count in a process that starts clean.
|
||||
export NODE_OPTIONS=--max-old-space-size=4096
|
||||
: > property-output.txt
|
||||
FAILED=0
|
||||
FAIL_SEED=""
|
||||
i=0
|
||||
while [ "$i" -lt "$SHARDS" ]; do
|
||||
SHARD_SEED=$(( BASE_SEED + i ))
|
||||
echo "=== shard $((i + 1))/$SHARDS: PROPERTY_SEED=$SHARD_SEED PROPERTY_NUM_RUNS=$PER_SHARD_NUM_RUNS ==="
|
||||
# tee OVERWRITES property-output.txt each shard; since we break on the
|
||||
# first failure, the file ends up holding exactly the failing shard's
|
||||
# output (which carries the shrunk counterexample + reproducing seed).
|
||||
if PROPERTY_SEED="$SHARD_SEED" PROPERTY_NUM_RUNS="$PER_SHARD_NUM_RUNS" \
|
||||
pnpm --filter @docmost/prosemirror-markdown exec \
|
||||
vitest run test/generative/ 2>&1 | tee property-output.txt; then
|
||||
echo "shard $((i + 1)) passed"
|
||||
else
|
||||
echo "shard $((i + 1)) FAILED (seed=$SHARD_SEED) — stopping; keeping its output"
|
||||
FAILED=1
|
||||
FAIL_SEED="$SHARD_SEED"
|
||||
break
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
||||
echo "fail_seed=$FAIL_SEED" >> "$GITHUB_OUTPUT"
|
||||
exit "$FAILED"
|
||||
|
||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||
# the next step under a different title) can never poison this dedup.
|
||||
- name: File counterexample issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property counterexample'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Discriminate counterexample vs infra failure by the fast-check
|
||||
# signature. No signature -> leave it to the infra-failure step.
|
||||
if ! grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||
# prefix already exists. A failure of this check must NOT block creation.
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||
# interpolate the counterexample into JSON).
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
curl -sS -X POST \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @payload.json
|
||||
|
||||
# An INFRA failure (OOM, tsc, install) has NO counterexample signature. File
|
||||
# it under a DISTINCT title so it is visible but keeps the counterexample
|
||||
# dedup (above) uncontaminated — a real counterexample can still file even
|
||||
# while an infra issue is open.
|
||||
- name: File infra failure issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property run infra failure'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Only file when there is NO counterexample signature (else the
|
||||
# counterexample step owns it).
|
||||
if grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "Counterexample present — owned by the counterexample step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed WITHOUT a fast-check counterexample (infra failure: OOM / build / install). This is NOT a converter round-trip bug.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nInvestigate the run log (memory, dependency install, or a tsc/import error). The nightly counterexample dedup is intentionally separate from this issue.\n\nTail of the test output:\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
curl -sS -X POST \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @payload.json
|
||||
@@ -64,8 +64,3 @@ lerna-debug.log*
|
||||
|
||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||
apps/client/public/vad/
|
||||
|
||||
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
|
||||
/ios
|
||||
/android
|
||||
.capacitor
|
||||
|
||||
@@ -230,6 +230,24 @@ pnpm build # nx run-many -t build (all packages)
|
||||
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
||||
```
|
||||
|
||||
> **Build the shared packages before running a consumer's `tsc`/tests in
|
||||
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
|
||||
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
|
||||
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
|
||||
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
|
||||
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
|
||||
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
|
||||
> built first:
|
||||
> ```bash
|
||||
> pnpm --filter @docmost/prosemirror-markdown build
|
||||
> pnpm --filter @docmost/editor-ext build
|
||||
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
> ```
|
||||
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
|
||||
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
|
||||
> that order). Reach for it whenever you run a consumer package's checks on their
|
||||
> own rather than through the full `pnpm build`.
|
||||
|
||||
**Lint** (per package — there is no root lint script):
|
||||
```bash
|
||||
pnpm --filter server lint # eslint --fix on server .ts
|
||||
@@ -293,7 +311,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, 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.
|
||||
- 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. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- 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`.
|
||||
|
||||
|
||||
@@ -166,32 +166,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
JSON-compatible schema (no custom tags / no code execution) behind the same
|
||||
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
||||
base-URL contract is unchanged. (#229)
|
||||
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||
separately-hosted cross-domain client must now be listed in
|
||||
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||
REST/browser client is now rejected until its origin is added to
|
||||
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -125,6 +125,32 @@ Gitmost follows the upstream Docmost setup. See the Docmost
|
||||
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
||||
|
||||
### Reverse proxy: SSE streaming paths
|
||||
|
||||
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
|
||||
long-lived `text/event-stream` response and **must bypass response buffering AND response
|
||||
compression** at every proxy in front of the app:
|
||||
|
||||
- `POST /api/ai-chat/stream` — the live agent turn stream
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
|
||||
|
||||
A buffering or compressing proxy does not break these with an error — it silently ruins them:
|
||||
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
|
||||
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
|
||||
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
|
||||
|
||||
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
|
||||
compression middleware is applied by proxy configuration, not headers:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
|
||||
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — route these paths through a dedicated router **without** the `compress`
|
||||
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Migration from Docmost
|
||||
|
||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
||||
|
||||
@@ -126,6 +126,32 @@ Gitmost повторяет процесс установки upstream-Docmost.
|
||||
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
||||
|
||||
### Reverse proxy: SSE-стриминговые пути
|
||||
|
||||
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
|
||||
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
|
||||
прокси перед приложением:
|
||||
|
||||
- `POST /api/ai-chat/stream` — живой стрим хода агента
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
|
||||
|
||||
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
|
||||
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
|
||||
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
|
||||
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
|
||||
|
||||
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
|
||||
compression-мидлвари управляются конфигом прокси, а не заголовками:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
|
||||
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
|
||||
(compress буферизует SSE-кадры до закрытия ответа), например
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Миграция с Docmost
|
||||
|
||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||
|
||||
@@ -23,18 +23,33 @@ roles:
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||
full: it defines the volume of the research, so keep searching until it is
|
||||
used up. If the user gave no number, estimate one yourself from the task's
|
||||
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||
more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
- Reuse the current/already-open document ONLY if either (a) the user
|
||||
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||
it AND its title matches the topic of the research. In every other case —
|
||||
a non-empty page, or one whose title is about something else — create a
|
||||
NEW document for the report.
|
||||
- Set up this document at the VERY START — right after the plan (STEP 0) and
|
||||
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||
and a skeleton of the sections you expect to fill.
|
||||
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||
write it in straight away (fact → source → reliability assessment) and
|
||||
grow or reshape the structure as your understanding evolves.
|
||||
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||
from the first minute and be updated continuously throughout the run, so
|
||||
that by the finalization stage it is already almost complete and only
|
||||
needs cleanup, ordering, and self-verification.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
@@ -53,9 +68,19 @@ roles:
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||
only when further searches stop yielding new relevant information
|
||||
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||
you get tired.
|
||||
|
||||
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||
reach: spend it in full even past the point where the topic already feels
|
||||
covered. Do not treat apparent saturation as permission to stop early —
|
||||
instead put the remaining searches to real use: broaden the scope, go
|
||||
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||
facts from independent angles. Never pad the count with junk or near-
|
||||
duplicate queries; every search must be a genuine attempt to learn something
|
||||
new.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
|
||||
@@ -23,18 +23,33 @@ roles:
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||
full: it defines the volume of the research, so keep searching until it is
|
||||
used up. If the user gave no number, estimate one yourself from the task's
|
||||
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||
more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
- Reuse the current/already-open document ONLY if either (a) the user
|
||||
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||
it AND its title matches the topic of the research. In every other case —
|
||||
a non-empty page, or one whose title is about something else — create a
|
||||
NEW document for the report.
|
||||
- Set up this document at the VERY START — right after the plan (STEP 0) and
|
||||
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||
and a skeleton of the sections you expect to fill.
|
||||
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||
write it in straight away (fact → source → reliability assessment) and
|
||||
grow or reshape the structure as your understanding evolves.
|
||||
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||
from the first minute and be updated continuously throughout the run, so
|
||||
that by the finalization stage it is already almost complete and only
|
||||
needs cleanup, ordering, and self-verification.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
@@ -53,9 +68,19 @@ roles:
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||
only when further searches stop yielding new relevant information
|
||||
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||
you get tired.
|
||||
|
||||
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||
reach: spend it in full even past the point where the topic already feels
|
||||
covered. Do not treat apparent saturation as permission to stop early —
|
||||
instead put the remaining searches to real use: broaden the scope, go
|
||||
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||
facts from independent angles. Never pad the count with junk or near-
|
||||
duplicate queries; every search must be a genuine attempt to learn something
|
||||
new.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
|
||||
@@ -33,4 +33,4 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 1
|
||||
version: 4
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
"version": 4,
|
||||
"hash": "9446ec6d2c8a6ec548358537ac392b8bf9b4d2a832ebb105d5514eac2c76da74"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 4,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/query-async-storage-persister": "5.90.17",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query-persist-client": "5.90.17",
|
||||
"@tanstack/react-virtual": "3.13.24",
|
||||
"ai": "6.0.207",
|
||||
"alfaaz": "1.1.0",
|
||||
@@ -48,7 +46,6 @@
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"idb-keyval": "6.2.5",
|
||||
"jotai": "2.18.1",
|
||||
"jotai-optics": "0.4.0",
|
||||
"js-cookie": "3.0.7",
|
||||
@@ -101,7 +98,6 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,18 +471,6 @@
|
||||
"Move page": "Move page",
|
||||
"Move page to a different space.": "Move page to a different space.",
|
||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||
"Syncing changes…": "Syncing changes…",
|
||||
"All changes synced": "All changes synced",
|
||||
"Update available": "Update available",
|
||||
"Reload": "Reload",
|
||||
"Make available offline": "Make available offline",
|
||||
"Saving page for offline use...": "Saving page for offline use...",
|
||||
"Page is now available offline": "Page is now available offline",
|
||||
"Failed to make page available offline": "Failed to make page available offline",
|
||||
"You're offline": "You're offline",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||
"Retry": "Retry",
|
||||
"Table of contents": "Table of contents",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||
"Share": "Share",
|
||||
|
||||
@@ -487,18 +487,6 @@
|
||||
"Move page": "Переместить страницу",
|
||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||
"Syncing changes…": "Синхронизация изменений…",
|
||||
"All changes synced": "Все изменения синхронизированы",
|
||||
"Update available": "Доступно обновление",
|
||||
"Reload": "Перезагрузить",
|
||||
"Make available offline": "Сделать доступным офлайн",
|
||||
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||
"Page is now available offline": "Страница доступна офлайн",
|
||||
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||
"You're offline": "Вы офлайн",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||
"Retry": "Повторить",
|
||||
"Table of contents": "Оглавление",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||
"Share": "Поделиться",
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Gitmost",
|
||||
"short_name": "Gitmost",
|
||||
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||
"lang": "en",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0E1117",
|
||||
"theme_color": "#0E1117",
|
||||
"icons": [
|
||||
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||
{
|
||||
"src": "icons/favicon-16x16.png",
|
||||
"type": "image/png",
|
||||
"sizes": "16x16"
|
||||
},
|
||||
{
|
||||
"src": "icons/favicon-32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180 192x192"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,21 +37,22 @@ import {
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
getEditorSelectionContext,
|
||||
type EditorSelectionContext,
|
||||
} from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
AI_CHAT_RUN_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatRunQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
shouldClearLatchOnQueryError,
|
||||
shouldClearStoppingLatch,
|
||||
shouldObserveRun,
|
||||
} from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
@@ -85,6 +86,12 @@ const MIN_HEIGHT = 400;
|
||||
// Margin kept between the window and the viewport edges while dragging.
|
||||
const EDGE_MARGIN = 8;
|
||||
|
||||
// #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when
|
||||
// a resume attempt could not attach to the live run and disarmed by the thread on
|
||||
// settle / local stream; this cap is the ONLY backstop against an endless tick
|
||||
// (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run).
|
||||
const DEGRADED_POLL_MAX_MS = 10 * 60_000;
|
||||
|
||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
@@ -242,150 +249,62 @@ export default function AiChatWindow() {
|
||||
[roles],
|
||||
);
|
||||
|
||||
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
||||
// ChatThread could not attach to a still-running run it arms this via
|
||||
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
||||
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||
const armedAtRef = useRef(0);
|
||||
const onResumeFallback = useCallback((active: boolean): void => {
|
||||
if (active) armedAtRef.current = Date.now();
|
||||
setDegradedPoll(active);
|
||||
}, []);
|
||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||
// resume attempt of the previously-open chat (invariant 8).
|
||||
useEffect(() => {
|
||||
setDegradedPoll(false);
|
||||
}, [activeChatId]);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
useAiChatMessagesQuery(
|
||||
activeChatId ?? undefined,
|
||||
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
||||
// and under the 10-min cap; otherwise off. NO error checks (TanStack v5
|
||||
// resets fetchFailureCount each fetch, so consecutive errors are not
|
||||
// expressible — and the poll must survive a server restart) and NO tail
|
||||
// checks (the settled/local-stream semantics live in ChatThread, which
|
||||
// disarms via onResumeFallback(false)). The time cap is the only backstop.
|
||||
() =>
|
||||
degradedPoll === true &&
|
||||
Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS
|
||||
? 2500
|
||||
: false,
|
||||
);
|
||||
|
||||
// #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.
|
||||
// this workspace. When the feature is off no runs are ever created, so the
|
||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||
// pointless attach round-trip.
|
||||
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.
|
||||
// autonomous mode). 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 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],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||
// already has the live SSE — polling/merging too would double-render). The
|
||||
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
|
||||
activeChatId ?? undefined,
|
||||
autonomousRunsEnabled && !localStreaming,
|
||||
);
|
||||
const run = runData?.run ?? null;
|
||||
|
||||
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
|
||||
// `run` is null until the current turn's run is fetched fresh, and the terminal
|
||||
// effect below holds the latch via `if (!run) return`. If that refetch instead
|
||||
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
|
||||
// streamer, the run stays null, its status-keyed refetchInterval is off, and
|
||||
// nothing would ever observe a terminal run — freezing the view with the
|
||||
// observer merge suppressed. Release the latch on that error so the live view
|
||||
// resumes rather than stays stuck (the local stopRun may already have succeeded
|
||||
// independently).
|
||||
//
|
||||
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
|
||||
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
|
||||
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
|
||||
// pending/running — releasing then would re-open the observer merge and flash
|
||||
// the growing detached run over the frozen row (the very flash F4 prevents). The
|
||||
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
|
||||
// on the run NOT being active: it cures only the genuine permanent-null-freeze
|
||||
// (`run === null`) and never releases against an active run.
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun,
|
||||
isLocalStreaming: localStreaming,
|
||||
runQueryFailed,
|
||||
run,
|
||||
})
|
||||
)
|
||||
setStoppingRun(false);
|
||||
}, [stoppingRun, localStreaming, runQueryFailed, run]);
|
||||
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||
// but only while we are an observer (never when we are the streamer — guards
|
||||
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||
// final persisted output is shown on reopen.
|
||||
const observedRow =
|
||||
shouldObserveRun(run, localStreaming) && !stoppingRun
|
||||
? (runData?.message ?? null)
|
||||
: null;
|
||||
|
||||
// When the observed run reaches a terminal status, do a final messages refetch
|
||||
// so the persisted final state (token/context badge, export source) is shown,
|
||||
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||
const finalizedRunIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!run || !activeChatId) return;
|
||||
if (run.status === "pending" || run.status === "running") {
|
||||
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||
finalizedRunIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Terminal: a stop we requested has landed (or the run finished on its own),
|
||||
// so release the stopping latch — the observer merge can now show the final
|
||||
// persisted (aborted/finished) output without any live re-stream. The decision
|
||||
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
|
||||
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
|
||||
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
|
||||
// `run` null (this branch's `if (!run) return` above holds) until the current
|
||||
// turn's run is fetched fresh, so the latch can never clear against a stale
|
||||
// cached run.
|
||||
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
|
||||
setStoppingRun(false);
|
||||
if (finalizedRunIdRef.current === run.id) return;
|
||||
finalizedRunIdRef.current = run.id;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -403,6 +322,27 @@ export default function AiChatWindow() {
|
||||
? { id: openPageData.id, title: openPageData.title }
|
||||
: null;
|
||||
|
||||
// Live editor handles for the selection snapshot (#388). Both are published by
|
||||
// the page editor; the read-only editor is used in read mode. Reading the
|
||||
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
|
||||
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||
|
||||
// Snapshot the user's current editor selection at send time. Edit-mode editor
|
||||
// wins; the read-only editor is the fallback (read mode). Null when neither
|
||||
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
|
||||
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
|
||||
// snapshot and multi-turn works without recreating the transport.
|
||||
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
|
||||
for (const editor of [pageEditor, readOnlyEditor]) {
|
||||
if (!editor || editor.isDestroyed) continue;
|
||||
const sel = getEditorSelectionContext(editor.state);
|
||||
if (sel) return sel;
|
||||
}
|
||||
return null;
|
||||
}, [pageEditor, readOnlyEditor]);
|
||||
|
||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||
@@ -1025,6 +965,9 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// #388: live snapshotter for the user's editor selection, read at
|
||||
// send time and nested inside openPage on the wire.
|
||||
getEditorSelection={getEditorSelection}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
@@ -1034,16 +977,13 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
// #184: live-follow a still-running run when we reopened the chat as
|
||||
// a passive observer; null when there is nothing to observe or this
|
||||
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||
// while we are the streamer.
|
||||
observedRow={observedRow}
|
||||
onStreamingChange={onStreamingChange}
|
||||
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
|
||||
// resume attempt could not attach to the live run; the thread
|
||||
// disarms it on settle / local stream.
|
||||
onResumeFallback={onResumeFallback}
|
||||
// #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.
|
||||
// ignores).
|
||||
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||
onServerStop={handleServerStop}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
@@ -12,50 +19,61 @@ const h = vi.hoisted(() => ({
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
resumeStream: vi.fn(),
|
||||
// The messages array useChat was seeded with (to assert strip/seed behavior).
|
||||
seededMessages: null as null | unknown[],
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
prepareSendMessagesRequest?: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
prepareReconnectToStreamRequest?: () => { api?: string };
|
||||
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
// Mock useChat: capture onFinish + seeded messages, return the spies and the
|
||||
// controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
useChat: (opts: {
|
||||
messages?: unknown[];
|
||||
onFinish?: (arg: Record<string, unknown>) => void;
|
||||
}) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
h.state.seededMessages = opts.messages ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||
setMessages: h.state.setMessages,
|
||||
resumeStream: h.state.resumeStream,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
// can invoke prepareSendMessagesRequest / prepareReconnectToStreamRequest / fetch.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
h.state.transport = opts as never;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Keep the ai-chat-query import light: ChatThread only needs the messages RQ key,
|
||||
// so stub the module to avoid pulling axios / i18n transitively.
|
||||
vi.mock("@/features/ai-chat/queries/ai-chat-query.ts", () => ({
|
||||
AI_CHAT_MESSAGES_RQ_KEY: (chatId: string) => ["ai-chat-messages", chatId],
|
||||
}));
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
@@ -63,49 +81,90 @@ vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
default: ({
|
||||
onQueue,
|
||||
onStop,
|
||||
}: {
|
||||
onQueue: (text: string) => void;
|
||||
onStop: () => void;
|
||||
}) => (
|
||||
<>
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
<button aria-label="Stop" onClick={() => onStop()}>
|
||||
stop
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
function renderThread() {
|
||||
function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
text = "",
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: text, status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
function renderThread(props?: {
|
||||
chatId?: string | null;
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
autonomousRunsEnabled?: boolean;
|
||||
}) {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
const onResumeFallback = vi.fn();
|
||||
const onServerStop = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const { unmount } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId={props?.chatId === undefined ? "c1" : props.chatId}
|
||||
initialRows={props?.initialRows ?? []}
|
||||
autonomousRunsEnabled={props?.autonomousRunsEnabled}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onResumeFallback={onResumeFallback}
|
||||
onServerStop={onServerStop}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
return { onTurnFinished, onResumeFallback, onServerStop, invalidateSpy, unmount };
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.seededMessages = null;
|
||||
h.state.transport = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.setMessages.mockClear();
|
||||
h.state.resumeStream.mockClear();
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
beforeEach(resetState);
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
@@ -122,10 +181,8 @@ describe("ChatThread — send now (#198)", () => {
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
@@ -136,42 +193,92 @@ describe("ChatThread — send now (#198)", () => {
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||
// the queue intact for the user), and which stop notice — if any — to show.
|
||||
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
// #388: the editor selection is snapshotted at send time and nested inside
|
||||
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||
// fresh snapshot.
|
||||
describe("ChatThread — editor selection wiring (#388)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSelection(props: {
|
||||
openPage?: { id: string; title: string } | null;
|
||||
getEditorSelection?: () => unknown;
|
||||
}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
openPage={props.openPage as never}
|
||||
getEditorSelection={props.getEditorSelection as never}
|
||||
onTurnFinished={vi.fn()}
|
||||
onResumeFallback={vi.fn()}
|
||||
onServerStop={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("nests the snapshot from the getter into openPage.selection at send time", () => {
|
||||
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => selection,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
|
||||
});
|
||||
|
||||
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||
// message, and report both what the parent was told and whether the queue was
|
||||
// flushed (a resend to the sendMessage spy).
|
||||
it("sends selection: null when the getter returns null", () => {
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => null,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
|
||||
});
|
||||
|
||||
it("does not send selection at all on a non-page route (openPage null)", () => {
|
||||
const getter = vi.fn(() => ({ text: "sel" }));
|
||||
renderWithSelection({ openPage: null, getEditorSelection: getter });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
|
||||
// The getter must not even be consulted when there is no page.
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(resetState);
|
||||
|
||||
function finishWith(flags: {
|
||||
isAbort?: boolean;
|
||||
isDisconnect?: boolean;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||
// not leave duplicate queue buttons in the DOM.
|
||||
cleanup();
|
||||
h.state.sendMessage.mockClear();
|
||||
const { onTurnFinished } = renderThread();
|
||||
// Populate the queue while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
@@ -187,16 +294,12 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
|
||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||
finishWith({});
|
||||
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
// A clean finish shows no stop notice.
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||
finishWith({ isAbort: true });
|
||||
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||
// queue is preserved for the user to decide.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||
});
|
||||
@@ -211,15 +314,11 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
|
||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||
finishWith({ isError: true });
|
||||
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
// isError clears the neutral notice (the error banner covers this case).
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the parent on EVERY terminal outcome", () => {
|
||||
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||
// every failure path alike.
|
||||
for (const flags of [
|
||||
{},
|
||||
{ isAbort: true },
|
||||
@@ -232,55 +331,411 @@ 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();
|
||||
// #184 phase 1.5: the resumable-SSE client. A reopened tab resumes the live run
|
||||
// via the SDK's reconnect transport (attach: replay + tail) instead of polling.
|
||||
describe("ChatThread — resume (attach) machinery (#184)", () => {
|
||||
const streamingTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "streaming", "partial"),
|
||||
];
|
||||
const settledTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "succeeded", "done"),
|
||||
];
|
||||
const userTail = () => [row("u1", "user", undefined, "hi")];
|
||||
|
||||
const visibleMsg = {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "streamed answer" }],
|
||||
};
|
||||
const emptyMsg = { id: "a1", role: "assistant", parts: [] };
|
||||
|
||||
beforeEach(resetState);
|
||||
// NOTE: do NOT vi.unstubAllGlobals() here — vitest.setup.ts installs
|
||||
// matchMedia/localStorage via vi.stubGlobal and unstubbing wipes them for the
|
||||
// rest of the file. Fetch is re-stubbed per test that needs it.
|
||||
afterEach(cleanup);
|
||||
|
||||
it("resumes on mount only when the flag is on, chatId is set, and the tail is not a settled assistant", () => {
|
||||
// streaming tail -> resume
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
// user tail -> resume (the assistant row may not be seeded yet)
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
// settled assistant tail -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
|
||||
// flag off -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: false, initialRows: streamingTail() });
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
|
||||
// no chatId -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
chatId: null,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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;
|
||||
it("strips the streaming tail from the seed, but keeps a user tail whole", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
// 2 rows in, streaming tail stripped -> 1 seeded message.
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
|
||||
function renderObserver(status: string) {
|
||||
h.state.status = status;
|
||||
render(
|
||||
cleanup();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
// user tail is not stripped.
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("builds the attach URL with expect=live&anchor only when the streaming tail was stripped", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
||||
);
|
||||
|
||||
cleanup();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream",
|
||||
);
|
||||
});
|
||||
|
||||
async function fetch204() {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
}
|
||||
|
||||
it("204 on a user tail: no crash, no restore, reconcile+invalidate, onResumeFallback(true)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: userTail(),
|
||||
});
|
||||
await fetch204();
|
||||
// No stripped row -> no restore merge.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
await fetch204();
|
||||
// Stripped row is restored to the store.
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a 500 attach failure restores the stripped row AND arms the poll (not lost)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // stripped row restored
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // degraded poll armed
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a network throw restores the stripped row AND arms the poll", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new Error("network down")),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state
|
||||
.transport!.fetch!("http://x", { method: "GET" })
|
||||
.catch(() => undefined); // the wrapper rethrows; swallow here
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("unmount during a pending attach aborts the controller and gates late callbacks", async () => {
|
||||
const { onResumeFallback, invalidateSpy, unmount } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
let abortSeen = false;
|
||||
let resolveFetch!: (v: unknown) => void;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
abortSeen = true;
|
||||
});
|
||||
return new Promise((res) => {
|
||||
resolveFetch = res;
|
||||
});
|
||||
}),
|
||||
);
|
||||
// Kick a reconnect GET (stays pending).
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => {
|
||||
pending = h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
// Unmount: the cleanup aborts the in-flight attach.
|
||||
unmount();
|
||||
expect(abortSeen).toBe(true);
|
||||
// A late 204 landing after unmount must NOT arm a poll / invalidate the (now
|
||||
// different) chat.
|
||||
onResumeFallback.mockClear();
|
||||
invalidateSpy.mockClear();
|
||||
await act(async () => {
|
||||
resolveFetch({ status: 204, ok: false });
|
||||
await pending;
|
||||
});
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a resume fetch error clears resumedTurn so the next local turn flushes the queue", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
h.state.status = "ready";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
// Queue then clean-finish: suppression was cleared, so the queue flushes.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("a resumed turn's onFinish does NOT flush the queue", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a healthy resumed finish (visible content) arms nothing and keeps the store", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// No restore (would clobber the fuller streamed message), no poll arm.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("isDisconnect WITH visible content arms the poll but does NOT restore", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
invalidateSpy.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
// Restore forbidden: the on-screen partial must not roll back.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an empty resumed message (starved replay) restores the stripped row AND arms the poll", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: emptyMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
||||
});
|
||||
|
||||
it("degraded-merge: merges the tail per initialRows update, and settles disarm the poll", async () => {
|
||||
h.state.status = "ready";
|
||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||
// Arm reconcile via a 204.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
|
||||
// A streaming-tail update: merge, poll stays armed.
|
||||
rerender([
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "streaming", "step 1\nstep 2"),
|
||||
]);
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(false);
|
||||
|
||||
// A settled-tail update: merge + disarm.
|
||||
h.state.setMessages.mockClear();
|
||||
rerender([
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "succeeded", "final"),
|
||||
]);
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("a local stream disarms both the merge and the poll", () => {
|
||||
h.state.status = "streaming";
|
||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||
onResumeFallback.mockClear();
|
||||
// A re-render while streaming: the reconciliation effect disarms.
|
||||
rerender(streamingTail());
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Send now is hidden on a resumed turn but visible on a local stream", () => {
|
||||
// Resumed turn: hidden.
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
expect(screen.queryByLabelText("Send now")).toBeNull();
|
||||
|
||||
// Local streaming turn (no resume): visible.
|
||||
cleanup();
|
||||
resetState();
|
||||
renderThread({ initialRows: [] });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
expect(screen.getByLabelText("Send now")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handleStop aborts the attach controller and calls onServerStop", async () => {
|
||||
const { onServerStop } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
// Establish an attach controller via a (pending) reconnect GET.
|
||||
let abortSeen = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
abortSeen = true;
|
||||
});
|
||||
return new Promise(() => undefined); // never resolves
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
void h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Stop"));
|
||||
expect(abortSeen).toBe(true);
|
||||
expect(onServerStop).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: render a resumable thread and expose a rerender that only swaps
|
||||
// initialRows (the degraded-merge effect depends on it).
|
||||
function renderResumable(initialRows: IAiChatMessageRow[]) {
|
||||
const onResumeFallback = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const Wrapper = ({ rows }: { rows: IAiChatMessageRow[] }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
initialRows={rows}
|
||||
autonomousRunsEnabled
|
||||
onTurnFinished={vi.fn()}
|
||||
observedRow={observedRow as never}
|
||||
onResumeFallback={onResumeFallback}
|
||||
/>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(<Wrapper rows={initialRows} />);
|
||||
const rerender = (rows: IAiChatMessageRow[]) =>
|
||||
act(() => view.rerender(<Wrapper rows={rows} />));
|
||||
return { rerender, onResumeFallback };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -24,7 +25,15 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
mergeById,
|
||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -61,6 +70,10 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** #388: snapshot the user's current editor selection at SEND time. Invoked
|
||||
* inside prepareSendMessagesRequest and nested into openPage on the wire, so a
|
||||
* fresh snapshot ships each turn. Null/absent => nothing selected. */
|
||||
getEditorSelection?: () => EditorSelectionContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
@@ -87,19 +100,13 @@ interface ChatThreadProps {
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||
* persisted assistant message here; we merge it into the live list so new
|
||||
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||
* fight the local stream when we are the streamer. */
|
||||
observedRow?: IAiChatMessageRow | null;
|
||||
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||
* resume once we go idle. Called from an effect on every transition. */
|
||||
onStreamingChange?: (streaming: boolean) => void;
|
||||
/** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
|
||||
* chat's window. Called `true` when a resume attempt could not attach to the
|
||||
* live run (attach 204 / starved-or-torn resumed finish), so the window starts
|
||||
* a dumb timed poll of the message history to follow the detached run to settle;
|
||||
* called `false` the moment a local stream starts or the terminal settled row is
|
||||
* merged (invariant 8). The window owns the timer + its 10-min cap. */
|
||||
onResumeFallback?: (active: 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,
|
||||
@@ -149,21 +156,65 @@ export default function ChatThread({
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
getEditorSelection,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
observedRow,
|
||||
onStreamingChange,
|
||||
onResumeFallback,
|
||||
autonomousRunsEnabled,
|
||||
onServerStop,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// resume machinery refs (#184 phase 1.5)
|
||||
const attachAbortRef = useRef<AbortController | null>(null);
|
||||
const reconcileTailRef = useRef(false);
|
||||
const noStreamHandledRef = useRef(false);
|
||||
const onNoActiveStreamRef = useRef<(() => void) | null>(null);
|
||||
// Live mount flag. The attach GET and the resumed `onFinish` are async and can
|
||||
// land AFTER this thread unmounts (the parent remounts per chat via `key`); with
|
||||
// chatIdRef then pointing at the NEW chat, an ungated late callback would arm a
|
||||
// spurious poll + foreign invalidation on the newly-opened chat. Every parent-
|
||||
// facing resume side-effect is gated on this.
|
||||
const mountedRef = useRef(true);
|
||||
const [resumedTurn, setResumedTurn] = useState(false);
|
||||
const resumedTurnRef = useRef(false);
|
||||
// Identity-stable pair setter (bare useState setter + ref write): it is closed
|
||||
// over by the transport useMemo([]), so it MUST NOT capture state.
|
||||
const setResumedTurnPair = useCallback((v: boolean) => {
|
||||
resumedTurnRef.current = v;
|
||||
setResumedTurn(v);
|
||||
}, []);
|
||||
|
||||
// Mount-time resume gating (in refs — computed once for this mount; the parent
|
||||
// remounts per chat via `key`).
|
||||
//
|
||||
// Attempt resume for any non-settled tail: a streaming tail (strip + expect
|
||||
// live replay) or a user tail (the run may exist but its assistant row is not
|
||||
// seeded yet — attach to the pre-opened registry entry and wait for frames).
|
||||
// A settled assistant tail must NEVER resume: replaying a finished run into a
|
||||
// store that already contains its message duplicates parts (SDK text-start
|
||||
// always pushes a new part).
|
||||
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
||||
const attemptResumeRef = useRef(
|
||||
autonomousRunsEnabled === true &&
|
||||
chatId !== null &&
|
||||
!isSettledAssistantTail(initialRows ?? []),
|
||||
);
|
||||
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
||||
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
||||
);
|
||||
|
||||
const initialMessages = useMemo<UIMessage[]>(
|
||||
() => (initialRows ?? []).map(rowToUiMessage),
|
||||
() =>
|
||||
seedRows(
|
||||
initialRows ?? [],
|
||||
attemptResumeRef.current && stripRef.current,
|
||||
).map(rowToUiMessage),
|
||||
[initialRows],
|
||||
);
|
||||
|
||||
@@ -181,6 +232,14 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selection snapshotter in a ref, same rationale as openPageRef: the
|
||||
// transport useMemo([]) closes it over, so prop-identity churn must not matter.
|
||||
// Called at send time inside prepareSendMessagesRequest (#388).
|
||||
const getEditorSelectionRef = useRef<
|
||||
(() => EditorSelectionContext | null) | undefined
|
||||
>(getEditorSelection);
|
||||
getEditorSelectionRef.current = getEditorSelection;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
@@ -261,9 +320,12 @@ export default function ChatThread({
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
// Local send: clear any resume-suppression flag so this genuine local turn's
|
||||
// onFinish flushes normally (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
}, [setQueue, setResumedTurnPair]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(text: string) => {
|
||||
@@ -283,6 +345,47 @@ export default function ChatThread({
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/ai-chat/stream",
|
||||
credentials: "include",
|
||||
prepareReconnectToStreamRequest: () => ({
|
||||
// SDK default URL uses the useChat STORE id — always build from the real chat id.
|
||||
// ?expect=live&anchor=<row id> ONLY when we stripped a streaming tail: expect=live
|
||||
// is the only case where a finished-retained replay is safe (the row is stripped,
|
||||
// replay rebuilds it), and the anchor pins the replay to OUR run — a mismatching
|
||||
// (newer) run must 204 into the restore+poll path instead of replaying a foreign
|
||||
// transcript into this store.
|
||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||
stripRef.current
|
||||
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
||||
: ""
|
||||
}`,
|
||||
}),
|
||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
|
||||
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
|
||||
// for observer Stop / unmount abort.
|
||||
const controller = new AbortController();
|
||||
attachAbortRef.current = controller;
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// No onFinish will come for a 204 (silent no-op) OR any non-2xx
|
||||
// (5xx/502 — a server restart mid-attach). Both run the same
|
||||
// no-active-stream recovery: restore the stripped row, invalidate, and
|
||||
// arm the degraded poll (idempotent via noStreamHandledRef; its part-d
|
||||
// also clears the resumedTurn flag). This is the restart-survival path
|
||||
// the removed F7 latch used to guard — a transient attach failure must
|
||||
// NOT drop the in-progress row or stop tracking the durable run.
|
||||
if (response.status === 204 || !response.ok)
|
||||
onNoActiveStreamRef.current?.();
|
||||
return response;
|
||||
} catch (err) {
|
||||
// Network throw: same no-onFinish recovery, then rethrow so the SDK
|
||||
// still surfaces the error to its own machinery.
|
||||
onNoActiveStreamRef.current?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
// Inject the chat id and the currently-open page alongside the useChat
|
||||
// messages so the server can resolve an existing chat (or create one
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
@@ -299,7 +402,16 @@ export default function ChatThread({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Attach the live editor selection to the open-page context at send
|
||||
// time — "this"/"here" in the user's message means THIS selection.
|
||||
// Nested inside openPage so it dies with the page when the server
|
||||
// rejects the page id (#388). Null when nothing is selected.
|
||||
openPage: openPageRef.current
|
||||
? {
|
||||
...openPageRef.current,
|
||||
selection: getEditorSelectionRef.current?.() ?? null,
|
||||
}
|
||||
: null,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
@@ -312,7 +424,15 @@ export default function ChatThread({
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
error,
|
||||
setMessages,
|
||||
resumeStream,
|
||||
} = useChat({
|
||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||
// every render mid-stream (see `chatStoreId` above).
|
||||
@@ -330,6 +450,38 @@ export default function ChatThread({
|
||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||
// the user to decide.
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// (1) Capture whether THIS finish belongs to a resumed (attach) turn and
|
||||
// immediately clear the flag so it can never suppress a LATER local turn.
|
||||
const wasResumed = resumedTurnRef.current;
|
||||
setResumedTurnPair(false);
|
||||
// (2) Recovery after a starved/torn resumed finish (invariant 9). The arm
|
||||
// and the stripped-row restore are gated DIFFERENTLY. Skip entirely once
|
||||
// unmounted (an abort-triggered onFinish landing after a chat switch must
|
||||
// not arm a poll / invalidate on the new chat).
|
||||
if (wasResumed && mountedRef.current) {
|
||||
const hasVisibleContent = assistantMessageHasVisibleContent(message);
|
||||
// ARM the reconcile + degraded poll when the resumed message carries no
|
||||
// visible content (starved replay) OR the connection dropped mid-run — in
|
||||
// both cases the poll must drive the row to its real terminal state.
|
||||
if (isDisconnect || !hasVisibleContent) {
|
||||
reconcileTailRef.current = true;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
onResumeFallback?.(true);
|
||||
}
|
||||
// RESTORE the stripped streaming row ONLY when the resumed message has no
|
||||
// visible content. On isDisconnect WITH visible content restore is
|
||||
// FORBIDDEN: the live stream may have advanced far past the mount-time
|
||||
// snapshot, so restoring would clobber on-screen content (invariant 9) —
|
||||
// the arm above suffices, the poll reaches the true terminal.
|
||||
if (!hasVisibleContent && strippedRowRef.current) {
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// (3) Standard branches.
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
@@ -342,6 +494,10 @@ export default function ChatThread({
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
|
||||
// flush-on-abort branch and the plain flush. The local streamer is the only
|
||||
// tab that owns the queue.
|
||||
if (wasResumed) return;
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
@@ -423,26 +579,98 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// #184: report our live streaming status up so the parent stops polling the run
|
||||
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||
// Effect (not render) so it never updates parent state during our own render;
|
||||
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
// 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to
|
||||
// resume (overflow / begin-failure / after retention / anchor-mismatch). One-
|
||||
// shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four
|
||||
// parts. Kept in a ref (read by the transport's fetch closure) and refreshed
|
||||
// each render below.
|
||||
const onNoActiveStream = useCallback(() => {
|
||||
// A late attach outcome after unmount must not arm a poll / invalidate on the
|
||||
// now-different chat this thread's refs were reused for.
|
||||
if (!mountedRef.current) return;
|
||||
if (noStreamHandledRef.current) return;
|
||||
noStreamHandledRef.current = true;
|
||||
// (a) Restore the stripped streaming row to the store — ONLY when we actually
|
||||
// stripped one (a user-tail 204 does NOT reach here with a stripped row, so do
|
||||
// not dereference null).
|
||||
if (strippedRowRef.current) {
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
}
|
||||
// (b) Reconcile the tail from the message history + invalidate it so the
|
||||
// degraded poll starts from a fresh fetch.
|
||||
reconcileTailRef.current = true;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
// (c) Arm the degraded poll (a dumb timer with a 10-min cap in the window);
|
||||
// the thread disarms it via onResumeFallback(false) on settle / local stream.
|
||||
onResumeFallback?.(true);
|
||||
// (d) 204 means onFinish will NOT fire — clear the suppression flag so it
|
||||
// cannot swallow the NEXT local turn's queue flush.
|
||||
setResumedTurnPair(false);
|
||||
}, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]);
|
||||
onNoActiveStreamRef.current = onNoActiveStream;
|
||||
|
||||
// #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.
|
||||
// Mount effect: kick off the resume attempt for a non-settled tail. Marking the
|
||||
// turn as resumed BEFORE resumeStream so onFinish (invariant 7/8) sees it.
|
||||
useEffect(() => {
|
||||
if (isStreaming || !observedRow) return;
|
||||
const observed = rowToUiMessage(observedRow);
|
||||
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||
}, [observedRow, isStreaming, setMessages]);
|
||||
// Re-arm on (re)mount — StrictMode dev-mounts twice, and the cleanup below
|
||||
// flips this false between the two.
|
||||
mountedRef.current = true;
|
||||
if (attemptResumeRef.current) {
|
||||
setResumedTurnPair(true);
|
||||
void resumeStream();
|
||||
}
|
||||
// Unmount: mark unmounted (gates late attach/onFinish side-effects) and abort
|
||||
// the in-flight attach GET so its callbacks don't fire against the next chat.
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
attachAbortRef.current?.abort();
|
||||
};
|
||||
// Mount-only by design; the parent remounts per chat via `key`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Reconciliation + degraded-merge (invariant 8). Deps are EXACTLY
|
||||
// [initialRows, isStreaming, setMessages].
|
||||
useEffect(() => {
|
||||
// A local stream owns the view: disarm BOTH the merge and the window poll.
|
||||
if (isStreaming) {
|
||||
reconcileTailRef.current = false;
|
||||
onResumeFallback?.(false);
|
||||
return;
|
||||
}
|
||||
if (!reconcileTailRef.current) return;
|
||||
const rows = initialRows ?? [];
|
||||
const tail = rows[rows.length - 1];
|
||||
if (!tail || tail.role !== "assistant") return;
|
||||
// Merge the polled assistant tail on EVERY initialRows update — while the
|
||||
// degraded poll is active this IS the live per-step progress.
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
||||
// Anchor-mismatch coherence: when we restored a stripped streaming row A but a
|
||||
// DIFFERENT run's row B is now the tail (A finished, B replaced the registry
|
||||
// entry, so the attach 204'd), A would otherwise linger forever as an orphan
|
||||
// jumping-dots row over the real run. Settle it from fresh history (where A is
|
||||
// now persisted) so no phantom row survives. No-op in the common case where A
|
||||
// IS the tail (id match).
|
||||
const stripped = strippedRowRef.current;
|
||||
if (stripped && stripped.id !== tail.id) {
|
||||
const historical = rows.find((r) => r.id === stripped.id);
|
||||
if (historical)
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
||||
}
|
||||
// Settled: the terminal merge is done — disarm the flag AND the window poll
|
||||
// explicitly (the window only has a time cap, it will not disarm itself).
|
||||
if (tail.status !== "streaming") {
|
||||
reconcileTailRef.current = false;
|
||||
onResumeFallback?.(false);
|
||||
}
|
||||
// onResumeFallback intentionally omitted (parent-stable callback); deps are
|
||||
// fixed by the resume design.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialRows, isStreaming, setMessages]);
|
||||
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
@@ -469,10 +697,12 @@ export default function ChatThread({
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
// Local send: clear any resume-suppression flag (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
[setQueue, stop, setResumedTurnPair],
|
||||
);
|
||||
|
||||
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||
@@ -485,6 +715,9 @@ export default function ChatThread({
|
||||
// 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(() => {
|
||||
// Abort the resume/attach GET first: the SDK does not pass it a signal, so an
|
||||
// observer's Stop would otherwise leave the attach fetch running.
|
||||
attachAbortRef.current?.abort();
|
||||
stop();
|
||||
if (!autonomousRunsEnabled) return;
|
||||
if (chatIdRef.current) {
|
||||
@@ -617,17 +850,23 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
|
||||
stop() does not abort the resumed attach fetch, so the click
|
||||
would be swallowed while flushOnAbortRef would fire minutes
|
||||
later on the natural finish. Only the remove affordance stays. */}
|
||||
{!resumedTurn && (
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -642,7 +881,11 @@ export default function ChatThread({
|
||||
</Stack>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onSend={(text) => {
|
||||
// Local send: clear any resume-suppression flag (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessage({ text });
|
||||
}}
|
||||
onQueue={enqueue}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
|
||||
@@ -40,6 +40,13 @@ interface MessageItemProps {
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render the one-line summary of
|
||||
* a call's arguments (e.g. the search query). Defaults to true (internal
|
||||
* chat). The public share passes false so an anonymous reader doesn't see the
|
||||
* agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
@@ -117,6 +124,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
@@ -210,6 +218,7 @@ function MessageItem({
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -274,6 +283,7 @@ export function arePropsEqual(
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.showInput === next.showInput &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName &&
|
||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||
|
||||
@@ -25,6 +25,13 @@ interface MessageListProps {
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render the
|
||||
* one-line summary of a call's arguments (e.g. the search query). Defaults to
|
||||
* true (internal chat). The public share passes false so an anonymous reader
|
||||
* doesn't see the agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
@@ -119,6 +126,7 @@ export default function MessageList({
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
@@ -208,6 +216,7 @@ export default function MessageList({
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getToolName,
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolLabelKey,
|
||||
toolRunState,
|
||||
ToolUiPart,
|
||||
@@ -21,6 +22,14 @@ interface ToolCallCardProps {
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Whether to render the one-line summary of the call's arguments (e.g. the
|
||||
* search query) under the label. Defaults to true (the internal chat). The
|
||||
* public share passes false: an anonymous reader should not see the agent's
|
||||
* raw query/argument text. Conservative and reversible — it only suppresses
|
||||
* the extra summary line, leaving the card (the action log) intact.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +40,14 @@ interface ToolCallCardProps {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
const inputSummary = showInput ? toolInputSummary(part) : undefined;
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
@@ -57,6 +68,12 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{inputSummary && (
|
||||
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
||||
{inputSummary}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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";
|
||||
|
||||
// react-i18next / notifications are pulled in transitively by ai-chat-query.ts
|
||||
// (the mutation hooks use them); stub so the module imports cleanly.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the service module; only getAiChatMessages is exercised, but the other
|
||||
// named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
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 { getAiChatMessages } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatMessagesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
const emptyPage = { items: [], meta: { hasNextPage: false, nextCursor: null } };
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// The degraded-poll fallback (#184 phase 1.5) is threaded into this query as a
|
||||
// `refetchInterval`; AiChatWindow supplies the deliberately-dumb callback. These
|
||||
// pin the plumbing the window depends on: the interval polls the message history,
|
||||
// and — critically — fetch ERRORS do NOT stop the tick (TanStack v5 resets the
|
||||
// failure count each fetch, so the poll must survive a server restart).
|
||||
describe("useAiChatMessagesQuery — degraded refetchInterval", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-polls at the interval while the callback returns a duration", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT re-poll when the callback returns false", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// Give any errant interval a chance to fire, then assert it did not.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps ticking through fetch errors (errors do not gate the poll)", async () => {
|
||||
vi.mocked(getAiChatMessages).mockRejectedValue(new Error("server down"));
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChatRun,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -37,7 +35,6 @@ import {
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
@@ -55,7 +52,6 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
];
|
||||
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
@@ -89,7 +85,15 @@ export function useAiChatsQuery() {
|
||||
* Load all persisted messages of a chat (oldest first), flattening the
|
||||
* paginated server response. Used to seed `useChat` initial messages.
|
||||
*/
|
||||
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
export function useAiChatMessagesQuery(
|
||||
chatId: string | undefined,
|
||||
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
||||
// still-running run (the attach returned 204 / the resumed stream ended with no
|
||||
// terminal row), the window arms a dumb timed poll of the message history to
|
||||
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
||||
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
||||
refetchInterval?: number | false | (() => number | false),
|
||||
) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
queryFn: ({ pageParam }) =>
|
||||
@@ -100,6 +104,7 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
@@ -139,34 +144,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||
*
|
||||
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||
* failed fetch can never spin a tight loop.
|
||||
*/
|
||||
export function useAiChatRunQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiChatRunResponse, Error>({
|
||||
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||
queryFn: () => getAiChatRun(chatId as string),
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameAiChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||
// use it); stub it so the module imports cleanly in this hook test.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatRun: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const runningResponse: IAiChatRunResponse = {
|
||||
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||
message: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "working...",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("useAiChatRunQuery — enable gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||
expect(result.current.data?.run?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
// Give any errant fetch a chance to fire, then assert none did.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fetch when there is no chat id", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -43,23 +42,6 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||
* persisted lifecycle state and the assistant message it materializes (the
|
||||
* partial output while the run is in-flight, the final output once it finished).
|
||||
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||
* runs, so the endpoint returns `{ run: null }`.
|
||||
*/
|
||||
export async function getAiChatRun(
|
||||
chatId: string,
|
||||
): Promise<IAiChatRunResponse> {
|
||||
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||
|
||||
@@ -210,41 +210,14 @@ export interface IAiChatMessageRow {
|
||||
// renders a "stopped" marker on interrupted turns.
|
||||
finishReason?: string;
|
||||
} | null;
|
||||
// Persisted lifecycle status of the row's turn, carried on the wire by
|
||||
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
|
||||
// the resume machinery to decide whether a tail is a live stream to attach to
|
||||
// or a settled row that must not be replayed).
|
||||
status?: 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 IAiChatMessagesParams {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
mergeById,
|
||||
} from "./resume-helpers.ts";
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isStreamingTail", () => {
|
||||
it("is true when the last row is a streaming assistant row", () => {
|
||||
expect(
|
||||
isStreamingTail([row("u1", "user"), row("a1", "assistant", "streaming")]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a settled assistant tail", () => {
|
||||
expect(isStreamingTail([row("a1", "assistant", "succeeded")])).toBe(false);
|
||||
expect(isStreamingTail([row("a1", "assistant")])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isStreamingTail([row("u1", "user")])).toBe(false);
|
||||
expect(isStreamingTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSettledAssistantTail", () => {
|
||||
it("is true for an assistant tail whose status is not streaming", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "succeeded")])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant")])).toBe(true);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "aborted")])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false for a streaming assistant tail", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "streaming")])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isSettledAssistantTail([row("u1", "user")])).toBe(false);
|
||||
expect(isSettledAssistantTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedRows", () => {
|
||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||
|
||||
it("returns the rows unchanged when not stripping", () => {
|
||||
expect(seedRows(rows, false)).toBe(rows);
|
||||
});
|
||||
|
||||
it("drops the last row when stripping", () => {
|
||||
const seeded = seedRows(rows, true);
|
||||
expect(seeded).toHaveLength(1);
|
||||
expect(seeded[0].id).toBe("u1");
|
||||
});
|
||||
|
||||
it("returns an empty list when stripping a single-row list", () => {
|
||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeById", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const incoming = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the incoming message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const incoming = makeMsg("a1", "first token");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeById(prev, null)).toBe(prev);
|
||||
expect(mergeById(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Pure decisions for the resumable-SSE resume machinery (#184 phase 1.5). A tab
|
||||
* that reopens a chat whose agent run is still going attaches to the server's
|
||||
* run-stream registry (replay + live tail) instead of polling snapshots; these
|
||||
* small predicates decide WHICH tail is safe to resume and how to seed the store,
|
||||
* extracted so they can be unit-tested in isolation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||
*/
|
||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status === "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* A SETTLED assistant tail: the last row is an assistant row whose status is
|
||||
* anything OTHER than 'streaming'. A settled assistant tail must NEVER resume —
|
||||
* replaying a finished run into a store that already holds its message duplicates
|
||||
* parts (`text-start` always pushes a new part).
|
||||
*/
|
||||
export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status !== "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||
* without duplicating parts).
|
||||
*/
|
||||
export function seedRows(
|
||||
rows: IAiChatMessageRow[],
|
||||
strip: boolean,
|
||||
): IAiChatMessageRow[] {
|
||||
return strip ? rows.slice(0, -1) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an assistant message into the rendered list by id: 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 mergeById(
|
||||
messages: UIMessage[],
|
||||
incoming: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!incoming) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === incoming.id);
|
||||
if (idx === -1) return [...messages, incoming];
|
||||
const next = messages.slice();
|
||||
next[idx] = incoming;
|
||||
return next;
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
RUN_POLL_INTERVAL_MS,
|
||||
isRunActive,
|
||||
runPollInterval,
|
||||
shouldObserveRun,
|
||||
shouldClearStoppingLatch,
|
||||
shouldClearLatchOnQueryError,
|
||||
mergeObservedMessage,
|
||||
} from "./run-polling.ts";
|
||||
|
||||
function makeRun(status: string): IAiChatRun {
|
||||
return { id: "run-1", chatId: "c1", status };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isRunActive", () => {
|
||||
it("treats pending and running as active", () => {
|
||||
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats terminal / unknown / nullish as not active", () => {
|
||||
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||
expect(isRunActive(null)).toBe(false);
|
||||
expect(isRunActive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||
it("returns 2000ms while the run is pending/running", () => {
|
||||
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||
});
|
||||
|
||||
it("returns false (stop polling) once the run is terminal", () => {
|
||||
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false (no polling) when there is no run", () => {
|
||||
expect(runPollInterval(null)).toBe(false);
|
||||
expect(runPollInterval(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT observe when there is no run", () => {
|
||||
expect(shouldObserveRun(null, false)).toBe(false);
|
||||
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
||||
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
||||
// observer (not streaming), and the CURRENT run is terminal.
|
||||
it("clears only when stopping, observing, and the run is terminal", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("succeeded"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("failed"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
||||
// re-open the flash for the current turn the moment we switch to observer role.
|
||||
// A predicate lacking the streaming gate would (wrongly) return true here.
|
||||
it("does NOT clear while this tab is the local streamer", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("succeeded"),
|
||||
isLocalStreaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// The detached run keeps growing after a local abort — while it is still
|
||||
// active the latch MUST hold so the observer merge stays suppressed.
|
||||
it("does NOT clear while the run is still active", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("running"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("pending"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
||||
// observed `run` is null until the current turn's run is fetched fresh. A null
|
||||
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
||||
// only against the current turn's own terminal run once observed.
|
||||
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: null,
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: undefined,
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear when no stop was requested", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: false,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
||||
// This guards the REAL anti-flash decision the component's run-query-error
|
||||
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
||||
// not a copy — so the test is non-vacuous vs the live code).
|
||||
|
||||
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
||||
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
||||
// runQueryFailed can be true while the held run is still pending/running.
|
||||
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
||||
// true — so this assertion fails on the buggy code (non-vacuous).
|
||||
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("running"),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("pending"),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
||||
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
||||
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
||||
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
||||
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
||||
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
||||
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("aborted"),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT clear without an actual query error", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: false,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear while this tab is the local streamer", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: true,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear when no stop was requested", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: false,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeObservedMessage", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the observed message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const observed = makeMsg("a1", "first token");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||
* assistant message into the rendered thread. These are the small pure decisions
|
||||
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||
*/
|
||||
|
||||
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||
// so a stale/odd value never polls forever).
|
||||
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||
|
||||
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||
* reaching a terminal status — no separate timeout cap is needed.
|
||||
*/
|
||||
export function runPollInterval(
|
||||
run: IAiChatRun | null | undefined,
|
||||
): number | false {
|
||||
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||
* stopped separately by {@link runPollInterval}.
|
||||
*/
|
||||
export function shouldObserveRun(
|
||||
run: IAiChatRun | null | undefined,
|
||||
localStreaming: boolean,
|
||||
): boolean {
|
||||
return !!run && !localStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the "stopping" latch — which suppresses the observer re-stream flash
|
||||
* after the user pressed Stop — be RELEASED now? All three must hold:
|
||||
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
|
||||
* streamer the run query is disabled, so the observed `run` is not the run we
|
||||
* are following — releasing the latch then would re-open the flash for the
|
||||
* current turn the instant we switch to observer role;
|
||||
* - the observed `run` EXISTS and has reached a TERMINAL status.
|
||||
*
|
||||
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
|
||||
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
|
||||
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
|
||||
* therefore HOLDS the latch, so it can only ever clear against the current turn's
|
||||
* OWN terminal run — never a stale cached one. (The cache removal itself is
|
||||
* integration-level in AiChatWindow; this predicate encodes the decision given
|
||||
* whatever run is currently observed, and a stale terminal run is
|
||||
* indistinguishable from a current terminal run at the predicate level — hence
|
||||
* the cache removal is what guarantees only the current run is ever passed here.)
|
||||
*/
|
||||
export function shouldClearStoppingLatch(args: {
|
||||
stoppingRun: boolean;
|
||||
run: IAiChatRun | null | undefined;
|
||||
isLocalStreaming: boolean;
|
||||
}): boolean {
|
||||
const { stoppingRun, run, isLocalStreaming } = args;
|
||||
if (!stoppingRun || isLocalStreaming) return false;
|
||||
return !!run && !isRunActive(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
|
||||
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
|
||||
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
|
||||
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
|
||||
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
|
||||
* its status-keyed refetchInterval is off, and nothing would ever observe a
|
||||
* terminal run — freezing the view with the observer merge suppressed. This
|
||||
* safety-net cures ONLY that genuine permanent-null-freeze.
|
||||
*
|
||||
* All four must hold:
|
||||
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
|
||||
* {@link shouldClearStoppingLatch});
|
||||
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
|
||||
* retry:false — isError);
|
||||
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
|
||||
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
|
||||
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
|
||||
* ACTIVE run (a single transient GET-run failure in the window between Stop and
|
||||
* settle). Without this gate a transient error would release the latch early —
|
||||
* re-opening the observer merge and flashing the growing detached run over the
|
||||
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
|
||||
* only ever cure the permanent-null-freeze (`run === null`, so
|
||||
* `isRunActive(null)` is false), never release against an active run.
|
||||
*
|
||||
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
|
||||
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
|
||||
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
|
||||
*
|
||||
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
|
||||
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
|
||||
* polling when the data is empty — so after we clear on null+error there is no
|
||||
* subsequent auto-poll that could return a still-active detached run and re-open the
|
||||
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
|
||||
* error, this null-branch clear would re-open the F7 flash through the null path.
|
||||
* Do not change the run query's refetchInterval without re-checking this path.
|
||||
*/
|
||||
export function shouldClearLatchOnQueryError(args: {
|
||||
stoppingRun: boolean;
|
||||
isLocalStreaming: boolean;
|
||||
runQueryFailed: boolean;
|
||||
run: IAiChatRun | null | undefined;
|
||||
}): boolean {
|
||||
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
|
||||
return (
|
||||
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an observed assistant message into the rendered list: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeObservedMessage(
|
||||
messages: UIMessage[],
|
||||
observed: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!observed) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||
if (idx === -1) return [...messages, observed];
|
||||
const next = messages.slice();
|
||||
next[idx] = observed;
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
@@ -77,6 +78,138 @@ describe("toolCitations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInputSummary", () => {
|
||||
it("returns the primary `query` string", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("summarizes a primary array field with a (+N) suffix", () => {
|
||||
// `urls` is an external MCP read_pages-style list; the first element plus a
|
||||
// count of the rest.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["a", "b", "c"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("a (+2)");
|
||||
});
|
||||
|
||||
it("omits the (+N) suffix for a single-element array", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["only"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("only");
|
||||
});
|
||||
|
||||
it("falls back to `title` for a page op with no query", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-createPage",
|
||||
state: "input-available",
|
||||
input: { pageId: "x", title: "My Page" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("My Page");
|
||||
});
|
||||
|
||||
it("prefers the earlier primary field when several are present", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
// `query` outranks `title` in PRIMARY_INPUT_FIELDS — the ordered list is
|
||||
// the contract, so a reordering must break this test.
|
||||
input: { query: "Q", title: "T" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("Q");
|
||||
});
|
||||
|
||||
it("does not clamp a value exactly at the 140-char limit", () => {
|
||||
const exact = "a".repeat(140);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: exact },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out).toBe(exact);
|
||||
expect(out.endsWith("…")).toBe(false);
|
||||
expect(out.length).toBe(140);
|
||||
});
|
||||
|
||||
it("clamps one char over the limit (141 -> 140 + ellipsis)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "a".repeat(141) },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBe(141);
|
||||
expect(out).toBe("a".repeat(140) + "…");
|
||||
});
|
||||
|
||||
it("clamps a long value to ~140 chars with an ellipsis", () => {
|
||||
const long = "a".repeat(300);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: long },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBeLessThanOrEqual(141);
|
||||
});
|
||||
|
||||
it("collapses newlines and repeated spaces to single spaces", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: " foo\n\n bar baz " },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("foo bar baz");
|
||||
});
|
||||
|
||||
it("returns undefined with no input", () => {
|
||||
expect(
|
||||
toolInputSummary({ type: "tool-x", state: "input-available" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: {},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: "just a string",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined while the input is still streaming (even with a full input)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-streaming",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
|
||||
@@ -97,6 +97,69 @@ function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Collapse runs of whitespace/newlines to a single space and trim. */
|
||||
function collapse(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Truncate to ~140 chars, appending an ellipsis when it overflows. */
|
||||
function clamp(s: string): string {
|
||||
const MAX = 140;
|
||||
return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority "primary" argument fields, in order. The first present one supplies
|
||||
* the summary. `urls` is included (external MCP `read_pages`-style tools take a
|
||||
* list of URLs) and is handled as an array; `url` covers the single-URL form.
|
||||
*/
|
||||
const PRIMARY_INPUT_FIELDS = [
|
||||
"query",
|
||||
"q",
|
||||
"searchQuery",
|
||||
"url",
|
||||
"urls",
|
||||
"title",
|
||||
"name",
|
||||
"text",
|
||||
"prompt",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the
|
||||
* search query), or undefined when no recognizable primary field is present.
|
||||
* Rendered under the tool label so tools without a friendly name (external MCP
|
||||
* tools like `Search_web_search`) still show WHAT was requested, not just a
|
||||
* generic "Ran tool {{name}}". The returned string is plain text and MUST be
|
||||
* rendered React-escaped (Mantine `<Text>`), never as markdown/HTML.
|
||||
*
|
||||
* Streaming gate: while `state === "input-streaming"` the `input` object grows
|
||||
* chunk by chunk but `messageSignature` deliberately does NOT track `input`, so
|
||||
* a live summary computed here would freeze at its first captured value and go
|
||||
* stale. We therefore return undefined until the state flips to
|
||||
* `input-available` (input finalized) — that state change IS tracked by the
|
||||
* signature, so the row re-renders and shows the complete summary. Do NOT add
|
||||
* `input` to `message-signature.ts` to work around this.
|
||||
*/
|
||||
export function toolInputSummary(part: ToolUiPart): string | undefined {
|
||||
if (part.state === "input-streaming") return undefined;
|
||||
if (!part.input || typeof part.input !== "object") return undefined;
|
||||
const input = part.input as Record<string, unknown>;
|
||||
|
||||
for (const field of PRIMARY_INPUT_FIELDS) {
|
||||
const value = input[field];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return clamp(collapse(value));
|
||||
}
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const first = collapse(String(value[0]));
|
||||
if (first.length === 0) continue;
|
||||
return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : ""));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||
* Only output-available parts (the tool returned) yield citations. Search
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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,7 +24,6 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,20 +34,6 @@ export default function useAuth() {
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||
// never block sign-in on cache cleanup.
|
||||
try {
|
||||
await clearOfflineCache();
|
||||
} catch {
|
||||
// best-effort: never block sign-in on cache cleanup
|
||||
}
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
setIsLoading(false);
|
||||
@@ -144,13 +129,6 @@ export default function useAuth() {
|
||||
// remain.)
|
||||
clearPersistedTreeCaches();
|
||||
await logout();
|
||||
// Purge the previous user's offline data while the page is still alive —
|
||||
// window.location.replace below would otherwise interrupt async cleanup.
|
||||
try {
|
||||
await clearOfflineCache();
|
||||
} catch {
|
||||
// best-effort: never block logout on cache cleanup
|
||||
}
|
||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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,27 +3,6 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Retry predicate for the collab-token query.
|
||||
*
|
||||
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||
*/
|
||||
export function collabTokenRetry(
|
||||
_failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
@@ -43,7 +22,13 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||
//refetchIntervalInBackground: true,
|
||||
refetchOnMount: true,
|
||||
retry: collabTokenRetry,
|
||||
//@ts-ignore
|
||||
retry: (failureCount, error) => {
|
||||
if (isAxiosError(error) && error.response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
retryDelay: (retryAttempt) => {
|
||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||
|
||||
@@ -23,7 +23,6 @@ import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
@@ -67,9 +66,6 @@ export function useCreateCommentMutation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||
mutationKey: offlineMutationKeys.createComment,
|
||||
mutationFn: (data) => createComment(data),
|
||||
onSuccess: (newComment) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
|
||||
@@ -11,12 +11,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
|
||||
export const isLocalSyncedAtom = atom<boolean>(false);
|
||||
|
||||
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
|
||||
export const isRemoteSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,8 +34,6 @@ import {
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -82,24 +80,16 @@ export function FullEditor({
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
// `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 fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||
currentPageEditModeAtom,
|
||||
);
|
||||
const userPageEditMode =
|
||||
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||
|
||||
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||
// editors (title lives in the 'title' fragment of the same doc).
|
||||
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||
|
||||
// Apply the user's saved preference only once on initial load, not on every
|
||||
// page navigation — so the mode sticks across navigations within a session.
|
||||
useEffect(() => {
|
||||
@@ -120,32 +110,28 @@ export function FullEditor({
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<EditorProvidersContext.Provider
|
||||
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</EditorProvidersContext.Provider>
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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, broadcasts, and does NOT write the editor", async () => {
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -157,11 +157,9 @@ describe("useGeneratePageTitle", () => {
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||
// its own. Writing here too would double/garble the title, so the hook must
|
||||
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
@@ -169,7 +167,7 @@ describe("useGeneratePageTitle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -205,9 +203,55 @@ describe("useGeneratePageTitle", () => {
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...the hook never writes the editor regardless of navigation...
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// ...and the change is still broadcast to other clients.
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
@@ -29,9 +33,18 @@ const MAX_CONTENT_CHARS = 20000;
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
@@ -57,15 +70,33 @@ export function useGeneratePageTitle(pageId: string) {
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Do NOT write the title into the editor here. The title editor is bound to
|
||||
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||
// its own as a remote provider update. The old REST-era setContent here
|
||||
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||
// the captured `pageId`, so it stays correct even if the user navigated
|
||||
// away during generation.
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
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,7 +6,16 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -19,16 +28,14 @@ import {
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
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 {
|
||||
currentPageEditModeAtom,
|
||||
dictationAvailabilityAtom,
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
@@ -53,8 +60,10 @@ import {
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useDebouncedCallback, 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";
|
||||
@@ -65,7 +74,9 @@ import {
|
||||
GitmostInsertRecordingResult,
|
||||
gitmostInsertRecordingIntoEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
@@ -102,6 +113,7 @@ export default function PageEditor({
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -115,10 +127,22 @@ export default function PageEditor({
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
// Always holds the latest collab token. The provider effect below runs once
|
||||
// per pageId, so a handler created inside it would otherwise close over a
|
||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
collabTokenRef.current = collabQuery?.token;
|
||||
}, [collabQuery?.token]);
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
@@ -128,27 +152,141 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||
// the context (defensive) — in practice full-editor always provides it.
|
||||
const editorProviders = useEditorProviders();
|
||||
const remote = editorProviders?.remote ?? null;
|
||||
const providersReady = editorProviders?.providersReady ?? false;
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSynced(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSynced(event.state);
|
||||
};
|
||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
||||
// may be stale). Guard the decode: a missing or unparseable token must
|
||||
// not throw "Invalid token specified" and should trigger a refresh so
|
||||
// the editor reconnects even when the initial token fetch failed.
|
||||
const token = collabTokenRef.current;
|
||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||
if (token) {
|
||||
try {
|
||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
||||
} catch {
|
||||
needsRefresh = true;
|
||||
}
|
||||
}
|
||||
if (!needsRefresh) return;
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
onStateless: onStatelessHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !remote || !currentUser?.user) {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remote, currentUser?.user),
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, remote, currentUser?.user]);
|
||||
}, [providersReady, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -513,7 +651,7 @@ export default function PageEditor({
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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}`;
|
||||
@@ -1,33 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
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])
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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 { useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -26,9 +29,6 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
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 {
|
||||
pageId: string;
|
||||
@@ -46,82 +46,65 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
|
||||
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
||||
// the body). Yjs is the source of truth for the title content.
|
||||
const editorProviders = useEditorProviders();
|
||||
const ydoc = editorProviders?.ydoc ?? null;
|
||||
const providersReady = editorProviders?.providersReady ?? false;
|
||||
|
||||
// Until the shared doc is ready, the collaborative editor binds nothing and
|
||||
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
|
||||
// a non-editable static <h1> with the `title` prop in the meantime. The prop
|
||||
// is NEVER fed into the collaborative editor (Yjs stays the single source of
|
||||
// truth — seeding it would duplicate the title).
|
||||
const titleReady = providersReady && !!ydoc;
|
||||
|
||||
const titleEditor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
||||
// Collaboration also manages undo/redo, so the History extension is
|
||||
// intentionally omitted (it would conflict with Yjs). When the doc is
|
||||
// not ready yet the editor renders empty until the doc arrives.
|
||||
...(ydoc
|
||||
? [Collaboration.configure({ document: ydoc, field: "title" })]
|
||||
: []),
|
||||
EmojiCommand,
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
}
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
depth: 20,
|
||||
}),
|
||||
EmojiCommand,
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
setActivePageId(pageId);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
debounceUpdate();
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": t("Page title"),
|
||||
},
|
||||
onUpdate({ editor, transaction }) {
|
||||
// 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,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": t("Page title"),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
[pageId, ydoc],
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
@@ -131,48 +114,69 @@ export function TitleEditor({
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
// On a local title change: update the URL slug and propagate the change to
|
||||
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
||||
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
||||
// emit and the title syncs on reconnect.
|
||||
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
|
||||
replace: true,
|
||||
const saveTitle = useCallback(() => {
|
||||
if (!titleEditor || activePageId !== pageId) return;
|
||||
|
||||
if (
|
||||
titleEditor.getText() === title ||
|
||||
(titleEditor.getText() === "" && title === null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateTitlePageMutationAsync({
|
||||
pageId: pageId,
|
||||
title: titleEditor.getText(),
|
||||
}).then((page) => {
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
|
||||
updatePageData(page);
|
||||
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
});
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
const page =
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (!page) return;
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
|
||||
const updatedPage: IPage = { ...page, title: titleText };
|
||||
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: titleText,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
updatePageData(updatedPage);
|
||||
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||
// 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);
|
||||
}, 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);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// force-save title on navigation
|
||||
saveTitle();
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleEditor) return;
|
||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||
@@ -239,22 +243,16 @@ export function TitleEditor({
|
||||
|
||||
return (
|
||||
<div className="page-title">
|
||||
{titleReady ? (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// Static, non-editable fallback so the title is visible before Yjs
|
||||
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
||||
<h1>{title}</h1>
|
||||
)}
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { UniqueID } from "@docmost/editor-ext";
|
||||
import { getEditorSelectionContext } from "./get-editor-selection";
|
||||
|
||||
/**
|
||||
* Unit tests for getEditorSelectionContext (#388). Built on a headless
|
||||
* ProseMirror schema (Document + Paragraph + Text + the block-id UniqueID
|
||||
* extension), mirroring the editor-ext test style. We assemble docs with
|
||||
* explicit block ids so the covered-blockIds assertions are deterministic.
|
||||
*/
|
||||
|
||||
// A schema that carries the `id` block attribute (UniqueID) on paragraphs, just
|
||||
// like the real editor.
|
||||
const { schema } = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({ types: ["paragraph"] }),
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
|
||||
function docOf(blocks: { id: string; text: string }[]): PMNode {
|
||||
return schema.node(
|
||||
"doc",
|
||||
null,
|
||||
blocks.map((b) =>
|
||||
schema.node("paragraph", { id: b.id }, b.text ? schema.text(b.text) : []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stateWith(doc: PMNode, from: number, to: number): EditorState {
|
||||
const base = EditorState.create({ schema, doc });
|
||||
return base.apply(base.tr.setSelection(TextSelection.create(doc, from, to)));
|
||||
}
|
||||
|
||||
// Select every text position of the doc (pos 1 .. content.size - 1).
|
||||
function selectAll(doc: PMNode): EditorState {
|
||||
return stateWith(doc, 1, doc.content.size - 1);
|
||||
}
|
||||
|
||||
describe("getEditorSelectionContext", () => {
|
||||
it("returns null for an empty (collapsed) selection", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||
const state = stateWith(doc, 3, 3); // caret, from === to
|
||||
expect(getEditorSelectionContext(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the default caret-at-start of a fresh editor", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({ types: ["paragraph"] }),
|
||||
],
|
||||
content: "<p>fresh</p>",
|
||||
});
|
||||
expect(getEditorSelectionContext(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("reads a single-paragraph selection with no block-separator artifacts", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toBe("Hello world");
|
||||
expect(sel.blockIds).toEqual(["b1"]);
|
||||
expect(sel.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("joins multiple blocks with a newline and collects all covered blockIds", () => {
|
||||
const doc = docOf([
|
||||
{ id: "b1", text: "First" },
|
||||
{ id: "b2", text: "Second" },
|
||||
]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toBe("First\nSecond");
|
||||
expect(sel.blockIds).toEqual(["b1", "b2"]);
|
||||
});
|
||||
|
||||
it("caps the text at 2000 chars and flags truncated", () => {
|
||||
const doc = docOf([{ id: "b1", text: "x".repeat(2500) }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toHaveLength(2000);
|
||||
expect(sel.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("computes before/after context and clamps it to the doc bounds", () => {
|
||||
// One paragraph "0123456789abcdefghij"; select the middle "56789".
|
||||
const doc = docOf([{ id: "b1", text: "0123456789abcdefghij" }]);
|
||||
// text char i lives at pos (1 + i); select chars index 5..9 -> pos 6..11.
|
||||
const sel = getEditorSelectionContext(stateWith(doc, 6, 11))!;
|
||||
expect(sel.text).toBe("56789");
|
||||
expect(sel.before).toBe("01234");
|
||||
expect(sel.after).toBe("abcdefghij");
|
||||
});
|
||||
|
||||
it("omits before/after at the document boundaries (never reads past 0/size)", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Edge" }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
// Selection spans the whole single block: nothing before or after it.
|
||||
expect(sel.before).toBeUndefined();
|
||||
expect(sel.after).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export interface EditorSelectionContext {
|
||||
text: string;
|
||||
truncated?: boolean;
|
||||
blockIds?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
// Client-side caps. The server re-caps every field independently (defence in
|
||||
// depth — the payload is attacker-controllable), so these only keep the wire
|
||||
// small for the common case.
|
||||
const TEXT_CAP = 2000;
|
||||
const CONTEXT_CHARS = 160;
|
||||
const MAX_BLOCK_IDS = 20;
|
||||
|
||||
// Pure: takes an EditorState so it is unit-testable with a headless editor.
|
||||
// Snapshots the user's current selection into the wire shape carried inside
|
||||
// openPage — plain text + the ids of the blocks it covers + a little surrounding
|
||||
// context. Returns null when nothing meaningful is selected.
|
||||
//
|
||||
// Deliberately does NOT emit the ProseMirror positions (from/to): they rot the
|
||||
// instant the document changes and the server tools address content by block id
|
||||
// + text (getNode / editPageText find-replace), never by position.
|
||||
export function getEditorSelectionContext(
|
||||
state: EditorState,
|
||||
): EditorSelectionContext | null {
|
||||
const { selection, doc } = state;
|
||||
// An empty selection (incl. the default caret-at-start of a fresh editor) is
|
||||
// never a "this"/"here" — bail before reading any text.
|
||||
if (selection.empty) return null;
|
||||
|
||||
const { from, to } = selection;
|
||||
|
||||
let text = doc.textBetween(from, to, "\n");
|
||||
let truncated = false;
|
||||
if (text.length > TEXT_CAP) {
|
||||
text = text.slice(0, TEXT_CAP);
|
||||
truncated = true;
|
||||
}
|
||||
// A selection spanning only non-text nodes (e.g. an image) trims to empty ->
|
||||
// treat as no selection.
|
||||
if (text.trim().length === 0) return null;
|
||||
|
||||
// Ids of every block the selection covers, deduped and capped. These bridge
|
||||
// the plain-text selection to the server tools (getNode / editPageText).
|
||||
const blockIds: string[] = [];
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
const id = node.isBlock ? node.attrs?.id : undefined;
|
||||
if (typeof id === "string" && id.length > 0 && !blockIds.includes(id)) {
|
||||
blockIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// ~160 chars of plain text on each side, clamped to the document bounds, so
|
||||
// editPageText can disambiguate a duplicate of the selected text.
|
||||
const before = doc.textBetween(Math.max(0, from - CONTEXT_CHARS), from, "\n");
|
||||
const after = doc.textBetween(
|
||||
to,
|
||||
Math.min(doc.content.size, to + CONTEXT_CHARS),
|
||||
"\n",
|
||||
);
|
||||
|
||||
const result: EditorSelectionContext = { text };
|
||||
if (truncated) result.truncated = true;
|
||||
if (blockIds.length > 0) result.blockIds = blockIds.slice(0, MAX_BLOCK_IDS);
|
||||
if (before.length > 0) result.before = before;
|
||||
if (after.length > 0) result.after = after;
|
||||
return result;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -38,39 +36,21 @@ function CreateNoteButton({
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const variables = {
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} 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);
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
// useCreatePageMutation already surfaces a red notification on error.
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
const isPending = createPageMutation.isPending;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
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,8 +12,6 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconPrinter,
|
||||
IconCloud,
|
||||
IconCloudCheck,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
@@ -41,8 +39,6 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
@@ -415,16 +411,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
function ConnectionWarning() {
|
||||
const { t } = useTranslation();
|
||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
if (isDisconnected) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||
@@ -436,7 +430,7 @@ function ConnectionWarning() {
|
||||
}
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [isDisconnected]);
|
||||
}, [yjsConnectionStatus]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
@@ -447,59 +441,22 @@ 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 (
|
||||
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
role="status"
|
||||
aria-label={offlineLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (2): connected but the remote replica is not fully caught up yet.
|
||||
if (!isRemoteSynced || !isLocalSynced) {
|
||||
const syncingLabel = t("Syncing changes…");
|
||||
return (
|
||||
<Tooltip label={syncingLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={syncingLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCloud size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (3): fully synced — subtle confirmation indicator.
|
||||
const syncedLabel = t("All changes synced");
|
||||
return (
|
||||
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||
<Tooltip
|
||||
label={t("Real-time editor connection lost. Retrying...")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="dimmed"
|
||||
c="red"
|
||||
role="status"
|
||||
aria-label={syncedLabel}
|
||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCloudCheck size={20} stroke={2} />
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
@@ -43,38 +42,12 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||
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(
|
||||
pageInput: Partial<IPageInput>,
|
||||
): UseQueryResult<IPage, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: pageKeys.detail(pageInput.pageId),
|
||||
queryKey: ["pages", pageInput.pageId],
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -83,9 +56,9 @@ export function usePageQuery(
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -96,10 +69,6 @@ export function usePageQuery(
|
||||
export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
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),
|
||||
onSuccess: (data) => {
|
||||
invalidateOnCreatePage(data);
|
||||
@@ -111,20 +80,18 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||
pageKeys.detail(data.slugId),
|
||||
);
|
||||
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
@@ -178,11 +145,11 @@ export function useRemovePageMutation() {
|
||||
});
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
if (cached) {
|
||||
const stamped = { ...cached, deletedAt: new Date() };
|
||||
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||
}
|
||||
|
||||
invalidateOnDeletePage(pageId);
|
||||
@@ -221,9 +188,6 @@ export function useDeletePageMutation() {
|
||||
|
||||
export function useMovePageMutation() {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -303,11 +267,8 @@ export function useRestorePageMutation() {
|
||||
// Replace would strip space/permissions/content and break the editor.
|
||||
const merge = (cached: IPage | undefined) =>
|
||||
cached ? { ...cached, ...restoredPage } : cached;
|
||||
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||
queryClient.setQueryData<IPage>(
|
||||
pageKeys.detail(restoredPage.slugId),
|
||||
merge,
|
||||
);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
@@ -322,7 +283,7 @@ export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams | null,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: pageKeys.sidebar(data),
|
||||
queryKey: ["sidebar-pages", data],
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||
@@ -333,7 +294,7 @@ export function useGetSidebarPagesQuery(
|
||||
|
||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({
|
||||
spaceId: data.spaceId,
|
||||
@@ -359,7 +320,7 @@ export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
return useQuery({
|
||||
queryKey: pageKeys.breadcrumbs(pageId),
|
||||
queryKey: ["breadcrumbs", pageId],
|
||||
queryFn: () => getPageBreadcrumbs(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
@@ -371,12 +332,10 @@ export async function fetchAllAncestorChildren(
|
||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||
opts?: { fresh?: boolean },
|
||||
) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook. 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).
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
const response = await queryClient.fetchQuery({
|
||||
...sidebarPagesQueryOptions(params),
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -386,7 +345,7 @@ export async function fetchAllAncestorChildren(
|
||||
|
||||
export function useRecentChangesQuery(spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
@@ -457,12 +416,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
} else {
|
||||
queryKey = pageKeys.sidebar({
|
||||
pageId: data.parentPageId,
|
||||
spaceId: data.spaceId,
|
||||
});
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
@@ -522,7 +481,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -546,7 +505,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||
queryKey: ["recent-changes", data.spaceId],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -560,9 +519,9 @@ export function invalidateOnUpdatePage(
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = pageKeys.rootSidebar(spaceId);
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
} else {
|
||||
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
@@ -585,7 +544,7 @@ export function invalidateOnUpdatePage(
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -600,8 +559,8 @@ export function updateCacheOnMovePage(
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
@@ -621,7 +580,7 @@ export function updateCacheOnMovePage(
|
||||
if (oldParentId !== null) {
|
||||
const oldParentCache = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IPage>>
|
||||
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||
|
||||
const remainingChildren =
|
||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||
@@ -659,8 +618,8 @@ export function updateCacheOnMovePage(
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCloudDownload,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -36,12 +35,6 @@ import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} from "@/features/page-embed/queries/page-embed-query";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
makePageAvailableOffline,
|
||||
warmPageYdoc,
|
||||
} from "@/features/offline/make-offline";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
@@ -79,46 +72,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const isTemplate = !!node.isTemplate;
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!node.temporaryExpiresAt;
|
||||
const { data: collabQuery } = useCollabToken();
|
||||
|
||||
const handleMakeAvailableOffline = async () => {
|
||||
notifications.show({ message: t("Saving page for offline use...") });
|
||||
try {
|
||||
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||
// reports whether every warm step succeeded.
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: node.id,
|
||||
spaceId: node.spaceId,
|
||||
});
|
||||
// Best-effort: warm the page's Yjs document into IndexedDB.
|
||||
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
||||
|
||||
if (result.ok) {
|
||||
notifications.show({ message: t("Page is now available offline") });
|
||||
} else {
|
||||
// Partial warm — the page may still be partly usable offline, but some
|
||||
// queries failed to cache, so surface it as an error rather than a
|
||||
// silent success. 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 next = !isTemplate;
|
||||
@@ -275,17 +228,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{t("Export")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCloudDownload size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleMakeAvailableOffline();
|
||||
}}
|
||||
>
|
||||
{t("Make available offline")}
|
||||
</Menu.Item>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Item
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useCreatePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -27,6 +28,7 @@ export type UseTreeMutation = {
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// children) and then immediately invokes a handler.
|
||||
const store = useStore();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
@@ -219,6 +222,20 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
||||
);
|
||||
try {
|
||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||
} catch (error) {
|
||||
console.error("Error updating page title:", error);
|
||||
}
|
||||
},
|
||||
[updatePageMutation, setData],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const node = treeModel.find(
|
||||
@@ -264,7 +281,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleDelete };
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
}
|
||||
|
||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||
|
||||
@@ -165,6 +165,9 @@ export default function ShareAiWidget({
|
||||
isStreaming={isStreaming}
|
||||
assistantName={assistantName}
|
||||
showCitations={false}
|
||||
// Anonymous reader: suppress the tool-argument summary line so the
|
||||
// agent's raw query/argument text isn't shown on the public share.
|
||||
showInput={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -32,37 +31,11 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factories for space queries. The hooks below and
|
||||
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||
* runtime keys can never silently drift apart.
|
||||
*/
|
||||
export const spaceKeys = {
|
||||
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
|
||||
list: (params?: QueryParams) => ["spaces", params] as const,
|
||||
members: (spaceId: string, query?: string) =>
|
||||
["spaceMembers", spaceId, query] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared queryOptions for fetching a space by id/slug. Both
|
||||
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
|
||||
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
|
||||
* prefetchQuery ignores it anyway and the warm path always passes a real id;
|
||||
* the hook reapplies `enabled` itself.)
|
||||
*/
|
||||
export const spaceByIdQueryOptions = (spaceId: string) =>
|
||||
queryOptions({
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function useGetSpacesQuery(
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||
return useQuery({
|
||||
queryKey: spaceKeys.list(params),
|
||||
queryKey: ["spaces", params],
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
@@ -71,16 +44,16 @@ export function useGetSpacesQuery(
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(spaceId)) {
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -89,11 +62,8 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
}
|
||||
|
||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
|
||||
// a 5min staleTime which would let this prefetch skip fetching fresh data;
|
||||
// prefetchSpace must always refetch (default staleTime: 0).
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: spaceKeys.detail(spaceSlug),
|
||||
queryKey: ["space", spaceSlug],
|
||||
queryFn: () => getSpaceById(spaceSlug),
|
||||
});
|
||||
|
||||
@@ -130,8 +100,10 @@ export function useGetSpaceBySlugQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
...spaceByIdQueryOptions(spaceId),
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,16 +116,14 @@ export function useUpdateSpaceMutation() {
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Space updated successfully") });
|
||||
|
||||
const space = queryClient.getQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
) as ISpace;
|
||||
const space = queryClient.getQueryData([
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
updatedSpace,
|
||||
);
|
||||
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
@@ -178,7 +148,7 @@ export function useDeleteSpaceMutation() {
|
||||
|
||||
if (variables.slug) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: spaceKeys.detail(variables.slug),
|
||||
queryKey: ["space", variables.slug],
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
@@ -186,7 +156,7 @@ export function useDeleteSpaceMutation() {
|
||||
// Remove space-specific queries
|
||||
if (variables.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: spaceKeys.detail(variables.id),
|
||||
queryKey: ["space", variables.id],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
@@ -226,7 +196,7 @@ export function useSpaceMembersInfiniteQuery(
|
||||
query?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: spaceKeys.members(spaceId, query),
|
||||
queryKey: ["spaceMembers", spaceId, query],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||
enabled: !!spaceId,
|
||||
|
||||
@@ -2,19 +2,9 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getMyInfo } from "@/features/user/services/user-service";
|
||||
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> {
|
||||
return useQuery({
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: async () => {
|
||||
return await getMyInfo();
|
||||
},
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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,7 +11,6 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
|
||||
@@ -71,30 +70,14 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||
}, [i18n.language, i18n.resolvedLanguage]);
|
||||
|
||||
// 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 (isLoading) return <></>;
|
||||
|
||||
if (isError && error?.["response"]?.status === 404) {
|
||||
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) {
|
||||
return <OfflineFallback />;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -11,8 +11,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
@@ -22,13 +21,6 @@ import {
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} 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 { initVitals } from "@/lib/telemetry/vitals";
|
||||
|
||||
@@ -39,30 +31,10 @@ export const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
@@ -83,44 +55,15 @@ root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||
native shell serves assets itself; a browser SW would conflict. */}
|
||||
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
|
||||
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
|
||||
// and skipped inside the Capacitor native WebView). The earlier hand-written
|
||||
// /sw.js registration from the mobile bootstrap was removed here to avoid a
|
||||
// double registration / competing service worker.
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -63,19 +62,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
// 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)) {
|
||||
if ([401, 403, 404].includes(error?.["status"])) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconFileOff}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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,4 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
declare const APP_VERSION: string
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import * as path from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
@@ -54,55 +53,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
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 },
|
||||
}),
|
||||
],
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
|
||||
@@ -24,10 +24,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import {
|
||||
CollaborationHandler,
|
||||
CollabEventHandlers,
|
||||
writeTitleFragment,
|
||||
} from './collaboration.handler';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
@@ -191,70 +188,6 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
|
||||
*
|
||||
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
|
||||
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
|
||||
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
|
||||
* through the local Hocuspocus `openDirectConnection`. The title sync
|
||||
* therefore works in BOTH single-process (no Redis) and Redis-clustered
|
||||
* deployments.
|
||||
*
|
||||
* openDirectConnection loads the doc from persistence when no editor is
|
||||
* connected, so this works whether or not an editor is currently open: the
|
||||
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
|
||||
*
|
||||
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
|
||||
* into the connection `context` so onStoreDocument sees `context.actor ===
|
||||
* 'agent'` for the resulting title store (mirrors the body/REST path). The
|
||||
* resulting title store is usually a no-op anyway — PageService already wrote
|
||||
* the same title to the page.title column, so onStoreDocument's
|
||||
* `titleText !== page.title` guard skips the column write — but we wire the
|
||||
* context for correctness regardless.
|
||||
*/
|
||||
async writePageTitle(
|
||||
pageId: string,
|
||||
title: string,
|
||||
context?: { user?: User; actor?: string; aiChatId?: string },
|
||||
): Promise<void> {
|
||||
const documentName = `page.${pageId}`;
|
||||
const connection = await this.hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
context ?? {},
|
||||
);
|
||||
try {
|
||||
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||
// resulting full doc state so we can persist it directly below.
|
||||
let ydocState: Buffer | null = null;
|
||||
await connection.transact((doc) => {
|
||||
writeTitleFragment(doc, title);
|
||||
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
});
|
||||
|
||||
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||
// body edit would then revert the column back to. Writing the ydoc here
|
||||
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||
//
|
||||
// Safe with or without a live editor: the write is idempotent and carries
|
||||
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||
// normal onStoreDocument flow still persists the (superset) state later and
|
||||
// the live clients receive the title change through the transact above.
|
||||
if (ydocState) {
|
||||
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||
pageId,
|
||||
ydocState,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import * as Y from 'yjs';
|
||||
import { CollaborationHandler, writeTitleFragment } from './collaboration.handler';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
import * as yjsUtil from './yjs.util';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
/**
|
||||
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
||||
@@ -193,149 +186,3 @@ describe('CollaborationHandler.deleteCommentMark', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Read the plain text held in the doc's 'title' XmlFragment, the same way
|
||||
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
|
||||
const readTitleText = (doc: Y.Doc): string => {
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
|
||||
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
|
||||
// Seed the doc's 'title' fragment with an OLD title, like a real page.
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
expect(readTitleText(doc)).toBe('Old Title');
|
||||
|
||||
writeTitleFragment(doc, 'New Title');
|
||||
|
||||
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
|
||||
// (append) or "New TitleNew Title" (duplication). A single heading node.
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
|
||||
expect(titleJson.content).toHaveLength(1);
|
||||
expect(titleJson.content[0].type).toBe('heading');
|
||||
});
|
||||
|
||||
it('seeds the title fragment when it started empty', () => {
|
||||
const doc = new Y.Doc();
|
||||
// Force the 'title' fragment to exist but be empty.
|
||||
doc.getXmlFragment('title');
|
||||
expect(readTitleText(doc)).toBe('');
|
||||
|
||||
writeTitleFragment(doc, 'First Title');
|
||||
|
||||
expect(readTitleText(doc)).toBe('First Title');
|
||||
});
|
||||
|
||||
it('does not corrupt the body when rewriting the title', () => {
|
||||
// A doc with both a body and an old title; the body must survive untouched.
|
||||
const doc = new Y.Doc();
|
||||
const bodyDoc = TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
|
||||
],
|
||||
},
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
|
||||
|
||||
writeTitleFragment(doc, 'New');
|
||||
|
||||
expect(readTitleText(doc)).toBe('New');
|
||||
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
|
||||
expect(jsonToText(bodyJson)).toContain('body text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
|
||||
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
|
||||
// method must drive the clear+seed through that direct connection (NOT through
|
||||
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
|
||||
const makeGateway = (doc: Y.Doc) => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||
fn(doc);
|
||||
});
|
||||
const openDirectConnection = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ transact, disconnect });
|
||||
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||
|
||||
return {
|
||||
gateway,
|
||||
openDirectConnection,
|
||||
transact,
|
||||
disconnect,
|
||||
persistTitleFragmentYdoc,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
|
||||
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
|
||||
makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ user: { id: 'u1' } }),
|
||||
);
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.any(Buffer),
|
||||
);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('threads agent provenance into the connection context', async () => {
|
||||
const doc = new Y.Doc();
|
||||
const { gateway, openDirectConnection } = makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'Agent Title', {
|
||||
user: { id: 'u1' },
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects even when the transaction throws', async () => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const openDirectConnection = jest.fn().mockResolvedValue({
|
||||
transact: jest.fn().mockRejectedValue(new Error('boom')),
|
||||
disconnect,
|
||||
});
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
|
||||
await expect(
|
||||
gateway.writePageTitle('page-1', 'X', {}),
|
||||
).rejects.toThrow('boom');
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
@@ -20,35 +19,6 @@ export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
||||
*
|
||||
* Used by the gateway's direct `writePageTitle` method to write a new page
|
||||
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
||||
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
||||
* rename that only updated the page.title DB column would be reverted on the
|
||||
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
||||
* The whole fragment is replaced (no merge/append),
|
||||
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
||||
*
|
||||
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
||||
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
||||
* title in an open editor WILL overwrite that in-progress edit. This is
|
||||
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
||||
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
||||
* DB column to on the next save.
|
||||
*/
|
||||
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
||||
const titleFragment = doc.getXmlFragment('title');
|
||||
|
||||
if (titleFragment.length > 0) {
|
||||
titleFragment.delete(0, titleFragment.length);
|
||||
}
|
||||
|
||||
const newTitleDoc = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationHandler {
|
||||
private readonly logger = new Logger(CollaborationHandler.name);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
@@ -244,43 +241,3 @@ describe('prosemirrorNodeToYElement', () => {
|
||||
expect(element.get(1).get(0).toString()).toBe('two');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTitleSeedYdoc', () => {
|
||||
it('builds a level-1 heading carrying the title text', () => {
|
||||
const doc = buildTitleSeedYdoc('Hello World');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
expect(first.attrs.level).toBe(1);
|
||||
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('Some Title');
|
||||
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a heading with no text child for an empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
// No text content for an empty title.
|
||||
expect(first.content ?? []).toHaveLength(0);
|
||||
expect(jsonToText(json).trim()).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||
const title = 'Round Trip Title';
|
||||
const doc1 = buildTitleSeedYdoc(title);
|
||||
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||
|
||||
const doc2 = buildTitleSeedYdoc(text1);
|
||||
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||
|
||||
expect(text1).toBe(title);
|
||||
expect(text2).toBe(text1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,6 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
import { Node, Schema } from '@tiptap/pm/model';
|
||||
import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
@@ -146,34 +145,6 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
||||
* fragment named exactly 'title' (the collaborative title-editor contract with
|
||||
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
||||
* whose text is the title (empty title => heading with no text child).
|
||||
*
|
||||
* The encoded state of the returned doc can be merged into a body doc via
|
||||
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
||||
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
||||
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
||||
*/
|
||||
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
||||
return TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: title ? [{ type: 'text', text: title }] : [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'title',
|
||||
tiptapExtensions,
|
||||
);
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||
|
||||
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
|
||||
// rename) from the standalone collab process to the API process, which is the
|
||||
// single broadcast authority. Imported by both halves of the bridge:
|
||||
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
|
||||
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||
|
||||
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
|
||||
const bodyJson = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||
};
|
||||
|
||||
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
|
||||
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
|
||||
const buildDoc = () => {
|
||||
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
d.broadcastStateless = jest.fn();
|
||||
return d;
|
||||
};
|
||||
|
||||
const cloneOut = (doc: any) =>
|
||||
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
|
||||
|
||||
const addTitleFragment = (doc: any, title: string) =>
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
|
||||
describe('PersistenceExtension', () => {
|
||||
let pageRepo: any;
|
||||
let pageHistoryRepo: any;
|
||||
let trx: any;
|
||||
let db: any;
|
||||
let aiQueue: any;
|
||||
let historyQueue: any;
|
||||
let notificationQueue: any;
|
||||
let collabHistory: any;
|
||||
let transclusionService: any;
|
||||
let ext: PersistenceExtension;
|
||||
|
||||
beforeEach(() => {
|
||||
pageRepo = {
|
||||
findById: jest.fn(),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageHistoryRepo = {
|
||||
findPageLastHistory: jest.fn(),
|
||||
saveHistory: jest.fn(),
|
||||
};
|
||||
trx = {};
|
||||
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
|
||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('seedTitleFragment', () => {
|
||||
it('returns false for empty/whitespace/null titles', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT re-seed an existing non-empty title fragment', () => {
|
||||
const doc = new Y.Doc();
|
||||
addTitleFragment(doc, 'Existing');
|
||||
|
||||
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
|
||||
|
||||
const text = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(text)).toContain('Existing');
|
||||
expect(JSON.stringify(text)).not.toContain('Other');
|
||||
});
|
||||
|
||||
it('seeds an empty fragment from a non-empty title and returns true', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
|
||||
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(json)).toContain('Hello');
|
||||
});
|
||||
|
||||
it('returns false (defensive) when reading the fragment throws', () => {
|
||||
const fakeDoc = {
|
||||
get: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStoreDocument', () => {
|
||||
const basePage = (overrides: any) => ({
|
||||
id: 'PAGE_ID',
|
||||
slugId: 'slug',
|
||||
spaceId: 'space',
|
||||
parentPageId: null,
|
||||
creatorId: 'creator',
|
||||
contributorIds: ['creator'],
|
||||
workspaceId: 'ws',
|
||||
title: 'whatever',
|
||||
content: null,
|
||||
lastUpdatedSource: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
|
||||
|
||||
it('no-op when neither body nor title changed', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'hello title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).not.toHaveBeenCalled();
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('title-only change persists the title without body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].contributorIds).toBeDefined();
|
||||
expect('content' in call[0]).toBe(false);
|
||||
// Title-only must not touch the body-authorship provenance.
|
||||
expect('lastUpdatedSource' in call[0]).toBe(false);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
expect(call[3].treeUpdate.title).toBe('New Title');
|
||||
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(
|
||||
'PAGE_ID',
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
|
||||
// The client can momentarily seed the 'title' fragment as an EMPTY heading
|
||||
// (hasTitleFragment true, extracted text '') before the real title syncs.
|
||||
// Body is unchanged here, so the only candidate write is the title -> the
|
||||
// guard must turn this into a full no-op (no updatePage, no broadcast).
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
// No write at all: the empty title is not authoritative and the body is
|
||||
// unchanged, so onStoreDocument must take the no-op fast path.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty title fragment
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] }, // different body -> bodyChanged
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
// Body is persisted, but the title is NOT included (empty == not
|
||||
// authoritative) and no tree update is broadcast for the title.
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
expect(call[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('body + title change persists both with full body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].lastUpdatedSource).toBe('user');
|
||||
expect(call[3].treeUpdate).toBeDefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('body-only change persists the body without a tree update', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'whatever',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
// No treeUpdate for a body-only save.
|
||||
expect(call[3]).toBeUndefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoadDocument', () => {
|
||||
it('returns early (no DB read) when the document is not empty', async () => {
|
||||
const document = { isEmpty: () => false };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined and does not persist when the page is null', async () => {
|
||||
const document = { isEmpty: () => true };
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
// Both the cheap pre-check and the locked re-read return the same row.
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The locked re-read must take the row lock inside the tx.
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
// Persist must run inside the transaction.
|
||||
expect(call[2]).toBe(trx);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Has Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Hot path: only the cheap lock-free read, no locked re-read, no write.
|
||||
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'T',
|
||||
ydoc: null,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[2]).toBe(trx);
|
||||
// The rebuilt doc carries the body.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
});
|
||||
|
||||
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
|
||||
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
|
||||
// rebuild path), but by the time we hold the lock another process has
|
||||
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
|
||||
const healed = TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
|
||||
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
|
||||
|
||||
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
const lockedRow = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Healed Title',
|
||||
ydoc: healedYdoc,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById
|
||||
.mockResolvedValueOnce(preCheck) // cheap pre-check
|
||||
.mockResolvedValueOnce(lockedRow); // locked re-read
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
|
||||
// seeded -> no clobbering write.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('healed');
|
||||
});
|
||||
|
||||
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
|
||||
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
await expect(
|
||||
ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any),
|
||||
).rejects.toThrow('db down');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
|
||||
expect(result).toBeTruthy();
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import * as Y from 'yjs';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToText,
|
||||
@@ -155,10 +154,6 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
|
||||
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
|
||||
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
|
||||
// runs exactly once (see healUnderLock).
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
@@ -170,193 +165,33 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page.ydoc) {
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
|
||||
const dbState = new Uint8Array(page.ydoc);
|
||||
|
||||
// Legacy pages persisted their title only in the `page.title` column; the
|
||||
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
|
||||
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
|
||||
// only when that fragment is empty AND there is a non-empty column title.
|
||||
let titleSeedNeeded = false;
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
|
||||
} catch (err) {
|
||||
// A malformed title fragment must not break loading; skip the seed.
|
||||
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
|
||||
titleSeedNeeded = false;
|
||||
}
|
||||
|
||||
if (!titleSeedNeeded) {
|
||||
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
|
||||
// title fragment. If the persist fails we must NOT hand out an unpersisted
|
||||
// fresh-client-id seed (it could later duplicate the title), so we fall
|
||||
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
|
||||
// seed. The title just won't render until a later successful heal —
|
||||
// non-fatal, non-corrupting.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
|
||||
err,
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
Y.applyUpdate(doc, dbState);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
|
||||
// title-seeds + persists every legacy page (content set, ydoc null) on its
|
||||
// first open, which neutralizes the duplication trap incrementally. A
|
||||
// proactive one-shot BATCH migration over all such pages could be added
|
||||
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
|
||||
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
|
||||
// exists in this repo yet, so we deliberately avoid a fragile migration.
|
||||
//
|
||||
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
|
||||
// a row lock (see healUnderLock).
|
||||
// if no ydoc state in db convert json in page.content to Ydoc.
|
||||
if (page.content) {
|
||||
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
|
||||
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
|
||||
// returning it would re-arm the duplication trap. A transient DB failure
|
||||
// means the client reconnects and retries: correctness over availability.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
page.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
|
||||
* title fragment, then persist) so it runs exactly ONCE per page, closing the
|
||||
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
|
||||
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
|
||||
* process via openDirectConnection AND the standalone collab process both
|
||||
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
|
||||
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
|
||||
* row FOR UPDATE inside a transaction and re-validating state under the lock:
|
||||
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
|
||||
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
|
||||
* write rolls back and propagates out (the caller then decides refuse vs.
|
||||
* fall-back).
|
||||
*/
|
||||
private async healUnderLock(pageId: string): Promise<Y.Doc> {
|
||||
return executeTx(this.db, async (trx) => {
|
||||
const locked = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
const doc = new Y.Doc();
|
||||
let rebuilt = false;
|
||||
|
||||
if (locked?.ydoc) {
|
||||
// Another process already healed (or the page always had a ydoc): adopt
|
||||
// the healthy persisted state, do NOT rebuild.
|
||||
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
|
||||
} else if (locked?.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
const built = TiptapTransformer.toYdoc(
|
||||
locked.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
|
||||
rebuilt = true;
|
||||
}
|
||||
// else: no ydoc and no content -> a fresh empty doc.
|
||||
|
||||
// Idempotent, emptiness-guarded title seed (safe to call always).
|
||||
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
|
||||
|
||||
if (rebuilt || seeded) {
|
||||
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
|
||||
// error propagates out of executeTx to the caller.
|
||||
await this.pageRepo.updatePage(
|
||||
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
|
||||
}
|
||||
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
|
||||
* pages whose persisted ydoc has no title fragment yet.
|
||||
*
|
||||
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
|
||||
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
|
||||
* fragment would re-introduce the Yjs duplication trap, so we never do it.
|
||||
* Returns true when a seed was applied (so the caller can persist).
|
||||
* Defensive: a malformed title must not break document loading.
|
||||
*/
|
||||
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
|
||||
const trimmed = (title ?? '').trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
if (titleFrag.length !== 0) return false;
|
||||
|
||||
const titleSeed = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
|
||||
this.logger.debug('seeded title fragment from page.title column');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an already-encoded Y.Doc state directly to `page.ydoc`, mirroring the
|
||||
* `pageRepo.updatePage({ ydoc })` write that onStoreDocument uses.
|
||||
*
|
||||
* Used by the gateway's writePageTitle (F1, variant C). A REST/MCP/agent rename
|
||||
* with no live editor writes the new title into the in-memory 'title' fragment,
|
||||
* but onStoreDocument's no-op fast-path (page.title column already equals the
|
||||
* new title) does NOT persist that in-memory fragment, so the stored `page.ydoc`
|
||||
* keeps the OLD title — and a later body edit then reverts the rename (loads the
|
||||
* OLD fragment, sees it differs from the column, overwrites the column back to
|
||||
* OLD). Writing the ydoc here keeps the persisted fragment consistent with the
|
||||
* column so the rename survives.
|
||||
*
|
||||
* Broadcast-safe / no double broadcast: this carries no `treeUpdate`, so the
|
||||
* tree WS + redis listeners (which gate on `treeUpdate`) do NOT re-broadcast the
|
||||
* rename — only PageService.update's own PAGE_UPDATED does. The only extra
|
||||
* side-effect is an idempotent search reindex.
|
||||
*
|
||||
* Idempotent and lock-free, so it is safe whether or not a live editor is
|
||||
* connected: Yjs state is cumulative, so a concurrent onStoreDocument simply
|
||||
* persists a superset of this state later.
|
||||
*/
|
||||
async persistTitleFragmentYdoc(
|
||||
pageId: string,
|
||||
ydocState: Buffer,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.updatePage({ ydoc: ydocState }, pageId);
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
// #355 — time the full store (persist + post-store side effects) into
|
||||
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||
@@ -385,34 +220,7 @@ export class PersistenceExtension implements Extension {
|
||||
this.logger.warn('jsonToText' + err?.['message']);
|
||||
}
|
||||
|
||||
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
|
||||
// (the collaborative title-editor contract with the client). Extract it
|
||||
// defensively: a malformed title fragment must NOT crash the document store.
|
||||
// `hasTitleFragment` distinguishes "the doc actually carries a title
|
||||
// fragment" from "legacy doc with no title fragment" — only the former may
|
||||
// write page.title, so a legacy doc never clobbers the column with ''.
|
||||
let titleText = '';
|
||||
let hasTitleFragment = false;
|
||||
try {
|
||||
const titleFrag = document.get('title', Y.XmlFragment);
|
||||
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
|
||||
if (hasTitleFragment) {
|
||||
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
|
||||
titleText = titleJson ? jsonToText(titleJson).trim() : '';
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('title extraction: ' + err?.['message']);
|
||||
hasTitleFragment = false;
|
||||
}
|
||||
|
||||
let page: Page = null;
|
||||
// Tracks whether the BODY ('default') changed in this store. The heavy
|
||||
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
|
||||
// gated on this so a title-only change does not trigger them.
|
||||
let bodyChanged = false;
|
||||
// Tracks a successful title-only persist so the post-tx contributor folding
|
||||
// (collabHistory.addContributors) runs for the title-only case too.
|
||||
let titleOnlyPersisted = false;
|
||||
const editingUserIds = this.consumeContributors(documentName);
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
@@ -459,88 +267,18 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
|
||||
// Only a populated 'title' fragment may update page.title; compare
|
||||
// against the current column value (treat null as '').
|
||||
//
|
||||
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
|
||||
// can momentarily initialize the 'title' fragment as an EMPTY heading
|
||||
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
|
||||
// BEFORE the server's real-title seed has synced. Writing that '' would
|
||||
// silently wipe a non-empty page.title to "untitled". A wiki page is
|
||||
// never legitimately retitled to empty via this path, so we treat an
|
||||
// empty extracted title as "not authoritative" and never persist it.
|
||||
// The `titleText.length > 0` clause makes this guard apply to BOTH the
|
||||
// title-only branch and the body+title branch below.
|
||||
//
|
||||
// DELIBERATE: this intentionally makes it impossible to retitle a page
|
||||
// to EMPTY via the collab path — a wiki page is never legitimately
|
||||
// empty-titled. If a non-empty-title rule ever needs relaxing or
|
||||
// enforcing differently, the REST UpdatePageDto is the place to validate
|
||||
// the title, not this collab guard.
|
||||
const titleChanged =
|
||||
hasTitleFragment &&
|
||||
titleText.length > 0 &&
|
||||
titleText !== (page.title ?? '');
|
||||
|
||||
// No-op fast path: neither body nor title changed.
|
||||
if (!bodyChanged && !titleChanged) {
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Title-only change: the body is unchanged, so skip the heavy body
|
||||
// history/contributor logic and persist just the new title and the
|
||||
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
|
||||
// used to drop this case entirely, losing the title change.
|
||||
if (!bodyChanged) {
|
||||
// Fold the window's editing users into contributors the same way the
|
||||
// body branch does, so a user who edited ONLY the title is not dropped
|
||||
// from page.contributorIds.
|
||||
const contributorIds = Array.from(
|
||||
new Set([
|
||||
...(page.contributorIds || []),
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
title: titleText,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
contributorIds,
|
||||
// A title-only change is not a body-authorship transition; leave
|
||||
// lastUpdatedSource/aiChatId untouched so the user->agent history
|
||||
// boundary in the body branch is not bypassed.
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
{
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
titleOnlyPersisted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// #206 persist-6 / #248 — store-side empty-guard. A momentarily-empty
|
||||
// live Y.Doc (a client/agent glitch, a bad merge, a transclusion that
|
||||
// emptied) must NOT overwrite non-empty persisted content. The LOAD
|
||||
// path already guards emptiness (onLoadDocument only hydrates from db
|
||||
// when the live doc isEmpty); the STORE path did not, so an empty
|
||||
// serialization was written straight over the page, wiping it
|
||||
// silently. Reached only when the body actually changed (the title-only
|
||||
// fast path above already returned).
|
||||
// silently.
|
||||
//
|
||||
// #251 — the ONE legitimate empty-over-non-empty write is a user who
|
||||
// deliberately clears the page. That intent arrives out-of-band as a
|
||||
@@ -610,8 +348,12 @@ export class PersistenceExtension implements Extension {
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (
|
||||
!isEmptyParagraphDoc(page.content as any) &&
|
||||
humanBaselineMissing
|
||||
) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
@@ -629,27 +371,9 @@ export class PersistenceExtension implements Extension {
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
// Persist the title in the SAME transaction when the title fragment
|
||||
// changed alongside the body.
|
||||
...(titleChanged ? { title: titleText } : {}),
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
// Only attach when the title actually changed; a body-only save must
|
||||
// not trigger a tree broadcast.
|
||||
titleChanged
|
||||
? {
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
@@ -670,8 +394,6 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
// `page` is truthy whenever anything was persisted (body OR title-only), so
|
||||
// the page.updated broadcast fires for a title-only change too.
|
||||
if (page) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
@@ -689,26 +411,14 @@ export class PersistenceExtension implements Extension {
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Record the window's editing users in collab history for a title-only
|
||||
// change too (the body branch does this below, gated on bodyChanged). Key
|
||||
// contributors by the canonical page UUID (page.id), not the doc-name id
|
||||
// which may be a slugId, so they MATCH the PAGE_HISTORY job which is
|
||||
// enqueued with page.id and pops contributors by page.id (#260).
|
||||
if (page && titleOnlyPersisted) {
|
||||
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||
}
|
||||
|
||||
// Body-only side-effects: skip them for a title-only change (body unchanged).
|
||||
if (page && bodyChanged) {
|
||||
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
||||
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
||||
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
||||
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page && bodyChanged) {
|
||||
if (page) {
|
||||
// Key contributors by the page UUID so they MATCH the PAGE_HISTORY job,
|
||||
// which is enqueued with page.id and pops contributors by page.id (#260).
|
||||
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
import {
|
||||
PageEvent,
|
||||
TreeUpdateSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
const treeUpdate: TreeUpdateSnapshot = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
title: 'Renamed',
|
||||
icon: '🚀',
|
||||
};
|
||||
|
||||
describe('PageTreeBridgePublisher', () => {
|
||||
let publisher: PageTreeBridgePublisher;
|
||||
let redis: { publish: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = { publish: jest.fn().mockResolvedValue(1) };
|
||||
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageTreeBridgePublisher,
|
||||
{ provide: RedisService, useValue: redisService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
|
||||
});
|
||||
|
||||
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||
expect(redis.publish).toHaveBeenCalledWith(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(treeUpdate),
|
||||
);
|
||||
});
|
||||
|
||||
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a publish rejection is caught (no throw)', async () => {
|
||||
redis.publish.mockRejectedValueOnce(new Error('redis down'));
|
||||
const errorSpy = jest
|
||||
.spyOn(publisher['logger'], 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
import { PageEvent } from '../../database/listeners/page.listener';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
|
||||
/**
|
||||
* Collab-process half of the cross-process tree-update bridge.
|
||||
*
|
||||
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
|
||||
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
|
||||
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
|
||||
* is no listener in this process to broadcast it — the live tree update would be
|
||||
* lost for 2-process (COLLAB_URL set) deployments.
|
||||
*
|
||||
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
|
||||
* Redis pub/sub channel to the API process, which re-broadcasts it via
|
||||
* `WsTreeService` (the single broadcast authority).
|
||||
*
|
||||
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
|
||||
* API process (where `PageWsListener` already broadcasts the same event locally).
|
||||
* That module placement is what prevents a double broadcast. In single-process
|
||||
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PageTreeBridgePublisher {
|
||||
private readonly logger = new Logger(PageTreeBridgePublisher.name);
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_UPDATED)
|
||||
async onPageUpdated(event: PageEvent): Promise<void> {
|
||||
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
|
||||
// Content-only saves leave `treeUpdate` undefined and are ignored.
|
||||
if (!event.treeUpdate) return;
|
||||
|
||||
try {
|
||||
await this.redis.publish(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(event.treeUpdate),
|
||||
);
|
||||
} catch (err) {
|
||||
// A Redis publish failure must not break the store path.
|
||||
this.logger.error(
|
||||
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { CaslModule } from '../../core/casl/casl.module';
|
||||
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -55,6 +54,6 @@ import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher
|
||||
? [CollaborationController]
|
||||
: []),
|
||||
],
|
||||
providers: [AppService, PageTreeBridgePublisher],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class CollabAppModule {}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
/**
|
||||
* F1 (variant C) — rename durability for a page with an already-persisted Yjs
|
||||
* 'title' fragment and NO live editor (the REST/MCP/agent rename path).
|
||||
*
|
||||
* The bug: PageService.update writes the NEW title to the `page.title` COLUMN,
|
||||
* then calls gateway.writePageTitle, which loads the page's ydoc (fragment =
|
||||
* OLD) and overwrites it to NEW in memory. On disconnect, onStoreDocument sees
|
||||
* titleText(NEW) === column(NEW) → no-op fast-path → it does NOT persist the
|
||||
* in-memory fragment. So `page.ydoc` keeps the OLD title, and a LATER body edit
|
||||
* loads the OLD fragment, sees it differs from the column, and silently reverts
|
||||
* the column back to OLD.
|
||||
*
|
||||
* The fix: writePageTitle persists the 'title' fragment to `page.ydoc` DIRECTLY
|
||||
* (via PersistenceExtension.persistTitleFragmentYdoc) after the transact, so the
|
||||
* persisted fragment and the column stay consistent.
|
||||
*
|
||||
* This test drives the REAL writePageTitle + the REAL onStoreDocument against an
|
||||
* in-memory page row, so it FAILS on the pre-fix no-op behaviour and PASSES after.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'user-1';
|
||||
const OLD_TITLE = 'Old Title';
|
||||
const NEW_TITLE = 'Renamed Title';
|
||||
|
||||
const bodyJson = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
// Build the initial persisted ydoc carrying BOTH a 'title' fragment and a body.
|
||||
const makeInitialYdoc = (title: string, body: any): Buffer => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
Y.applyUpdate(
|
||||
doc,
|
||||
Y.encodeStateAsUpdate(TiptapTransformer.toYdoc(body, 'default', tiptapExtensions)),
|
||||
);
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
};
|
||||
|
||||
// Load a doc from a persisted buffer (mirrors openDirectConnection loading from
|
||||
// persistence when no editor is connected). hocuspocus augments the live doc
|
||||
// with broadcastStateless(); a bare Y.Doc lacks it, so stub it.
|
||||
const loadDoc = (buf: Buffer): Y.Doc => {
|
||||
const doc = new Y.Doc();
|
||||
if (buf) Y.applyUpdate(doc, new Uint8Array(buf));
|
||||
(doc as any).broadcastStateless = jest.fn();
|
||||
return doc;
|
||||
};
|
||||
|
||||
// Read the 'title' fragment text from a persisted buffer.
|
||||
const readTitle = (buf: Buffer): string => {
|
||||
const doc = loadDoc(buf);
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('rename durability (F1 variant C): persisted title fragment survives a body edit', () => {
|
||||
it('persists the renamed title into page.ydoc so a later body edit does not revert it', async () => {
|
||||
// In-memory page row = the DB.
|
||||
const row: any = {
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||
lastUpdatedSource: 'user',
|
||||
title: OLD_TITLE,
|
||||
// content column mirrors the normalized body in the ydoc.
|
||||
content: TiptapTransformer.fromYdoc(
|
||||
loadDoc(makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1'))),
|
||||
'default',
|
||||
),
|
||||
ydoc: makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1')),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({ ...row })),
|
||||
updatePage: jest.fn(async (data: any, _pageId?: string) => {
|
||||
Object.assign(row, data, { updatedAt: new Date() });
|
||||
}),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const noopQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// db whose transaction().execute(fn) runs fn with a trx stub (drives the
|
||||
// real executeTx helper without a database).
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
// Fake hocuspocus: openDirectConnection loads a doc from the CURRENT persisted
|
||||
// ydoc (no live editor) and, on disconnect, runs the real onStoreDocument —
|
||||
// exactly the no-live-editor unload path.
|
||||
const fakeHocuspocus = {
|
||||
openDirectConnection: jest.fn(async (name: string, context: any) => {
|
||||
const liveDoc = loadDoc(row.ydoc);
|
||||
return {
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(liveDoc),
|
||||
disconnect: async () => {
|
||||
await ext.onStoreDocument({
|
||||
documentName: name,
|
||||
document: liveDoc,
|
||||
context,
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const gateway: CollaborationGateway = Object.create(
|
||||
CollaborationGateway.prototype,
|
||||
);
|
||||
(gateway as any).hocuspocus = fakeHocuspocus;
|
||||
(gateway as any).persistenceExtension = ext;
|
||||
|
||||
// --- REST/service rename (no live editor) ---
|
||||
// 1) PageService.update writes the NEW title to the column.
|
||||
await pageRepo.updatePage({ title: NEW_TITLE }, PAGE_ID);
|
||||
// 2) PageService.update syncs the Yjs 'title' fragment.
|
||||
await gateway.writePageTitle(PAGE_ID, NEW_TITLE, {
|
||||
user: { id: USER_ID } as any,
|
||||
});
|
||||
|
||||
// Reload the persisted ydoc: the 'title' fragment must now be NEW.
|
||||
// (Pre-fix this is still OLD — writePageTitle did not persist the fragment.)
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
|
||||
// --- a later body edit must NOT revert the title ---
|
||||
const editDoc = loadDoc(row.ydoc);
|
||||
const frag = editDoc.getXmlFragment('default');
|
||||
const p = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'appended');
|
||||
p.insert(0, [t]);
|
||||
frag.insert(frag.length, [p]);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName,
|
||||
document: editDoc,
|
||||
context: { user: { id: USER_ID } },
|
||||
} as any);
|
||||
|
||||
// The body edit was persisted, and the title stayed NEW in BOTH the column
|
||||
// and the persisted ydoc fragment (pre-fix the column reverts to OLD).
|
||||
expect(row.title).toBe(NEW_TITLE);
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
|
||||
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
|
||||
* so a LATE tab — one that reloaded, or opened after the starter dropped — can
|
||||
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
|
||||
* so far, and then follow the live tail as a normal streamer.
|
||||
*
|
||||
* This is deliberately single-process and best-effort: it holds nothing the DB
|
||||
* does not (the run + assistant row are the source of truth), so a process
|
||||
* restart simply drops in-flight entries and the client falls back to its
|
||||
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
||||
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
||||
*/
|
||||
|
||||
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
||||
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
||||
|
||||
/** Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204). */
|
||||
export const RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
// 2x the replay cap: a just-written 4MB replay burst alone can never trip the
|
||||
// per-subscriber cap (see controller); only a genuinely stalled socket can.
|
||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
|
||||
export interface RunStreamCallbacks {
|
||||
onFrame: (frame: string) => void;
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
export interface RunStreamAttachment {
|
||||
replay: string[];
|
||||
finished: boolean;
|
||||
start(): void; // drain pending frames (order preserved) and go live
|
||||
unsubscribe(): void; // safe to call at any point, idempotent
|
||||
}
|
||||
|
||||
interface Subscriber extends RunStreamCallbacks {
|
||||
started: boolean;
|
||||
pending: string[];
|
||||
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
|
||||
// called in the SAME tick as `attach()` today (see attach), so `pending` never
|
||||
// holds more than one microtask of frames — but the async `attach` signature is
|
||||
// a phase-2 seam: an await between attach and start would let a stalled paused
|
||||
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
|
||||
pendingBytes: number;
|
||||
overflowed: boolean;
|
||||
pendingEnd: boolean;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
runId: string;
|
||||
// The persisted assistant row id of this run (set at bind; undefined if the
|
||||
// seed failed). Used by the attach anchor check (invariant 6).
|
||||
assistantMessageId?: string;
|
||||
frames: string[];
|
||||
bytes: number;
|
||||
overflowed: boolean;
|
||||
finished: boolean;
|
||||
subscribers: Set<Subscriber>;
|
||||
retainTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
||||
private readonly entries = new Map<string, Entry>(); // key: chatId
|
||||
|
||||
/**
|
||||
* Register a fresh entry at the START of a run (before any frame), so a tab
|
||||
* that attaches in the begin->seed window finds an entry to wait on. If an
|
||||
* entry already exists for this chat (a previous, possibly still-live run whose
|
||||
* tee loop is draining), it is terminated MIRRORING the done-path (invariant 3)
|
||||
* so its subscribers are released and its retention timer is cleared; a late
|
||||
* `done` from that old tee then fires against the closed-over old reference and,
|
||||
* thanks to identity checks, never touches this new entry.
|
||||
*/
|
||||
open(chatId: string, runId: string): void {
|
||||
const existing = this.entries.get(chatId);
|
||||
if (existing) {
|
||||
if (existing.retainTimer) {
|
||||
clearTimeout(existing.retainTimer);
|
||||
existing.retainTimer = undefined;
|
||||
}
|
||||
// Started subscribers get exactly one onEnd() and are removed; paused ones
|
||||
// are marked pendingEnd (their start() will end them). finished=true guards
|
||||
// any later done from the old tee loop from double-notifying.
|
||||
this.terminateSubscribers(existing);
|
||||
}
|
||||
this.entries.set(chatId, {
|
||||
runId,
|
||||
frames: [],
|
||||
bytes: 0,
|
||||
overflowed: false,
|
||||
finished: false,
|
||||
subscribers: new Set<Subscriber>(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tee a run's SSE frame stream into its entry (called from consumeSseStream).
|
||||
* No-op with a warning when there is no entry or the entry belongs to a
|
||||
* different run (invariant 1). The reader loop is fire-and-forget: the tee
|
||||
* branch outlives the client socket by design.
|
||||
*/
|
||||
bind(
|
||||
chatId: string,
|
||||
runId: string,
|
||||
assistantMessageId: string | undefined,
|
||||
stream: ReadableStream<string>,
|
||||
): void {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.runId !== runId) {
|
||||
// Invariant 1: only the matching run may mutate the entry.
|
||||
this.logger.warn(
|
||||
`bind: no matching run-stream entry for chat=${chatId} run=${runId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
entry.assistantMessageId = assistantMessageId;
|
||||
const reader = stream.getReader();
|
||||
const pump = async (): Promise<void> => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
this.ingestFrame(entry, value);
|
||||
}
|
||||
this.finalizeEntry(chatId, entry);
|
||||
} catch {
|
||||
// A read error is a terminal event too — release subscribers.
|
||||
this.finalizeEntry(chatId, entry);
|
||||
}
|
||||
};
|
||||
void pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
||||
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
||||
* on runId (invariant 1); the shared terminal path is idempotent.
|
||||
*/
|
||||
abortEntry(chatId: string, runId: string): void {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.runId !== runId) return;
|
||||
this.finalizeEntry(chatId, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a run's stream. Async only for the phase-2 Redis seam — the body
|
||||
* runs synchronously so the replay snapshot and the subscriber registration
|
||||
* happen in ONE tick with no await between them (invariant 4): a frame ingested
|
||||
* concurrently cannot slip into the gap and be lost or duplicated.
|
||||
*
|
||||
* Returns null (-> the caller answers 204) when:
|
||||
* - there is no entry, or it overflowed (replay is gone);
|
||||
* - expect=live with an anchor that does not match this run's assistant id
|
||||
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
|
||||
* - the run finished and the caller did not expect a live tail.
|
||||
* A finished run with expect=live yields a replay-only attachment (no
|
||||
* subscriber registered). Otherwise a paused subscriber is registered and the
|
||||
* caller replays `replay`, then calls start() to drain and go live.
|
||||
*/
|
||||
async attach(
|
||||
chatId: string,
|
||||
expectLive: boolean,
|
||||
anchor: string | undefined,
|
||||
cb: RunStreamCallbacks,
|
||||
): Promise<RunStreamAttachment | null> {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.overflowed) return null;
|
||||
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
||||
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
||||
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
|
||||
if (entry.finished && !expectLive) return null;
|
||||
if (entry.finished && expectLive) {
|
||||
// Replay-only: the run is done, no subscriber is registered.
|
||||
return {
|
||||
replay: entry.frames.slice(),
|
||||
finished: true,
|
||||
start: () => undefined,
|
||||
unsubscribe: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const sub: Subscriber = {
|
||||
onFrame: cb.onFrame,
|
||||
onEnd: cb.onEnd,
|
||||
started: false,
|
||||
pending: [],
|
||||
pendingBytes: 0,
|
||||
overflowed: false,
|
||||
pendingEnd: false,
|
||||
};
|
||||
entry.subscribers.add(sub);
|
||||
// Snapshot in the SAME synchronous block as the registration (invariant 4).
|
||||
const replay = entry.frames.slice();
|
||||
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
|
||||
// returns — no await between them. While a subscriber is paused, every frame
|
||||
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
|
||||
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
|
||||
// that contract is ever broken (e.g. the phase-2 Redis await seam).
|
||||
return {
|
||||
replay,
|
||||
finished: false,
|
||||
start: () => {
|
||||
if (sub.overflowed) {
|
||||
// The pending buffer overflowed while paused: end the stream instead of
|
||||
// replaying a partial (a 204-equivalent post-attach degrade).
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
return;
|
||||
}
|
||||
// Deliver frames buffered while paused, in order, then go live.
|
||||
for (const frame of sub.pending) {
|
||||
try {
|
||||
sub.onFrame(frame);
|
||||
} catch {
|
||||
entry.subscribers.delete(sub);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sub.pending = [];
|
||||
sub.started = true;
|
||||
if (sub.pendingEnd) {
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
},
|
||||
unsubscribe: () => {
|
||||
entry.subscribers.delete(sub);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.retainTimer) clearTimeout(entry.retainTimer);
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
|
||||
private ingestFrame(entry: Entry, frame: string): void {
|
||||
entry.bytes += Buffer.byteLength(frame);
|
||||
if (!entry.overflowed) {
|
||||
entry.frames.push(frame);
|
||||
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
|
||||
// The crossing frame was already counted AND (below) fanned out; only the
|
||||
// replay buffer is dropped. After overflow no more frames are buffered,
|
||||
// but live fan-out continues.
|
||||
entry.overflowed = true;
|
||||
entry.frames = [];
|
||||
this.logger.warn(
|
||||
`run-stream buffer overflow for run=${entry.runId}; ` +
|
||||
`late attach will 204 until the run ends`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const sub of entry.subscribers) {
|
||||
if (sub.started) {
|
||||
try {
|
||||
sub.onFrame(frame);
|
||||
} catch {
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
} else {
|
||||
sub.pending.push(frame);
|
||||
sub.pendingBytes += Buffer.byteLength(frame);
|
||||
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||
// The paused subscriber's buffer overflowed — only possible if start()
|
||||
// was delayed past the same-tick contract (the phase-2 await seam).
|
||||
// Drop it rather than buffer the whole run; on start() it degrades to an
|
||||
// immediate end (a 204-equivalent) instead of replaying a partial.
|
||||
sub.overflowed = true;
|
||||
sub.pending = [];
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared terminal path for done / read-error / external-abort. Idempotent: a
|
||||
* second call (already finished) is a no-op, so an open()-replaced or
|
||||
* abort-then-done entry is never double-armed or double-ended.
|
||||
*/
|
||||
private finalizeEntry(chatId: string, entry: Entry): void {
|
||||
if (entry.finished) return;
|
||||
this.terminateSubscribers(entry);
|
||||
const timer = setTimeout(() => {
|
||||
// Invariant 2: only delete OUR entry (a replacement may already own the key).
|
||||
if (this.entries.get(chatId) === entry) this.entries.delete(chatId);
|
||||
}, RUN_STREAM_RETAIN_FINISHED_MS);
|
||||
timer.unref?.();
|
||||
entry.retainTimer = timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the entry finished and release its subscribers, mirroring the done-path:
|
||||
* started subscribers get exactly one onEnd() and are removed; paused ones are
|
||||
* flagged pendingEnd so their start() ends them. Deleting the current element
|
||||
* during Set iteration is safe.
|
||||
*/
|
||||
private terminateSubscribers(entry: Entry): void {
|
||||
entry.finished = true;
|
||||
for (const sub of entry.subscribers) {
|
||||
if (sub.started) {
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
} else {
|
||||
sub.pendingEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
AiChatStreamRegistryService,
|
||||
RUN_STREAM_MAX_BUFFER_BYTES,
|
||||
RUN_STREAM_RETAIN_FINISHED_MS,
|
||||
RunStreamCallbacks,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
|
||||
/**
|
||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
|
||||
* is the whole of the resumable-transport contract: replay ordering, paused ->
|
||||
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
|
||||
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
|
||||
* the issue's task 1.5 has a test here.
|
||||
*/
|
||||
|
||||
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
||||
function makePushStream(): {
|
||||
stream: ReadableStream<string>;
|
||||
push: (f: string) => void;
|
||||
close: () => void;
|
||||
error: (e?: unknown) => void;
|
||||
} {
|
||||
let controller!: ReadableStreamDefaultController<string>;
|
||||
const stream = new ReadableStream<string>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
});
|
||||
return {
|
||||
stream,
|
||||
push: (f) => controller.enqueue(f),
|
||||
close: () => controller.close(),
|
||||
error: (e) => controller.error(e ?? new Error('read error')),
|
||||
};
|
||||
}
|
||||
|
||||
// Let the fire-and-forget pump drain queued frames (reader.read() resolves on a
|
||||
// macrotask boundary for an already-enqueued value).
|
||||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function collector(): {
|
||||
cb: RunStreamCallbacks;
|
||||
frames: string[];
|
||||
ended: () => number;
|
||||
} {
|
||||
const frames: string[] = [];
|
||||
let ends = 0;
|
||||
return {
|
||||
frames,
|
||||
ended: () => ends,
|
||||
cb: {
|
||||
onFrame: (f) => frames.push(f),
|
||||
onEnd: () => {
|
||||
ends += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AiChatStreamRegistryService', () => {
|
||||
const CHAT = 'chat-1';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AiChatStreamRegistryService();
|
||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registry.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('replays frames in arrival order (live attach)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
src.push('c');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, false, undefined, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a', 'b', 'c']);
|
||||
expect(att!.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('late attach gets the full prefix as replay plus the live tail', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
att.start();
|
||||
// Live tail arrives after start().
|
||||
src.push('c');
|
||||
src.push('d');
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['c', 'd']);
|
||||
});
|
||||
|
||||
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a']);
|
||||
src.push('b'); // arrives while paused -> pending
|
||||
src.push('c');
|
||||
await flush();
|
||||
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
||||
att.start(); // drains pending in order
|
||||
expect(c.frames).toEqual(['b', 'c']);
|
||||
src.push('d'); // now live
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
// Terminate the run while the subscriber is still paused.
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
expect(c.ended()).toBe(0); // paused: not ended yet
|
||||
att.start();
|
||||
expect(c.ended()).toBe(1); // start() drains + ends
|
||||
});
|
||||
|
||||
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
|
||||
expect(att.finished).toBe(true);
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
|
||||
// zero subscribers.
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(0);
|
||||
att.start();
|
||||
expect(c.frames).toEqual([]);
|
||||
});
|
||||
|
||||
it('finished WITHOUT expect=live returns null', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const c = collector();
|
||||
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
||||
expect(
|
||||
await registry.attach(CHAT, true, 'assist-1', c.cb),
|
||||
).toBeNull();
|
||||
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
||||
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('matching anchor with expect=live attaches', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A live (started) subscriber attached before the flood.
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
att.start();
|
||||
|
||||
const oneMb = 'x'.repeat(1024 * 1024);
|
||||
// 5 x 1MB = 5MB > 4MB cap; the 5th frame is the one that crosses.
|
||||
for (let i = 0; i < 5; i++) src.push(oneMb + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.overflowed).toBe(true);
|
||||
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
|
||||
// The live subscriber received ALL 5 frames, including the crossing one.
|
||||
expect(c.frames).toHaveLength(5);
|
||||
expect(c.frames[4]).toBe(oneMb + 4);
|
||||
|
||||
// A NEW attach after overflow gets null (replay buffer is gone).
|
||||
const c2 = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
|
||||
const a = collector();
|
||||
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
|
||||
// B: live (started) — its delivery must be unaffected by A's overflow.
|
||||
const b = collector();
|
||||
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
|
||||
attB.start();
|
||||
|
||||
const oneMb = 'x'.repeat(1024 * 1024);
|
||||
// 9 x 1MB = 9MB > 8MB per-subscriber cap; A's pending overflows, B streams live.
|
||||
for (let i = 0; i < 9; i++) src.push(oneMb + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// A was dropped from the subscriber set on overflow; B (started) remains.
|
||||
expect(entry.subscribers.size).toBe(1);
|
||||
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
|
||||
// B received every frame live (delivery unaffected by A's overflow).
|
||||
expect(b.frames).toHaveLength(9);
|
||||
|
||||
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
|
||||
attA.start();
|
||||
expect(a.frames).toEqual([]);
|
||||
expect(a.ended()).toBe(1);
|
||||
});
|
||||
|
||||
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
att.start(); // started subscriber on run-1
|
||||
|
||||
// run-2 starts on the same chat while run-1's tee is still reading.
|
||||
registry.open(CHAT, 'run-2');
|
||||
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
|
||||
|
||||
const newEntry = (registry as any).entries.get(CHAT);
|
||||
expect(newEntry.runId).toBe('run-2');
|
||||
expect(newEntry.finished).toBe(false);
|
||||
|
||||
// The old tee now completes: its late done must NOT double-end nor delete the
|
||||
// new entry.
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
expect(c.ended()).toBe(1); // still exactly one
|
||||
const still = (registry as any).entries.get(CHAT);
|
||||
expect(still).toBe(newEntry);
|
||||
expect(still.runId).toBe('run-2');
|
||||
});
|
||||
|
||||
it('bind with a foreign runId is a no-op (invariant 1)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'WRONG-run', 'assist-x', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
|
||||
expect(entry.frames).toEqual([]);
|
||||
expect(entry.assistantMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('abortEntry with a foreign runId is a no-op (invariant 1)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'WRONG-run');
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
const bad = collector();
|
||||
const badAtt = (await registry.attach(CHAT, false, undefined, {
|
||||
onFrame: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
onEnd: bad.cb.onEnd,
|
||||
}))!;
|
||||
badAtt.start();
|
||||
|
||||
const good = collector();
|
||||
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
|
||||
goodAtt.start();
|
||||
|
||||
src.push('a'); // bad throws on this frame -> ejected
|
||||
src.push('b'); // good still receives both
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
|
||||
expect(good.frames).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retention + replace timer behavior. Fake timers, and entries are finalized via
|
||||
* the synchronous abortEntry() path so no stream pump / microtask juggling is
|
||||
* needed.
|
||||
*/
|
||||
describe('AiChatStreamRegistryService retention timers', () => {
|
||||
const CHAT = 'chat-r';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
registry = new AiChatStreamRegistryService();
|
||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registry.onModuleDestroy();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('a finished entry is removed after the retention window', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
|
||||
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
|
||||
// Simulate the race where the key was replaced without clearing A's timer.
|
||||
const sentinel = { marker: true };
|
||||
(registry as any).entries.set(CHAT, sentinel);
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
|
||||
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('open() over a retained entry clears its timer and the successor survives', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
|
||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
||||
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.runId).toBe('run-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type {
|
||||
RunStreamAttachment,
|
||||
RunStreamCallbacks,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #184 phase 1.5 attach endpoint
|
||||
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
||||
* registry is mocked so this exercises ONLY the controller's replay/live/204/
|
||||
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
|
||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
||||
* streamRegistry, environment).
|
||||
*/
|
||||
describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeRawRes() {
|
||||
const raw: any = {
|
||||
writableEnded: false,
|
||||
writableLength: 0,
|
||||
destroyed: false,
|
||||
written: [] as string[],
|
||||
head: null as any,
|
||||
write: jest.fn((f: string) => {
|
||||
raw.written.push(f);
|
||||
return true;
|
||||
}),
|
||||
writeHead: jest.fn((code: number, headers: any) => {
|
||||
raw.head = { code, headers };
|
||||
return raw;
|
||||
}),
|
||||
flushHeaders: jest.fn(),
|
||||
end: jest.fn(() => {
|
||||
raw.writableEnded = true;
|
||||
}),
|
||||
destroy: jest.fn(() => {
|
||||
raw.destroyed = true;
|
||||
}),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
};
|
||||
const res: any = {
|
||||
raw,
|
||||
status: jest.fn(() => res),
|
||||
send: jest.fn(),
|
||||
hijack: jest.fn(),
|
||||
};
|
||||
return { res, raw };
|
||||
}
|
||||
|
||||
function makeReq(destroyed = false) {
|
||||
const handlers: Record<string, () => void> = {};
|
||||
const raw: any = {
|
||||
destroyed,
|
||||
once: jest.fn((ev: string, fn: () => void) => {
|
||||
handlers[ev] = fn;
|
||||
}),
|
||||
};
|
||||
return { req: { raw } as any, raw, fireClose: () => handlers['close']?.() };
|
||||
}
|
||||
|
||||
function makeAttachment(
|
||||
over: Partial<RunStreamAttachment> = {},
|
||||
): RunStreamAttachment {
|
||||
return {
|
||||
replay: [],
|
||||
finished: false,
|
||||
start: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makeController(opts: {
|
||||
chat?: unknown;
|
||||
attachment?: RunStreamAttachment | null;
|
||||
}) {
|
||||
const aiChatRepo = { findById: jest.fn().mockResolvedValue(opts.chat) };
|
||||
let capturedCb: RunStreamCallbacks | undefined;
|
||||
const streamRegistry = {
|
||||
attach: jest.fn(
|
||||
(
|
||||
_chatId: string,
|
||||
_live: boolean,
|
||||
_anchor: string | undefined,
|
||||
cb: RunStreamCallbacks,
|
||||
) => {
|
||||
capturedCb = cb;
|
||||
return Promise.resolve(
|
||||
opts.attachment === undefined ? makeAttachment() : opts.attachment,
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
const environment = { isAiChatResumableStreamEnabled: () => true };
|
||||
const controller = new AiChatController(
|
||||
{} as never, // aiChatService
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
streamRegistry as never,
|
||||
environment as never,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
aiChatRepo,
|
||||
streamRegistry,
|
||||
getCb: () => capturedCb!,
|
||||
};
|
||||
}
|
||||
|
||||
const owned = { id: 'c1', creatorId: 'u1' };
|
||||
|
||||
it('owner-gates: a foreign chat throws ForbiddenException and never attaches', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await expect(
|
||||
controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(streamRegistry.attach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 204 when the registry has nothing to resume (no entry / finished / anchor-mismatch)', async () => {
|
||||
const { controller } = makeController({ chat: owned, attachment: null });
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
expect(res.send).toHaveBeenCalled();
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('threads expect=live and anchor through to the registry', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'anchor-1',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
true,
|
||||
'anchor-1',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes expect=false when the query is absent', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
false,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hijacks, writes headers + replay, registers a close cleanup, then goes live', async () => {
|
||||
const start = jest.fn();
|
||||
const attachment = makeAttachment({
|
||||
replay: ['f1', 'f2'],
|
||||
finished: false,
|
||||
start,
|
||||
});
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res.hijack).toHaveBeenCalled();
|
||||
expect(raw.writeHead).toHaveBeenCalledWith(
|
||||
200,
|
||||
expect.objectContaining({ 'content-type': 'text/event-stream' }),
|
||||
);
|
||||
expect(raw.written).toEqual(['f1', 'f2']); // replay
|
||||
expect(start).toHaveBeenCalled(); // go live after replay
|
||||
expect(req.raw.once).toHaveBeenCalledWith('close', expect.any(Function));
|
||||
});
|
||||
|
||||
it('finished replay ends the response immediately without going live', async () => {
|
||||
const start = jest.fn();
|
||||
const attachment = makeAttachment({
|
||||
replay: ['f1'],
|
||||
finished: true,
|
||||
start,
|
||||
});
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'a1',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(raw.written).toEqual(['f1']);
|
||||
expect(raw.end).toHaveBeenCalled();
|
||||
expect(start).not.toHaveBeenCalled(); // finished -> returns before start()
|
||||
});
|
||||
|
||||
it('a close during the awaits (req.raw.destroyed) unsubscribes and writes nothing', async () => {
|
||||
const attachment = makeAttachment({ replay: ['f1'] });
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq(true); // destroyed already at registration time
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
||||
expect(raw.writeHead).not.toHaveBeenCalled();
|
||||
expect(raw.written).toEqual([]);
|
||||
});
|
||||
|
||||
it('the registered close handler unsubscribes the attachment', async () => {
|
||||
const attachment = makeAttachment({ replay: [] });
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res } = makeRawRes();
|
||||
const { req, fireClose } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(attachment.unsubscribe).not.toHaveBeenCalled();
|
||||
fireClose(); // socket closed
|
||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onFrame destroys the socket when the buffered length exceeds the cap', async () => {
|
||||
const attachment = makeAttachment({ replay: [] });
|
||||
const { controller, getCb } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
const cb = getCb();
|
||||
// Normal live frame writes.
|
||||
raw.writableLength = 0;
|
||||
cb.onFrame('live-1');
|
||||
expect(raw.written).toContain('live-1');
|
||||
// A stalled socket over the cap is destroyed instead of buffering.
|
||||
raw.writableLength = SUBSCRIBER_MAX_BUFFERED_BYTES + 1;
|
||||
raw.write.mockClear();
|
||||
cb.onFrame('too-much');
|
||||
expect(raw.destroy).toHaveBeenCalled();
|
||||
expect(raw.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The begin-hook `open()` flag gate (#184 phase 1.5). `open()` lives ONLY in the
|
||||
* stream() begin-hook, gated on the resumable flag. If it regressed, a flag-off
|
||||
* turn would create an EMPTY registry entry (never bound, never finished) and a
|
||||
* later attach would find a non-null paused attachment -> a hung SSE that never
|
||||
* gets a frame and never ends, instead of a clean 204. These drive stream() only
|
||||
* far enough to capture the runHooks it hands to the service, then invoke the
|
||||
* begin-hook and assert whether the registry was opened.
|
||||
*/
|
||||
describe('AiChatController begin-hook open() flag gate (#184 phase 1.5)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: true, autonomousRuns: true } },
|
||||
} as unknown as Workspace;
|
||||
|
||||
function makeController(opts: { resumable: boolean }) {
|
||||
let capturedArgs: any;
|
||||
const aiChatService = {
|
||||
resolveRoleForRequest: jest.fn(async () => null),
|
||||
getChatModel: jest.fn(async () => ({})),
|
||||
stream: jest.fn(async (args: any) => {
|
||||
capturedArgs = args;
|
||||
}),
|
||||
};
|
||||
const aiChatRunService = {
|
||||
getActiveForChat: jest.fn(async () => undefined),
|
||||
beginRun: jest.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
})),
|
||||
};
|
||||
const streamRegistry = { open: jest.fn(), attach: jest.fn() };
|
||||
const environment = {
|
||||
isAiChatResumableStreamEnabled: () => opts.resumable,
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
aiChatRunService as never,
|
||||
{} as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
streamRegistry as never,
|
||||
environment as never,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
streamRegistry,
|
||||
aiChatRunService,
|
||||
getRunHooks: () => capturedArgs?.runHooks,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReqRes() {
|
||||
const req: any = {
|
||||
raw: { sessionId: 'sess-1', once: jest.fn() },
|
||||
body: {
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const res: any = {
|
||||
raw: { once: jest.fn(), on: jest.fn(), headersSent: false, writableEnded: false },
|
||||
hijack: jest.fn(),
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
it('flag OFF: the begin-hook does NOT open a registry entry (no hung empty entry)', async () => {
|
||||
const { controller, streamRegistry, aiChatRunService, getRunHooks } =
|
||||
makeController({ resumable: false });
|
||||
const { req, res } = makeReqRes();
|
||||
await controller.stream(req, res, user, workspace);
|
||||
|
||||
const runHooks = getRunHooks();
|
||||
expect(runHooks).toBeDefined();
|
||||
const handle = await runHooks.begin('chat-1');
|
||||
// The run still begins (the durable-run feature is independent of resume)...
|
||||
expect(aiChatRunService.beginRun).toHaveBeenCalled();
|
||||
expect(handle).toEqual({ runId: 'run-1', signal: expect.anything() });
|
||||
// ...but with the flag off the registry entry is NEVER opened.
|
||||
expect(streamRegistry.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flag ON: the begin-hook opens the registry entry with (chatId, runId)', async () => {
|
||||
const { controller, streamRegistry, getRunHooks } = makeController({
|
||||
resumable: true,
|
||||
});
|
||||
const { req, res } = makeReqRes();
|
||||
await controller.stream(req, res, user, workspace);
|
||||
|
||||
const runHooks = getRunHooks();
|
||||
await runHooks.begin('chat-1');
|
||||
expect(streamRegistry.open).toHaveBeenCalledWith('chat-1', 'run-1');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user