Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a990ebd604 | |||
| 94ca907476 | |||
| fff772cbe2 | |||
| 16c2a4b623 | |||
| 58f13a7efd | |||
| 2bb48f841f | |||
| daa96eb132 | |||
| 4c5230d677 | |||
| 50ca27c5d4 | |||
| de25c258f9 | |||
| afc50ead38 | |||
| ec932e89a3 | |||
| 490d7965a1 | |||
| 32c9361969 | |||
| c30f910d66 | |||
| 7007f6bcf9 | |||
| 4bf06c68cf | |||
| af44736fb9 | |||
| 2a4d1acfd7 | |||
| 11eb87d58a | |||
| f2c8aa70f3 | |||
| 661ea8ba07 | |||
| 059edccb64 | |||
| 693a9a350b | |||
| 79b2da686b | |||
| ed3a8f8174 | |||
| 8503ff1f3d | |||
| c18b4c132c | |||
| 3163f50c98 | |||
| 3e81f64415 | |||
| d36021a111 | |||
| 1f2999e5ad | |||
| c674db2b2f | |||
| 44cdb5a4c3 | |||
| 03af65dd06 | |||
| dead5fa8a8 | |||
| 5b17503df7 | |||
| 46bb55dbd1 | |||
| 8b34a428f4 | |||
| e236782260 | |||
| 12ed3a0332 | |||
| b3cc6a7ff8 | |||
| c42cb42413 | |||
| ae3dfd8de6 | |||
| 41030b2c78 | |||
| 515a1aaf1d | |||
| 03eafa6c68 | |||
| a42f1ead48 | |||
| b7a3ec227d | |||
| 74387bb047 | |||
| 6ec2981743 | |||
| dd1fe90515 | |||
| 846341d7d4 | |||
| 32e10ca6d3 | |||
| 74d212cfd3 | |||
| c96fafc4ad | |||
| 9a6389133b | |||
| 64566e9327 | |||
| 10a4326fbf | |||
| 68409a8ae9 | |||
| fc624f5a4b | |||
| 0b8497e496 | |||
| 433252bdb1 | |||
| be70bd2e8e | |||
| 2f8c5d9a98 | |||
| 6f81067f4d | |||
| 55e8f61b3c | |||
| 69e04349a0 | |||
| ab133fa0d0 | |||
| d42ca8dc57 | |||
| 25a31e6c0d | |||
| 0af7eb30c3 | |||
| 9a94c8df18 | |||
| 3550bfa411 | |||
| a0ecf21cb5 | |||
| d81781aa27 | |||
| daca5ce8d6 | |||
| daeeb1f3f2 | |||
| a919c79cb2 | |||
| 798a81abfe | |||
| 8abde99611 | |||
| d608311cae | |||
| 6dad309b51 | |||
| f34542c881 | |||
| b038d96708 | |||
| f5bbfdb2d4 | |||
| 875f6ba9c5 | |||
| 7741821ee3 | |||
| 48a074e0d2 | |||
| 17e3b3d882 | |||
| 801add63e8 | |||
| 7ad072ba2c | |||
| 216499d57b | |||
| b041944a23 | |||
| 401d62119c | |||
| 4f8563b5b5 | |||
| 5bd5995ef0 | |||
| 575125a5dc | |||
| bb5abf29a6 | |||
| 51260793c0 | |||
| c765483947 | |||
| 45ff922dd4 | |||
| e3ca2dc1d5 | |||
| f919ced8c9 | |||
| 4109b2ef7f | |||
| 6b27ff0652 | |||
| e133b86982 | |||
| 579c82617b | |||
| 173f35e473 | |||
| 696b96ac18 | |||
| a96ca8e26b | |||
| f84386f24a | |||
| c0c44fddb9 | |||
| 91e58e3c9f | |||
| 8d062728e5 | |||
| f1cffc2d0f | |||
| bb9a6fd765 | |||
| 1c083fbd3d | |||
| 52763998d3 | |||
| daf728676f | |||
| bc433d12e6 | |||
| 1d8e3444f4 | |||
| c50f5b66bb | |||
| 51d44b6061 | |||
| 6247585b66 | |||
| 141ebb4864 | |||
| 045a0afaad | |||
| be433d40f0 | |||
| e56a05926d | |||
| eae7640f30 | |||
| 0099ba272d | |||
| 826bb491ca | |||
| 7be49c1280 | |||
| c7073b62d1 | |||
| 11b2c55485 | |||
| 3a344626db | |||
| 31f51eaa47 | |||
| b66929714f | |||
| a09935aa29 | |||
| 047433595e | |||
| 9e95412695 | |||
| 2fa86e2a33 | |||
| e3eece78c3 | |||
| e1b8ef5b8b | |||
| 5adcd2f08b | |||
| 1e7bd1f9d2 |
@@ -251,6 +251,16 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 120000 (2 min).
|
# Default 120000 (2 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||||
|
|
||||||
|
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
||||||
|
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
||||||
|
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
||||||
|
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
||||||
|
# guaranteed to actually flip when an operator flips it during an incident. When
|
||||||
|
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
||||||
|
# the api-key management endpoints return 404. The resolved state is logged at boot
|
||||||
|
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
||||||
|
# API_KEYS_ENABLED=true
|
||||||
|
|
||||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||||
# small for a long AI-chat research turn: the client resends the FULL message
|
# small for a long AI-chat research turn: the client resends the FULL message
|
||||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||||
@@ -302,6 +312,16 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||||
# registry is process-local).
|
# registry is process-local).
|
||||||
# AI_CHAT_RESUMABLE_STREAM=false
|
# AI_CHAT_RESUMABLE_STREAM=false
|
||||||
|
#
|
||||||
|
# Per-run replay ring cap (#491), in BYTES, for the resumable-stream registry
|
||||||
|
# above. The registry buffers the run's recent SSE tail so a reopened tab can
|
||||||
|
# attach and continue from the step it already persisted; the ring is bounded and
|
||||||
|
# rotates on every confirmed step-persist. This caps the un-persisted tail between
|
||||||
|
# rotations — an overflow evicts the oldest frames and a late attach falls back to
|
||||||
|
# 204 -> degraded poll, so correctness never depends on the size. Default 4194304
|
||||||
|
# (4MB); a 0/invalid value falls back to the default. The per-subscriber backpressure
|
||||||
|
# cap is derived as 2x this value. Only meaningful with AI_CHAT_RESUMABLE_STREAM on.
|
||||||
|
# AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES=4194304
|
||||||
|
|
||||||
# --- Run lifecycle tunables (#487) ---
|
# --- Run lifecycle tunables (#487) ---
|
||||||
# These govern the universal run machinery (every turn is now a first-class run,
|
# These govern the universal run machinery (every turn is now a first-class run,
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ jobs:
|
|||||||
needs: [test, e2e-server, e2e-mcp, build]
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
# Image boot-smoke (issue #476): every other job tests code from the working
|
||||||
|
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
||||||
|
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
||||||
|
# crash-loop, runtime module missing from the image, wrong static-asset
|
||||||
|
# headers). The services below back a smoke boot of the exact image right
|
||||||
|
# before it is pushed; a smoke failure blocks the push.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -82,6 +114,37 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Load the image into the local docker daemon so it can be booted (the
|
||||||
|
# push step below exports straight to the registry and leaves nothing
|
||||||
|
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
||||||
|
# IDENTICAL to the push step's build-args — same cache scope + same args
|
||||||
|
# means the layers are reused and the image we smoke IS the image we push.
|
||||||
|
- name: Build image for smoke (load, no push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: gitmost:smoke
|
||||||
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
|
||||||
|
# Boot-smoke the exact image against the job services (see the comment on
|
||||||
|
# `services:` above): health (startup migrator), auth/setup, client dist
|
||||||
|
# served, immutable + brotli asset headers. Fails the job (and therefore
|
||||||
|
# the push) on any miss.
|
||||||
|
- name: Smoke the built image
|
||||||
|
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
||||||
|
|
||||||
|
# The smoke script leaves the container running on failure precisely so
|
||||||
|
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
||||||
|
- name: Dump smoke container log on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
||||||
|
|
||||||
- name: Build and push develop image
|
- name: Build and push develop image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -163,6 +226,13 @@ jobs:
|
|||||||
- name: Build mcp
|
- name: Build mcp
|
||||||
run: pnpm --filter @docmost/mcp build
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
|
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
|
||||||
|
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
|
||||||
|
# so build it here or tsc fails with TS2307 Cannot find module
|
||||||
|
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
|
||||||
|
- name: Build token-estimate
|
||||||
|
run: pnpm --filter @docmost/token-estimate build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,17 @@ jobs:
|
|||||||
exit "$FAILED"
|
exit "$FAILED"
|
||||||
|
|
||||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
# reproducing seed into property-output.txt. File a dedup-guarded issue.
|
||||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
#
|
||||||
# the next step under a different title) can never poison this dedup.
|
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
||||||
|
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
||||||
|
# single open issue swallow every OTHER counterexample (a different bug B whose
|
||||||
|
# title shares the prefix would be treated as a duplicate and stay silent until
|
||||||
|
# the first issue is closed). Hashing the shrunk example instead means two
|
||||||
|
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
||||||
|
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
||||||
|
# step (below) still keys on its own distinct title, so it can never poison
|
||||||
|
# this dedup either.
|
||||||
- name: File counterexample issue
|
- name: File counterexample issue
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
@@ -146,25 +154,48 @@ jobs:
|
|||||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
||||||
|
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
||||||
|
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
||||||
|
# that reach the same bug — unlike the seed, path, or shrink count (which
|
||||||
|
# precede/follow this block and vary run-to-run) and unlike the whole
|
||||||
|
# output (which embeds those varying parts). Hashing THIS is what makes the
|
||||||
|
# dedup identity the bug itself rather than an incidental run detail.
|
||||||
|
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
||||||
|
if [ -z "$CE_TEXT" ]; then
|
||||||
|
# No parseable shrunk block (unexpected — the signature check above
|
||||||
|
# already confirmed fast-check output). Fall back to the reproducing
|
||||||
|
# seed so we still emit a stable identity instead of silently deduping.
|
||||||
|
CE_TEXT="seed:${FAIL_SEED}"
|
||||||
|
fi
|
||||||
|
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
||||||
|
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
||||||
|
# Machine-readable marker embedded in the issue body; the open-issue search
|
||||||
|
# below matches on it (and on the hash in the title) so identity travels
|
||||||
|
# with the issue regardless of any human title edits.
|
||||||
|
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
||||||
|
export CE_HASH CE_MARKER
|
||||||
|
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
||||||
|
|
||||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
||||||
# prefix already exists. A failure of this check must NOT block creation.
|
# carries this exact hash (in its title or its body marker). A different
|
||||||
|
# counterexample has a different hash and is NOT deduped. A failure of this
|
||||||
|
# check must NOT block creation.
|
||||||
EXISTING=""
|
EXISTING=""
|
||||||
if EXISTING=$(curl -sS \
|
if EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
if printf '%s' "$EXISTING" \
|
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
|
| 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 h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
||||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
echo "An open issue for counterexample ${CE_HASH} already exists — skipping creation."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||||
# interpolate the counterexample into JSON).
|
# 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' \
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- counterexample hash: `%s`\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\n%s\n' \
|
||||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
'{title: $title, body: $body}' > payload.json
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|||||||
+49
-13
@@ -25,37 +25,65 @@ jobs:
|
|||||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
# the PR so the migration is renamed to a current timestamp before merge.
|
||||||
# runs for pull_request events (needs a base branch to diff against).
|
# Runs for pull_request (diff against the base branch) AND for push (#476
|
||||||
|
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
||||||
|
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
||||||
|
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
||||||
|
# nothing to diff against and still skips the job.
|
||||||
migration-order:
|
migration-order:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history for the base-branch diff)
|
- name: Checkout (full history for the base diff)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Added migrations must sort after the newest on the base branch
|
- name: Added migrations must sort after the newest on the base
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.base_ref }}
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
BEFORE_SHA: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
MIG_DIR="apps/server/src/database/migrations"
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
# break the merge-base when the base has moved ahead of the PR merge —
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
BASE="origin/${TARGET_BRANCH}"
|
||||||
|
else
|
||||||
|
# push event: compare against the pre-push tip of the branch.
|
||||||
|
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "::notice::branch creation push — nothing to compare"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# The before-SHA is not in the clone (a force-push rewrote history).
|
||||||
|
# One recovery attempt — refresh every remote head (cheap: the
|
||||||
|
# checkout is already fetch-depth:0); a fetch failure aborts via
|
||||||
|
# `set -e`, which is fail-closed too.
|
||||||
|
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
||||||
|
# ordering against, and a gate whose job is to BLOCK must not guess.
|
||||||
|
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BASE="$BEFORE_SHA"
|
||||||
|
fi
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
||||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
# `set -e` above already aborts on a non-zero diff exit.
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
||||||
bad=0
|
bad=0
|
||||||
for f in $added; do
|
for f in $added; do
|
||||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -131,6 +159,14 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
|
# @docmost/token-estimate is a shared workspace package the client vitest
|
||||||
|
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
|
||||||
|
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
|
||||||
|
# build it before the recursive test run or the client suite fails with
|
||||||
|
# "Failed to resolve import '@docmost/token-estimate'" (#490).
|
||||||
|
- name: Build token-estimate
|
||||||
|
run: pnpm --filter @docmost/token-estimate build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ packages/mcp/build/
|
|||||||
# is a build artifact like build/ — never committed, always fresh.
|
# is a build artifact like build/ — never committed, always fresh.
|
||||||
packages/mcp/src/registry-stamp.generated.ts
|
packages/mcp/src/registry-stamp.generated.ts
|
||||||
|
|
||||||
|
# token-estimate compiled output (#490; built in CI/Docker via `pnpm build` /
|
||||||
|
# the server `pretest`, never committed, so src/ and prod can never diverge).
|
||||||
|
packages/token-estimate/dist/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- 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`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `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`.
|
- 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`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `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`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -471,6 +472,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
|
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
||||||
|
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
||||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
+123
-1
@@ -129,6 +129,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **A drifted comment suggestion can be re-synced instead of failing forever
|
||||||
|
with a 409.** A suggestion whose stored anchor no longer matched the live
|
||||||
|
document used to reject every apply attempt with an unrecoverable conflict; a
|
||||||
|
new resync path re-reads the live anchor so the suggestion applies against the
|
||||||
|
current text, and orphaned anchors (whose marked run was deleted) are
|
||||||
|
reconciled rather than left blocking. (#496)
|
||||||
|
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
|
||||||
|
to save a named version of a page. The history panel now distinguishes
|
||||||
|
intentional versions (a "Saved" / "Agent version" badge) from automatic
|
||||||
|
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
|
||||||
|
snapshots switched from a fixed interval to a trailing idle-flush with a
|
||||||
|
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
||||||
|
changes (e.g. a person's edits followed by the AI agent). (#370)
|
||||||
|
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||||
|
redeployed while a tab is left open for hours, the tab now learns the new
|
||||||
|
build version over the existing WebSocket (announced per-connect, so a natural
|
||||||
|
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||||
|
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||||
|
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||||
|
the next safe point — the next in-app navigation (or immediately if you click
|
||||||
|
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||||
|
reload happens per 5-minute window, shared with the existing chunk-load
|
||||||
|
recovery, so a permanent version skew degrades to the banner rather than a
|
||||||
|
reload loop while a second deploy in the same tab still recovers. When the
|
||||||
|
build carries no version info the feature stays inert. (#481)
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
@@ -304,6 +330,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
longer controls whether a turn is a run — it now governs **only** the
|
longer controls whether a turn is a run — it now governs **only** the
|
||||||
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
||||||
disconnect stops the run). (#487)
|
disconnect stops the run). (#487)
|
||||||
|
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
||||||
|
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
||||||
|
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
||||||
|
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
||||||
|
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
||||||
|
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
||||||
|
alignment is called out as an install-gated plan rather than a bare version
|
||||||
|
bump. No runtime change.
|
||||||
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||||
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||||
generator, and the AI-chat markdown renderer all now use
|
generator, and the AI-chat markdown renderer all now use
|
||||||
@@ -336,6 +370,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **MCP write tools no longer report a false failure that provokes a duplicate
|
||||||
|
write.** `drawioCreate` used to throw when the diagram landed as a NESTED block
|
||||||
|
(anchored inside a callout or table cell) because there is no `#<index>` handle
|
||||||
|
for it — but the diagram was already written, so a retry-prone agent re-created
|
||||||
|
it and produced a duplicate. It now returns success with `nodeId: null` plus a
|
||||||
|
warning that explains the write landed and how to re-read it (via
|
||||||
|
`getOutline` / `getPageJson` by `attachmentId`). Separately, when the live
|
||||||
|
collaboration-session cache hits its LRU entry cap, evicting a session whose
|
||||||
|
write is still in flight no longer rejects that write as a hard failure — it is
|
||||||
|
reported as INDETERMINATE ("the update may already have persisted; verify
|
||||||
|
before retry") so the agent re-reads instead of blind-retrying, and a
|
||||||
|
still-connecting session is no longer picked as an idle eviction victim by a
|
||||||
|
parallel acquire. (#494)
|
||||||
|
- **A long AI chat no longer bricks on the model's context window, and each turn
|
||||||
|
stops re-persisting the whole tool-output history.** Tool outputs are now
|
||||||
|
stored ONCE, in `metadata.parts`; the `tool_calls` trace keeps only per-step
|
||||||
|
outcome flags (a v2 trace shape), ending the O(N²) write amplification that
|
||||||
|
re-wrote every prior output on every step (measured on a live Postgres via the
|
||||||
|
`pg_current_wal_lsn()` delta: the trace column shrank ~3200×, the full
|
||||||
|
assistant row ~51%). The persisted record is unchanged in content — the full
|
||||||
|
history still lives in `metadata.parts`. At REPLAY time only, the history sent
|
||||||
|
to the provider is now bounded by a deterministic, prompt-cache-friendly token
|
||||||
|
budget: `floor(0.7 × chatContextWindow)` when a window is configured (no cap —
|
||||||
|
anti-brick protection, not a cost limiter), a flat 100k fallback for installs
|
||||||
|
with no window set (exactly the ones that hit terminal overflow), or off when
|
||||||
|
the window is explicitly `0`. Trimming truncates old tool outputs first, then
|
||||||
|
mechanically collapses the oldest turns, always keeping the recent turns full
|
||||||
|
and the tool-call/result pairing balanced. A provider context-overflow 400 is
|
||||||
|
now classified and used as a reactive signal: the row is stamped so the NEXT
|
||||||
|
turn re-trims aggressively (0.5×), which un-bricks a chat that just 400'd. The
|
||||||
|
client token badge and the server budgeter now share one estimator (new
|
||||||
|
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
||||||
|
activation is also cached in the chat metadata to avoid re-resolving it each
|
||||||
|
turn. (#490)
|
||||||
|
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
|
||||||
|
when a diagram is opened in the draw.io editor.** Agent-created diagrams
|
||||||
|
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
|
||||||
|
SVG's `content=` attribute as base64; the draw.io editor decodes that via
|
||||||
|
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
|
||||||
|
`ё`, `—`) split into garbage and the editor's autosave then persisted the
|
||||||
|
corrupted model, breaking the page preview too. Both write paths
|
||||||
|
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
|
||||||
|
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
|
||||||
|
DOM as UTF-8 — so labels open intact. The decoder reads both the new
|
||||||
|
entity-encoded form and the old base64 form, so existing diagrams still open.
|
||||||
|
*Healing pre-fix diagrams:* only a diagram that still holds its original
|
||||||
|
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
|
||||||
|
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
|
||||||
|
same XML (rewrites the attachment in the new form); no migration script is
|
||||||
|
needed. A diagram that was already opened in the editor persisted the
|
||||||
|
mojibake at rest, so `drawioGet` reads the already-corrupted text and
|
||||||
|
`drawioUpdate` faithfully rewrites it — that text is lost and is not
|
||||||
|
recoverable by a rewrite. (#507)
|
||||||
- **A chat with one malformed message part no longer 500s on every turn, and a
|
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||||
failed send no longer duplicates the user's message.** Incoming client parts
|
failed send no longer duplicates the user's message.** Incoming client parts
|
||||||
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||||
@@ -352,7 +439,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||||
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||||
10 min; see `.env.example`). (#489)
|
10 min; see `.env.example`). (#489)
|
||||||
|
- **Decisions on comment suggestions now leave a durable audit record.**
|
||||||
|
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
||||||
|
subject comment, so the only surviving trace of who decided what is the audit
|
||||||
|
event — but the audit trail was wired to a Noop service that silently
|
||||||
|
swallowed every event. The trail is now DB-backed, so
|
||||||
|
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
||||||
|
comment-decision events) persist to the `audit` table and can be reviewed
|
||||||
|
after the comment is gone. A persistence failure is still swallowed with a
|
||||||
|
warning so it never breaks the originating request. (#496)
|
||||||
|
- **Applying a comment suggestion no longer strips the replaced run's inline
|
||||||
|
formatting.** The suggested text was re-inserted carrying only the comment
|
||||||
|
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||||
|
prevailing formatting of the replaced run is now carried onto the applied
|
||||||
|
text. (#496)
|
||||||
|
- **Markdown round-trips no longer silently drop a line that opens with a block
|
||||||
|
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
||||||
|
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
||||||
|
that begins with a block marker — an ATX heading `#`, a blockquote/callout `>`,
|
||||||
|
a list marker (`-`/`*`/`+`/`N.`/`N)`), a code fence, a table `|`, a thematic
|
||||||
|
break (`---`), or a setext underline (`--`, `----`, or a lone `=`) — is now
|
||||||
|
backslash-escaped so it round-trips as text instead of being re-parsed into a
|
||||||
|
heading/list/quote/rule and losing its content. Front-matter stripping is
|
||||||
|
scoped to the import path only. (#493)
|
||||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
@@ -469,6 +578,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
through that exact share (its own share or an ancestor `includeSubPages`
|
through that exact share (its own share or an ancestor `includeSubPages`
|
||||||
share); any other value now returns the generic "not found" instead of
|
share); any other value now returns the generic "not found" instead of
|
||||||
serving the page. (#218)
|
serving the page. (#218)
|
||||||
|
- **MCP tool-allowlist semantics flipped: an empty `[]` now means deny-all
|
||||||
|
(previously it was coerced to "no restrictions").** For an external MCP server,
|
||||||
|
a stored `tool_allowlist` of `[]` now denies **every** tool of that server
|
||||||
|
(zero tools reach the agent) instead of being treated as an empty/unset filter
|
||||||
|
that allowed all of them. A corrupt or non-array stored value now **fails
|
||||||
|
closed** to deny-all rather than silently allowing everything. The admin form
|
||||||
|
no longer silently widens an existing deny-all server: leaving its tag field
|
||||||
|
empty preserves `[]` (deny-all) on save instead of NULL-ing the column to
|
||||||
|
allow-all, so a routine rename/toggle can no longer grant the agent every tool.
|
||||||
|
"No restrictions" is still expressible — a genuinely unrestricted server stores
|
||||||
|
NULL, and clearing the field on such a server keeps it NULL. Operationally
|
||||||
|
significant: audit any server that was created or left with a literal `[]`, as
|
||||||
|
it now exposes no tools until an explicit allowlist (or NULL) is set. (#476)
|
||||||
|
|
||||||
- **Tool and provider error text no longer leaks to anonymous readers in the
|
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||||
public-share AI chat.** A failing tool's raw error (which could carry an
|
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
|||||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
|
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
|
||||||
|
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
|
||||||
|
# prod install would resolve a broken workspace symlink and the server would die
|
||||||
|
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
|
||||||
|
# package + its manifest, mirroring prosemirror-markdown above.
|
||||||
|
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
|
||||||
|
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
COPY --from=builder /app/pnpm*.yaml /app/
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
|
|||||||
@@ -206,6 +206,137 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
||||||
apply at all.
|
apply at all.
|
||||||
|
|
||||||
|
## Local embeddings server
|
||||||
|
|
||||||
|
The AI agent's semantic (RAG) search needs an **embeddings model**. Instead of paying a cloud
|
||||||
|
provider (e.g. OpenAI `text-embedding-3-*`) to embed every page, you can run a small open-weights
|
||||||
|
model yourself with Hugging Face
|
||||||
|
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI), which
|
||||||
|
serves an OpenAI-compatible `/v1/embeddings` endpoint. `intfloat/multilingual-e5-small` is a good
|
||||||
|
default: multilingual, 384-dim, and comfortable on CPU (~1–2 GB RAM, 1–2 vCPU). Point Gitmost at it
|
||||||
|
under **Workspace settings → AI → Embeddings**.
|
||||||
|
|
||||||
|
### Option A — local (same Docker network as Gitmost)
|
||||||
|
|
||||||
|
Run TEI as a container on the network Gitmost is already on. The port is never published, so the
|
||||||
|
endpoint stays internal and needs no authentication.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- gitmost_net # same network Gitmost is on
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data # weights are downloaded once and cached here
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gitmost_net:
|
||||||
|
external: true # the network Gitmost already uses
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------------------|-----------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `http://embeddings:80/v1/` |
|
||||||
|
| Embedding API key | — (leave empty) |
|
||||||
|
|
||||||
|
> `embeddings` is the container name — Gitmost resolves it over DNS inside the Docker network.
|
||||||
|
> The port is not published, so the endpoint is reachable only by containers on that network and
|
||||||
|
> no authorization is required.
|
||||||
|
|
||||||
|
### Option B — separate host (public via Traefik + Let's Encrypt)
|
||||||
|
|
||||||
|
This assumes the host already runs Traefik with an ACME resolver (the example below uses
|
||||||
|
`letsEncrypt`, the `websecure` entrypoint and a shared `docker_main_net` network). Replace the
|
||||||
|
domain / network / resolver with your own.
|
||||||
|
|
||||||
|
**DNS:** add an A record `embeddings.example.com` → the IP of your Traefik host (same
|
||||||
|
challenge / port 80 as the rest of your sites).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- docker_main_net # the network Traefik is attached to
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate"
|
||||||
|
- "--api-key"
|
||||||
|
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||||
|
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||||
|
traefik.http.routers.embeddings.tls: "true"
|
||||||
|
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||||
|
traefik.http.routers.embeddings.service: "embeddings"
|
||||||
|
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||||
|
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||||
|
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_main_net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------------------|---------------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `https://embeddings.example.com/v1/` |
|
||||||
|
| Embedding API key | your `sk-emb-…` |
|
||||||
|
|
||||||
|
Check it from outside:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://embeddings.example.com/v1/embeddings \
|
||||||
|
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||||
|
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
# -> dims: 384
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embeddings server notes
|
||||||
|
|
||||||
|
- **Vector dimension is 384.** If this Gitmost was previously embedded with a different model
|
||||||
|
(e.g. `text-embedding-3-large` = 3072-dim), the old pgvector rows won't match the new dimension —
|
||||||
|
clear the existing embeddings / re-index before switching. Gitmost only compares vectors of the
|
||||||
|
same dimension, so mixed-dimension rows are silently ignored rather than searched.
|
||||||
|
- **First start downloads the weights** (hundreds of MB) from `huggingface.co` into the
|
||||||
|
`tei-models` volume; every start after that reads from the volume.
|
||||||
|
- **Pin the version.** Pin the image, and optionally the model: add `--revision <commit-sha>` to
|
||||||
|
`command` (the sha is on the model's page on Hugging Face).
|
||||||
|
- **Air-gapped / no egress:** seed the `tei-models` volume ahead of time and add
|
||||||
|
`environment: [HF_HUB_OFFLINE=1]`.
|
||||||
|
- **GPU:** use the cuda tag of the same release (e.g.
|
||||||
|
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) and start the container with `gpus: all`.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
|
|||||||
+131
@@ -193,6 +193,137 @@ dump/restore, существующий каталог данных переис
|
|||||||
> неизменным и бэкапьте вместе с базой данных.
|
> неизменным и бэкапьте вместе с базой данных.
|
||||||
|
|
||||||
|
|
||||||
|
## Локальный сервер эмбеддингов
|
||||||
|
|
||||||
|
Семантическому (RAG) поиску AI-агента нужна **модель эмбеддингов**. Вместо оплаты облачного
|
||||||
|
провайдера (например, OpenAI `text-embedding-3-*`) за эмбеддинг каждой страницы можно запустить
|
||||||
|
небольшую open-weights модель у себя через Hugging Face
|
||||||
|
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI) — он
|
||||||
|
отдаёт OpenAI-совместимый эндпоинт `/v1/embeddings`. Хороший дефолт — `intfloat/multilingual-e5-small`:
|
||||||
|
многоязычная, 384-мерная, комфортно работает на CPU (~1–2 ГБ RAM, 1–2 vCPU). Пропишите её в
|
||||||
|
**Настройки воркспейса → AI → Эмбеддинги**.
|
||||||
|
|
||||||
|
### Вариант A — локально (та же Docker-сеть, что и Gitmost)
|
||||||
|
|
||||||
|
Запустите TEI контейнером в той же сети, где уже работает Gitmost. Порт наружу не публикуется,
|
||||||
|
поэтому эндпоинт остаётся внутренним и не требует авторизации.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- gitmost_net # same network Gitmost is on
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data # weights are downloaded once and cached here
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gitmost_net:
|
||||||
|
external: true # the network Gitmost already uses
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|-------------------|-----------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `http://embeddings:80/v1/` |
|
||||||
|
| Embedding API key | — (оставить пустым) |
|
||||||
|
|
||||||
|
> `embeddings` — имя контейнера, Gitmost резолвит его по DNS внутри Docker-сети.
|
||||||
|
> Наружу порт не публикуется, эндпоинт доступен только контейнерам этой сети, поэтому
|
||||||
|
> авторизация не нужна.
|
||||||
|
|
||||||
|
### Вариант B — на отдельном хосте (наружу через Traefik + Let's Encrypt)
|
||||||
|
|
||||||
|
Предполагается, что на хосте уже есть Traefik с ACME-резолвером (в примере ниже — `letsEncrypt`,
|
||||||
|
entrypoint `websecure`, общая сеть `docker_main_net`). Замените домен / сеть / резолвер на свои.
|
||||||
|
|
||||||
|
**DNS:** заведите A-запись `embeddings.example.com` → IP хоста с Traefik (тот же challenge / порт 80,
|
||||||
|
что и у остальных сайтов).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- docker_main_net # the network Traefik is attached to
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate"
|
||||||
|
- "--api-key"
|
||||||
|
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||||
|
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||||
|
traefik.http.routers.embeddings.tls: "true"
|
||||||
|
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||||
|
traefik.http.routers.embeddings.service: "embeddings"
|
||||||
|
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||||
|
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||||
|
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_main_net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|-------------------|---------------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `https://embeddings.example.com/v1/` |
|
||||||
|
| Embedding API key | ваш `sk-emb-…` |
|
||||||
|
|
||||||
|
Проверка снаружи:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://embeddings.example.com/v1/embeddings \
|
||||||
|
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||||
|
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
# -> dims: 384
|
||||||
|
```
|
||||||
|
|
||||||
|
### Заметки про сервер эмбеддингов
|
||||||
|
|
||||||
|
- **Размерность вектора — 384.** Если раньше этот Gitmost эмбеддился другой моделью
|
||||||
|
(например, `text-embedding-3-large` = 3072-dim), старые строки в pgvector не совпадут по
|
||||||
|
размерности — очистите существующие эмбеддинги / переиндексируйте перед переключением. Gitmost
|
||||||
|
сравнивает только вектора одной размерности, поэтому строки другой размерности не участвуют в
|
||||||
|
поиске, а не ломают его.
|
||||||
|
- **Первый старт тянет веса** (сотни МБ) с `huggingface.co` в том `tei-models`; дальше — из тома.
|
||||||
|
- **Пин версии.** Пиньте образ, а при желании и модель: добавьте в `command` `--revision <commit-sha>`
|
||||||
|
(sha берётся со страницы модели на Hugging Face).
|
||||||
|
- **Без egress (air-gapped):** засейте том `tei-models` заранее и добавьте
|
||||||
|
`environment: [HF_HUB_OFFLINE=1]`.
|
||||||
|
- **GPU:** возьмите cuda-тег того же релиза (например,
|
||||||
|
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) и запустите контейнер с `gpus: all`.
|
||||||
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Совместная работа в реальном времени
|
- Совместная работа в реальном времени
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@casl/react": "5.0.1",
|
"@casl/react": "5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@docmost/token-estimate": "workspace:*",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "8.3.18",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "8.3.18",
|
"@mantine/dates": "8.3.18",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"A new version is available": "A new version is available",
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
@@ -239,6 +240,8 @@
|
|||||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
|
"Failed to re-open comment": "Failed to re-open comment",
|
||||||
|
"Comment no longer exists": "Comment no longer exists",
|
||||||
"Resolve comment": "Resolve comment",
|
"Resolve comment": "Resolve comment",
|
||||||
"Unresolve comment": "Unresolve comment",
|
"Unresolve comment": "Unresolve comment",
|
||||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||||
@@ -1418,5 +1421,29 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Suggestion dismissed": "Suggestion dismissed",
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
|
||||||
|
"Save version": "Save version",
|
||||||
|
"Ctrl+S": "Ctrl+S",
|
||||||
|
"Version saved": "Version saved",
|
||||||
|
"Already saved as the latest version": "Already saved as the latest version",
|
||||||
|
"Agent version": "Agent version",
|
||||||
|
"Boundary": "Boundary",
|
||||||
|
"Autosave": "Autosave",
|
||||||
|
"Only versions": "Only versions",
|
||||||
|
"No saved versions yet.": "No saved versions yet.",
|
||||||
|
"Time worked on this article": "Time worked on this article",
|
||||||
|
"Show time worked on this page": "Show time worked on this page",
|
||||||
|
"Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)",
|
||||||
|
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min",
|
||||||
|
"No editing activity recorded yet.": "No editing activity recorded yet.",
|
||||||
|
"× {{count}} days without edits": "× {{count}} days without edits",
|
||||||
|
"agent: {{value}}": "agent: {{value}}",
|
||||||
|
"Work": "Work",
|
||||||
|
"Agent": "Agent",
|
||||||
|
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||||
|
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||||
|
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||||
|
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
|
||||||
|
"{{hours}}h": "{{hours}}h",
|
||||||
|
"{{minutes}}m": "{{minutes}}m"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"A new version is available": "Доступна новая версия",
|
||||||
"Account": "Аккаунт",
|
"Account": "Аккаунт",
|
||||||
"Active": "Активный",
|
"Active": "Активный",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
@@ -239,6 +240,8 @@
|
|||||||
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
||||||
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
||||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||||
|
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
|
||||||
|
"Comment no longer exists": "Комментарий больше не существует",
|
||||||
"Resolve comment": "Решить комментарий",
|
"Resolve comment": "Решить комментарий",
|
||||||
"Unresolve comment": "Снять статус решённого с комментария",
|
"Unresolve comment": "Снять статус решённого с комментария",
|
||||||
"Resolve Comment Thread": "Решить ветку комментариев",
|
"Resolve Comment Thread": "Решить ветку комментариев",
|
||||||
@@ -256,6 +259,9 @@
|
|||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
|
"Copy chat": "Копировать чат",
|
||||||
|
"Dock to sidebar": "Закрепить в боковой панели",
|
||||||
|
"Undock": "Открепить",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
"Failed to export chat": "Не удалось экспортировать чат",
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
@@ -285,6 +291,9 @@
|
|||||||
"Alt text": "Альтернативный текст",
|
"Alt text": "Альтернативный текст",
|
||||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||||
"Add a description": "Добавить описание",
|
"Add a description": "Добавить описание",
|
||||||
|
"Caption": "Подпись",
|
||||||
|
"Add a caption": "Добавить подпись",
|
||||||
|
"Shown below the image.": "Отображается под изображением.",
|
||||||
"Justify": "По ширине",
|
"Justify": "По ширине",
|
||||||
"Merge cells": "Объединить ячейки",
|
"Merge cells": "Объединить ячейки",
|
||||||
"Split cell": "Разделить ячейку",
|
"Split cell": "Разделить ячейку",
|
||||||
@@ -388,22 +397,6 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
"Transcribe": "Транскрибировать",
|
|
||||||
"Transcribing…": "Транскрибация…",
|
|
||||||
"No speech detected": "Речь не распознана",
|
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
|
||||||
"Start dictation": "Начать диктовку",
|
|
||||||
"Stop recording": "Остановить запись",
|
|
||||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
|
||||||
"No microphone found": "Микрофон не найден",
|
|
||||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
|
||||||
"Could not start recording": "Не удалось начать запись",
|
|
||||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
|
||||||
"Dictation": "Диктовка",
|
|
||||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
|
||||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
|
||||||
"This page is read-only": "Страница открыта только для чтения",
|
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -419,9 +412,6 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
"Back to references": "Вернуться к ссылкам",
|
|
||||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
|
||||||
"Empty footnote": "Пустая сноска",
|
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формулы",
|
"Math block": "Блок формулы",
|
||||||
@@ -447,6 +437,9 @@
|
|||||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||||
"{{count}} result available_one": "Доступен 1 результат",
|
"{{count}} result available_one": "Доступен 1 результат",
|
||||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||||
|
"{{count}} result found_one": "Найден {{count}} результат",
|
||||||
|
"{{count}} result found_few": "Найдено {{count}} результата",
|
||||||
|
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||||
"Equal columns": "Равные столбцы",
|
"Equal columns": "Равные столбцы",
|
||||||
"Left sidebar": "Левая боковая панель",
|
"Left sidebar": "Левая боковая панель",
|
||||||
"Right sidebar": "Правая боковая панель",
|
"Right sidebar": "Правая боковая панель",
|
||||||
@@ -456,6 +449,7 @@
|
|||||||
"Names do not match": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||||
|
"now": "сейчас",
|
||||||
"Space created successfully": "Пространство успешно создано",
|
"Space created successfully": "Пространство успешно создано",
|
||||||
"Space updated successfully": "Пространство успешно обновлено",
|
"Space updated successfully": "Пространство успешно обновлено",
|
||||||
"Space deleted successfully": "Пространство успешно удалено",
|
"Space deleted successfully": "Пространство успешно удалено",
|
||||||
@@ -559,6 +553,7 @@
|
|||||||
"Add 2FA method": "Добавить метод 2FA",
|
"Add 2FA method": "Добавить метод 2FA",
|
||||||
"Backup codes": "Резервные коды",
|
"Backup codes": "Резервные коды",
|
||||||
"Disable": "Отключить",
|
"Disable": "Отключить",
|
||||||
|
"disabled": "отключено",
|
||||||
"Invalid verification code": "Недействительный код подтверждения",
|
"Invalid verification code": "Недействительный код подтверждения",
|
||||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||||
@@ -702,62 +697,6 @@
|
|||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI agent": "AI-агент",
|
|
||||||
"Take a look at the current document": "Посмотри текущий документ",
|
|
||||||
"Start automatically": "Запускать автоматически",
|
|
||||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
|
||||||
"Launch message": "Стартовое сообщение",
|
|
||||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
|
||||||
"AI agent is typing…": "AI-агент печатает…",
|
|
||||||
"{{name}} is typing…": "{{name}} печатает…",
|
|
||||||
"Thinking…": "Думаю…",
|
|
||||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
|
||||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
|
||||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
|
||||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
|
||||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
|
||||||
"Agent role": "Роль агента",
|
|
||||||
"AI chat": "AI-чат",
|
|
||||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
|
||||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
|
||||||
"Ask a question…": "Задайте вопрос…",
|
|
||||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
|
||||||
"Ask the AI agent…": "Спросите AI-агента…",
|
|
||||||
"Copy chat": "Копировать чат",
|
|
||||||
"Dock to sidebar": "Закрепить в боковой панели",
|
|
||||||
"Undock": "Открепить",
|
|
||||||
"Created successfully": "Успешно создано",
|
|
||||||
"Context size / model limit": "Размер контекста / лимит модели",
|
|
||||||
"Context window (tokens)": "Окно контекста (токены)",
|
|
||||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
|
||||||
"Deleted successfully": "Успешно удалено",
|
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
|
||||||
"Failed": "Ошибка",
|
|
||||||
"OK · {{n}}": "OK · {{n}}",
|
|
||||||
"Test": "Тест",
|
|
||||||
"No tools available": "Инструменты недоступны",
|
|
||||||
"Available tools": "Доступные инструменты",
|
|
||||||
"Minimize": "Свернуть",
|
|
||||||
"No chats yet.": "Чатов пока нет.",
|
|
||||||
"Send": "Отправить",
|
|
||||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
|
||||||
"Queue message": "Поставить в очередь",
|
|
||||||
"Remove queued message": "Убрать из очереди",
|
|
||||||
"Send now": "Отправить сейчас",
|
|
||||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
|
||||||
"Something went wrong": "Что-то пошло не так",
|
|
||||||
"Stop": "Стоп",
|
|
||||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
|
||||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
|
||||||
"Universal assistant": "Универсальный ассистент",
|
|
||||||
"You": "Вы",
|
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Thinking": "Думаю",
|
"Thinking": "Думаю",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
@@ -784,8 +723,40 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||||
"Instructions": "Инструкции",
|
"AI / Models": "ИИ / Модели",
|
||||||
|
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||||
|
"Add server": "Добавить сервер",
|
||||||
|
"Edit server": "Изменить сервер",
|
||||||
|
"Delete server": "Удалить сервер",
|
||||||
|
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||||
|
"No external servers configured": "Внешние серверы не настроены",
|
||||||
|
"Server name": "Имя сервера",
|
||||||
|
"Transport": "Транспорт",
|
||||||
|
"URL": "URL",
|
||||||
|
"Authorization header": "Заголовок авторизации",
|
||||||
|
"Tool allowlist": "Список разрешённых инструментов",
|
||||||
|
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||||
|
"Test": "Тест",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Created successfully": "Успешно создано",
|
||||||
|
"Deleted successfully": "Успешно удалено",
|
||||||
|
"Clear": "Очистить",
|
||||||
|
"Provider": "Провайдер",
|
||||||
|
"•••• set": "•••• задан",
|
||||||
|
"Clear key": "Очистить ключ",
|
||||||
|
"Base URL": "Базовый URL",
|
||||||
|
"Chat model": "Модель чата",
|
||||||
|
"Embedding model": "Модель эмбеддингов",
|
||||||
|
"System message": "Системное сообщение",
|
||||||
|
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Test connection": "Проверить соединение",
|
||||||
|
"Connection successful": "Соединение установлено",
|
||||||
|
"Connection failed": "Не удалось установить соединение",
|
||||||
|
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -1013,6 +984,7 @@
|
|||||||
"Try again": "Попробовать снова",
|
"Try again": "Попробовать снова",
|
||||||
"Untitled chat": "Чат без названия",
|
"Untitled chat": "Чат без названия",
|
||||||
"No document": "Без документа",
|
"No document": "Без документа",
|
||||||
|
"You": "Вы",
|
||||||
"What can I help you with?": "Чем я могу вам помочь?",
|
"What can I help you with?": "Чем я могу вам помочь?",
|
||||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||||
@@ -1041,6 +1013,9 @@
|
|||||||
"Page menu": "Меню страницы",
|
"Page menu": "Меню страницы",
|
||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Collapse": "Свернуть",
|
"Collapse": "Свернуть",
|
||||||
|
"Expand all": "Развернуть все",
|
||||||
|
"Collapse all": "Свернуть все",
|
||||||
|
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||||
"Comment menu": "Меню комментария",
|
"Comment menu": "Меню комментария",
|
||||||
"Group menu": "Меню группы",
|
"Group menu": "Меню группы",
|
||||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||||
@@ -1077,7 +1052,7 @@
|
|||||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||||
"No results found": "Результаты не найдены",
|
"No results found": "Результаты не найдены",
|
||||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||||
"Chat menu": "Меню чата",
|
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||||
"API key menu": "Меню API-ключа",
|
"API key menu": "Меню API-ключа",
|
||||||
"Jump to comment selection": "Перейти к выбору комментария",
|
"Jump to comment selection": "Перейти к выбору комментария",
|
||||||
"Slash commands": "Команды со слешем",
|
"Slash commands": "Команды со слешем",
|
||||||
@@ -1131,6 +1106,9 @@
|
|||||||
"Undo": "Отменить",
|
"Undo": "Отменить",
|
||||||
"Redo": "Повторить",
|
"Redo": "Повторить",
|
||||||
"Backlinks": "Обратные ссылки",
|
"Backlinks": "Обратные ссылки",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
|
"Empty footnote": "Пустая сноска",
|
||||||
"Last updated by": "Последний изменивший",
|
"Last updated by": "Последний изменивший",
|
||||||
"Last updated": "Последнее обновление",
|
"Last updated": "Последнее обновление",
|
||||||
"Stats": "Статистика",
|
"Stats": "Статистика",
|
||||||
@@ -1164,6 +1142,7 @@
|
|||||||
"Page title": "Заголовок страницы",
|
"Page title": "Заголовок страницы",
|
||||||
"Page content": "Содержимое страницы",
|
"Page content": "Содержимое страницы",
|
||||||
"Member actions": "Действия с участником",
|
"Member actions": "Действия с участником",
|
||||||
|
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||||
"Toggle password visibility": "Переключить видимость пароля",
|
"Toggle password visibility": "Переключить видимость пароля",
|
||||||
"Send comment": "Отправить комментарий",
|
"Send comment": "Отправить комментарий",
|
||||||
"Token actions": "Действия с токеном",
|
"Token actions": "Действия с токеном",
|
||||||
@@ -1183,11 +1162,187 @@
|
|||||||
"Removed from favorites": "Удалено из избранного",
|
"Removed from favorites": "Удалено из избранного",
|
||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
|
"Label added": "Метка добавлена",
|
||||||
|
"Label removed": "Метка удалена",
|
||||||
|
"Image updated": "Изображение обновлено",
|
||||||
|
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||||
|
"Member deactivated": "Участник деактивирован",
|
||||||
|
"Member activated": "Участник активирован",
|
||||||
|
"Name is required": "Укажите имя",
|
||||||
|
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||||
|
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||||
|
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||||
|
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||||
|
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
|
"AI chat": "AI-чат",
|
||||||
|
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||||
|
"Ask a question…": "Задайте вопрос…",
|
||||||
|
"Thinking…": "Думаю…",
|
||||||
|
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||||
|
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||||
|
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||||
|
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||||
|
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||||
|
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||||
|
"Public share assistant": "Ассистент публичного доступа",
|
||||||
|
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||||
|
"Public assistant model": "Модель публичного ассистента",
|
||||||
|
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||||
|
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||||
|
"Assistant identity": "Личность ассистента",
|
||||||
|
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||||
|
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||||
|
"Minimize": "Свернуть",
|
||||||
|
"Context size / model limit": "Размер контекста / лимит модели",
|
||||||
|
"Context window (tokens)": "Окно контекста (токены)",
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
|
"AI agent": "AI-агент",
|
||||||
|
"Take a look at the current document": "Посмотри текущий документ",
|
||||||
|
"AI agent is typing…": "AI-агент печатает…",
|
||||||
|
"{{name}} is typing…": "{{name}} печатает…",
|
||||||
|
"Send": "Отправить",
|
||||||
|
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||||
|
"Queue message": "Поставить в очередь",
|
||||||
|
"Remove queued message": "Убрать из очереди",
|
||||||
|
"Send now": "Отправить сейчас",
|
||||||
|
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||||
|
"Stop": "Стоп",
|
||||||
|
"Response stopped.": "Ответ остановлен.",
|
||||||
|
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||||
|
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||||
|
"Chat menu": "Меню чата",
|
||||||
|
"No chats yet.": "Чатов пока нет.",
|
||||||
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
|
"Ask the AI agent…": "Спросите AI-агента…",
|
||||||
|
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||||
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
|
"Something went wrong": "Что-то пошло не так",
|
||||||
|
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||||
|
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||||
|
"Searched pages": "Поиск по страницам",
|
||||||
|
"Read page": "Прочитана страница",
|
||||||
|
"Created page": "Создана страница",
|
||||||
|
"Updated page": "Обновлена страница",
|
||||||
|
"Renamed page": "Переименована страница",
|
||||||
|
"Moved page": "Перемещена страница",
|
||||||
|
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||||
|
"Commented": "Добавлен комментарий",
|
||||||
|
"Resolved comment": "Комментарий решён",
|
||||||
|
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||||
|
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||||
|
"AI agent {{name}}": "AI-агент {{name}}",
|
||||||
|
"Endpoints": "Эндпоинты",
|
||||||
|
"where we fetch models": "откуда мы получаем модели",
|
||||||
|
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||||
|
"Chat / LLM": "Чат / LLM",
|
||||||
|
"root": "корневой",
|
||||||
|
"Semantic search": "Семантический поиск",
|
||||||
|
"Voice / STT": "Голос / STT",
|
||||||
|
"Voice dictation": "Голосовой ввод",
|
||||||
|
"Streaming dictation": "Потоковый голосовой ввод",
|
||||||
|
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||||
|
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||||
|
"Test endpoint": "Проверить эндпоинт",
|
||||||
|
"Save and test": "Сохранить и проверить",
|
||||||
|
"Save endpoints": "Сохранить эндпоинты",
|
||||||
|
"Configured and enabled": "Настроено и включено",
|
||||||
|
"Configured but disabled": "Настроено, но отключено",
|
||||||
|
"Enabled but not configured": "Включено, но не настроено",
|
||||||
|
"Not configured": "Не настроено",
|
||||||
|
"External tools": "Внешние инструменты",
|
||||||
|
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||||
|
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||||
|
"MCP server": "MCP-сервер",
|
||||||
|
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||||
|
"Enable MCP server": "Включить MCP-сервер",
|
||||||
|
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||||
|
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||||
|
"Model": "Модель",
|
||||||
|
"Done": "Готово",
|
||||||
|
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||||
|
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||||
|
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||||
|
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||||
|
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||||
|
"Embedding API key": "API-ключ для эмбеддингов",
|
||||||
|
"Embeddings": "Эмбеддинги",
|
||||||
|
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||||
|
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||||
|
"Reindex now": "Переиндексировать сейчас",
|
||||||
|
"Start dictation": "Начать диктовку",
|
||||||
|
"Stop recording": "Остановить запись",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||||
|
"No microphone found": "Микрофон не найден",
|
||||||
|
"Could not start recording": "Не удалось начать запись",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
|
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||||
|
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||||
|
"Dictation": "Диктовка",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||||
|
"This page is read-only": "Страница открыта только для чтения",
|
||||||
|
"Request format": "Формат запроса",
|
||||||
|
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||||
|
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||||
|
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
|
"Agent role": "Роль агента",
|
||||||
|
"Universal assistant": "Универсальный ассистент",
|
||||||
|
"Add role": "Добавить роль",
|
||||||
|
"Edit role": "Изменить роль",
|
||||||
|
"Role name": "Название роли",
|
||||||
|
"e.g. Proofreader": "напр. Корректор",
|
||||||
|
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||||
|
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||||
|
"Instructions": "Инструкции",
|
||||||
|
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Model provider override": "Переопределение провайдера модели",
|
||||||
|
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||||
|
"Model override": "Переопределение модели",
|
||||||
|
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||||
|
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||||
|
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||||
|
"Start automatically": "Запускать автоматически",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||||
|
"Launch message": "Стартовое сообщение",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||||
|
"Agent roles": "Роли агента",
|
||||||
|
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||||
|
"No roles configured": "Роли не настроены",
|
||||||
|
"Delete role": "Удалить роль",
|
||||||
|
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||||
|
"HTML embed": "HTML-вставка",
|
||||||
|
"Edit HTML embed": "Изменить HTML-вставку",
|
||||||
|
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||||
|
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||||
|
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||||
|
"<script>...</script>": "<script>...</script>",
|
||||||
|
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||||
|
"advanced": "дополнительно",
|
||||||
|
"Enable HTML embed": "Включить HTML-вставки",
|
||||||
|
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||||
|
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||||
|
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||||
|
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||||
|
"Analytics / tracker": "Аналитика / трекер",
|
||||||
|
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||||
|
"Go to login page": "Перейти на страницу входа",
|
||||||
|
"Move to space": "Переместить в пространство",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
"Inline (side by side)": "В ряд",
|
"Inline (side by side)": "В ряд",
|
||||||
@@ -1199,6 +1354,7 @@
|
|||||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||||
|
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||||
"Protocol": "Протокол",
|
"Protocol": "Протокол",
|
||||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||||
@@ -1268,7 +1424,6 @@
|
|||||||
"Retry": "Повторить",
|
"Retry": "Повторить",
|
||||||
"The catalog is empty": "Каталог пуст",
|
"The catalog is empty": "Каталог пуст",
|
||||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
@@ -1281,5 +1436,29 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
"Dismiss": "Не применять",
|
"Dismiss": "Не применять",
|
||||||
"Suggestion dismissed": "Предложение отклонено",
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
|
||||||
|
"Save version": "Сохранить версию",
|
||||||
|
"Ctrl+S": "Ctrl+S",
|
||||||
|
"Version saved": "Версия сохранена",
|
||||||
|
"Already saved as the latest version": "Уже сохранено как последняя версия",
|
||||||
|
"Agent version": "Версия агента",
|
||||||
|
"Boundary": "Граница",
|
||||||
|
"Autosave": "Автосейв",
|
||||||
|
"Only versions": "Только версии",
|
||||||
|
"No saved versions yet.": "Пока нет сохранённых версий.",
|
||||||
|
"Time worked on this article": "Время работы над статьёй",
|
||||||
|
"Show time worked on this page": "Показать время работы над страницей",
|
||||||
|
"Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)",
|
||||||
|
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин",
|
||||||
|
"No editing activity recorded yet.": "Правок пока нет.",
|
||||||
|
"× {{count}} days without edits": "× {{count}} дн. без правок",
|
||||||
|
"agent: {{value}}": "агент: {{value}}",
|
||||||
|
"Work": "Работа",
|
||||||
|
"Agent": "Агент",
|
||||||
|
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||||
|
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||||
|
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||||
|
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
|
||||||
|
"{{hours}}h": "{{hours}} ч",
|
||||||
|
"{{minutes}}m": "{{minutes}} м"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -21,20 +24,26 @@ export function isChunkLoadError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(error: unknown) {
|
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
||||||
|
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
||||||
|
// proactive version-coherence path) can be exercised against the real guard.
|
||||||
|
export function handleError(error: unknown) {
|
||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
||||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
||||||
// flag is already set we fall through to the manual recovery UI below.
|
// proactive version-coherence path consumes, so a mismatch that arrives on
|
||||||
try {
|
// both paths reloads at most once per window across BOTH). This recovers
|
||||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
// lazy chunk (which would loop) is stopped after the first reload and falls
|
||||||
} catch {
|
// through to the manual recovery UI below. If the shared budget is already
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
// spent this window, or the stamp write fails (storage unavailable), we return
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
// without reloading rather than risk a loop.
|
||||||
return;
|
if (hasAutoReloaded()) return;
|
||||||
}
|
if (!markAutoReloaded()) return;
|
||||||
|
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||||
|
// proactive version-coherence path writes, tagged with this path).
|
||||||
|
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,11 @@ import ConversationList from "@/features/ai-chat/components/conversation-list.ts
|
|||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import {
|
import {
|
||||||
exportAiChat,
|
exportAiChat,
|
||||||
|
getAiChatMessagesDelta,
|
||||||
stopRun,
|
stopRun,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { mergeDeltaRowsIntoPages } from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -269,17 +272,64 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(
|
useAiChatMessagesQuery(
|
||||||
activeChatId ?? undefined,
|
activeChatId ?? undefined,
|
||||||
// DELIBERATELY DUMB: poll every 2.5s WHILE ARMED, otherwise off. NO error
|
// #491: the full infinite-query no longer POLLS. It seeds the thread ONCE; the
|
||||||
// checks (TanStack resets fetchFailureCount each fetch; the poll must survive
|
// degraded fallback now runs a DELTA poller (below) that augments THIS cache
|
||||||
// a server restart), NO tail checks, NO cap here — the settled/stalled/idle-cap
|
// idempotently, instead of refetching every page (with full parts) every 2.5s.
|
||||||
// semantics all live in ChatThread's FSM, which disarms via onResumeFallback.
|
false,
|
||||||
() => (degradedPoll === true ? 2500 : false),
|
// #344: gate on windowOpen too — no message history is fetched while the window
|
||||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
// is closed; it loads when the window opens with an active chat.
|
||||||
// degraded poll runs) while the window is closed; it loads when the window
|
|
||||||
// opens with an active chat.
|
|
||||||
windowOpen,
|
windowOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #491 degraded DELTA poll. While armed (degradedPoll) and the window is open on a
|
||||||
|
// chat, poll POST /ai-chat/messages/delta every 2.5s: it returns only the rows
|
||||||
|
// CHANGED since the previous cursor (+ the run fact) in ONE round-trip. We merge
|
||||||
|
// those rows into the SAME infinite-query cache the thread reads (idempotently by
|
||||||
|
// id — the delta's overlap window re-delivers rows), so the thread's reconcile
|
||||||
|
// effect follows the detached run to its terminal row from a fraction of the wire
|
||||||
|
// cost. The run-fact settle stays the thread FSM's job (row-status reconcile), so
|
||||||
|
// we do NOT double-poll /run here. Cursor resets when the chat changes / disarms.
|
||||||
|
const deltaCursorRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
deltaCursorRef.current = undefined;
|
||||||
|
}, [activeChatId, degradedPoll]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!degradedPoll || !windowOpen || !activeChatId) return;
|
||||||
|
const chatId = activeChatId;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await getAiChatMessagesDelta(chatId, deltaCursorRef.current);
|
||||||
|
if (cancelled) return;
|
||||||
|
deltaCursorRef.current = res.cursor;
|
||||||
|
if (res.rows.length > 0) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
AI_CHAT_MESSAGES_RQ_KEY(chatId),
|
||||||
|
(
|
||||||
|
old:
|
||||||
|
| {
|
||||||
|
pages: { items: IAiChatMessageRow[]; meta: unknown }[];
|
||||||
|
pageParams: unknown[];
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) =>
|
||||||
|
old
|
||||||
|
? { ...old, pages: mergeDeltaRowsIntoPages(old.pages, res.rows) }
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient failure (e.g. a server restart mid-run): swallow and retry on
|
||||||
|
// the next tick — the poll must survive a bounce, like the old dumb refetch.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const id = setInterval(() => void tick(), 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [degradedPoll, windowOpen, activeChatId, queryClient]);
|
||||||
|
|
||||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
// this workspace. When the feature is off no runs are ever created, so the
|
// 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
|
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||||
|
|||||||
@@ -172,9 +172,18 @@ function resetState() {
|
|||||||
h.state.getRun.mockResolvedValue({ run: null, message: null });
|
h.state.getRun.mockResolvedValue({ run: null, message: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #491: the streaming tail carries a persisted step frontier (metadata.stepsPersisted),
|
||||||
|
// which the tail-only attach reads as `n` in `?anchor=<id>&n=<n>`. Seeded WHOLE now.
|
||||||
const streamingTail = () => [
|
const streamingTail = () => [
|
||||||
row("u1", "user", undefined, "hi"),
|
row("u1", "user", undefined, "hi"),
|
||||||
row("a1", "assistant", "streaming", "partial"),
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "partial",
|
||||||
|
status: "streaming",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
metadata: { stepsPersisted: 2 },
|
||||||
|
} as IAiChatMessageRow,
|
||||||
];
|
];
|
||||||
const settledTail = () => [
|
const settledTail = () => [
|
||||||
row("u1", "user", undefined, "hi"),
|
row("u1", "user", undefined, "hi"),
|
||||||
@@ -335,20 +344,24 @@ describe("ChatThread — send now", () => {
|
|||||||
expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(1);
|
expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", () => {
|
it("Stop then a REAL network-drop finish exits to idle (honor-in-stopping), NOT a false reconnect", async () => {
|
||||||
// Regression for the disconnect-first reorder: on the STOP path, even a drop-
|
// Regression for the disconnect-first reorder: on the STOP path, even a drop-
|
||||||
// form finish { isError:true, isDisconnect:true } arriving in `stopping` must be
|
// form finish { isError:true, isDisconnect:true } arriving in `stopping` must be
|
||||||
// HONORED (reducer) and exit to idle — it must NOT enter the reconnect ladder.
|
// HONORED (reducer) and exit to idle — it must NOT enter the reconnect ladder.
|
||||||
startLocalStreamWithRun(); // live local stream, autonomous
|
startLocalStreamWithRun(); // live local stream, autonomous
|
||||||
fireEvent.click(screen.getByLabelText("Stop")); // STOP_REQUESTED -> stopping
|
fireEvent.click(screen.getByLabelText("Stop")); // STOP_REQUESTED -> stopping
|
||||||
h.state.error = { message: "Failed to fetch" };
|
h.state.error = { message: "Failed to fetch" };
|
||||||
act(() => {
|
// #491: the disconnect re-seeds from persist (async getRun) before dispatching
|
||||||
|
// FINISH_DISCONNECT, which the reducer HONORS in `stopping` -> idle. Flush it.
|
||||||
|
await act(async () => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
message: { id: "a1", role: "assistant", parts: [] },
|
message: { id: "a1", role: "assistant", parts: [] },
|
||||||
isAbort: false,
|
isAbort: false,
|
||||||
isDisconnect: true,
|
isDisconnect: true,
|
||||||
isError: true,
|
isError: true,
|
||||||
});
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -803,19 +816,24 @@ describe("ChatThread — resume (attach) machinery", () => {
|
|||||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips the streaming tail from the seed, keeps a user tail whole", () => {
|
it("#491 tail-only: seeds the streaming tail WHOLE (no strip), keeps a user tail whole", () => {
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
expect(h.state.seededMessages).toHaveLength(1);
|
// MUTATION-VERIFY: re-introduce the seed-strip and this goes red — the streaming
|
||||||
|
// tail (steps 0..N-1) MUST be seeded so the SDK continuation appends the tail to
|
||||||
|
// the RIGHT message. Both rows (user + assistant) are seeded.
|
||||||
|
expect(h.state.seededMessages).toHaveLength(2);
|
||||||
cleanup();
|
cleanup();
|
||||||
resetState();
|
resetState();
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||||
expect(h.state.seededMessages).toHaveLength(1);
|
expect(h.state.seededMessages).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the attach URL with expect=live&anchor only for a stripped streaming tail", () => {
|
it("#491 tail-only: builds the attach URL with ?anchor=&n= from the persisted step frontier", () => {
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
// n=2 comes from a1's metadata.stepsPersisted (MUTATION-VERIFY: hardcode n=0 and
|
||||||
|
// this fails). No `expect=live` param anymore.
|
||||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
"/api/ai-chat/runs/c1/stream?anchor=a1&n=2",
|
||||||
);
|
);
|
||||||
cleanup();
|
cleanup();
|
||||||
resetState();
|
resetState();
|
||||||
@@ -839,39 +857,41 @@ describe("ChatThread — resume (attach) machinery", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
it("204 on a streaming tail: NO restore (row kept) + invalidate + onResumeFallback(true)", async () => {
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
autonomousRunsEnabled: true,
|
autonomousRunsEnabled: true,
|
||||||
initialRows: streamingTail(),
|
initialRows: streamingTail(),
|
||||||
});
|
});
|
||||||
await attachFetch({ status: 204, ok: false });
|
await attachFetch({ status: 204, ok: false });
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
// #491 tail-only: the anchor row was never stripped, so there is NOTHING to
|
||||||
|
// restore. MUTATION-VERIFY: re-add a restore setMessages here and it goes red.
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
});
|
});
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("F7 restart-survival: a 500 attach failure restores the row AND arms the poll", async () => {
|
it("F7 restart-survival: a 500 attach failure arms the poll WITHOUT a restore", async () => {
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
autonomousRunsEnabled: true,
|
autonomousRunsEnabled: true,
|
||||||
initialRows: streamingTail(),
|
initialRows: streamingTail(),
|
||||||
});
|
});
|
||||||
await attachFetch({ status: 500, ok: false });
|
await attachFetch({ status: 500, ok: false });
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
});
|
});
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("F7 restart-survival: a network throw restores the row AND arms the poll", async () => {
|
it("F7 restart-survival: a network throw arms the poll WITHOUT a restore", async () => {
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
autonomousRunsEnabled: true,
|
autonomousRunsEnabled: true,
|
||||||
initialRows: streamingTail(),
|
initialRows: streamingTail(),
|
||||||
});
|
});
|
||||||
await attachFetch(new Error("network down"), true);
|
await attachFetch(new Error("network down"), true);
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
});
|
});
|
||||||
@@ -931,7 +951,7 @@ describe("ChatThread — resume (attach) machinery", () => {
|
|||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an empty resumed message (starved replay) restores the row AND arms the poll", () => {
|
it("an empty resumed message (starved replay) arms the poll WITHOUT a restore", () => {
|
||||||
h.state.status = "ready";
|
h.state.status = "ready";
|
||||||
const { onResumeFallback } = renderThread({
|
const { onResumeFallback } = renderThread({
|
||||||
autonomousRunsEnabled: true,
|
autonomousRunsEnabled: true,
|
||||||
@@ -947,7 +967,9 @@ describe("ChatThread — resume (attach) machinery", () => {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
// #491 tail-only: the seeded steps 0..N-1 are still on screen (the SDK
|
||||||
|
// continuation never wiped them), so there is nothing to restore — just poll.
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -995,24 +1017,41 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #491: the authoritative PERSISTED assistant row `getRun` projects on a local
|
||||||
|
// disconnect — the re-seed source. Its metadata.stepsPersisted becomes `n`.
|
||||||
|
const persistedAnchor = (steps = 3) => ({
|
||||||
|
run: { id: "run-1", status: "running" },
|
||||||
|
message: {
|
||||||
|
id: "a2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "persisted 0..N-1",
|
||||||
|
status: "streaming",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
metadata: { stepsPersisted: steps },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true }
|
// A REAL live SSE drop. ai@6.0.207 emits BOTH { isError:true, isDisconnect:true }
|
||||||
// for a network TypeError AND sets useChat `error` — NOT the { isError:false,
|
// for a network TypeError AND sets useChat `error`. #491: an autonomous local drop
|
||||||
// error:null } form the old tests fed. This is the form browser QA hit; with the
|
// now RE-SEEDS from persist (async getRun) BEFORE entering the reconnect ladder, so
|
||||||
// buggy isError-first routing OR without the errorView render-gate these tests go
|
// this helper is async and flushes the getRun microtask before returning.
|
||||||
// red (a real drop surfaces the terminal error banner, masking the reconnect
|
async function disconnect(message: unknown = liveMsg) {
|
||||||
// ladder). MUTATION-VERIFY of disconnect-first + the errorView phase-gate.
|
|
||||||
function disconnect(message: unknown = liveMsg) {
|
|
||||||
h.state.error = { message: "Failed to fetch" }; // the SDK sets error on the drop
|
h.state.error = { message: "Failed to fetch" }; // the SDK sets error on the drop
|
||||||
act(() => {
|
await act(async () => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
message,
|
message,
|
||||||
isAbort: false,
|
isAbort: false,
|
||||||
isDisconnect: true,
|
isDisconnect: true,
|
||||||
isError: true,
|
isError: true,
|
||||||
});
|
});
|
||||||
|
// Flush the getRun().then re-seed + the deferred FINISH_DISCONNECT dispatch.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderLive() {
|
function renderLive() {
|
||||||
|
// The persisted-anchor read the local disconnect performs to re-seed from persist.
|
||||||
|
h.state.getRun.mockResolvedValue(persistedAnchor());
|
||||||
const view = renderThread({
|
const view = renderThread({
|
||||||
autonomousRunsEnabled: true,
|
autonomousRunsEnabled: true,
|
||||||
initialRows: settledTail(),
|
initialRows: settledTail(),
|
||||||
@@ -1032,35 +1071,80 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("a live disconnect starts a backoff reconnect (banner + resumeStream after backoff)", () => {
|
it("#491: a live disconnect RE-SEEDS from persist, then backs off to reconnect with ?anchor=&n=", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
|
// The re-seed read the authoritative persisted row and replaced the live partial.
|
||||||
|
// MUTATION-VERIFY: skip the getRun re-seed (send `n` off the live message) and the
|
||||||
|
// n below no longer matches the PERSISTED stepsPersisted.
|
||||||
|
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalled(); // re-seeded the store from persist
|
||||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
// n=3 is the PERSISTED row's stepsPersisted (from getRun), NOT the live store.
|
||||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a2",
|
"/api/ai-chat/runs/c1/stream?anchor=a2&n=3",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", () => {
|
it("#491 regression (#137/#161 dup): getRun REJECT on a live disconnect drops the live partial + nulls the anchor", async () => {
|
||||||
|
// The re-seed source (getRun) FAILS — a flaky-network blip (SSE + getRun both
|
||||||
|
// fail, network recovers in ~1s). The OLD .catch just re-entered the ladder with
|
||||||
|
// NO re-seed and NO filter, so the reconnect could tail-apply the registry's
|
||||||
|
// frames onto the live partial that ALREADY has those steps -> duplicated text.
|
||||||
|
renderLive();
|
||||||
|
h.state.getRun.mockReset();
|
||||||
|
h.state.getRun.mockRejectedValue(new Error("network"));
|
||||||
|
await disconnect(); // live partial = liveMsg (id "a2")
|
||||||
|
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||||
|
// THE GUARANTEE: on the getRun failure the live partial (a2) is FILTERED from the
|
||||||
|
// store, so the reconnect can never tail-apply already-present steps onto it.
|
||||||
|
// MUTATION-VERIFY: revert the .catch fix (enterReconnect only, no filter) and no
|
||||||
|
// setMessages call removes a2 -> this reddens.
|
||||||
|
const removedLivePartial = (
|
||||||
|
h.state.setMessages as unknown as {
|
||||||
|
mock: { calls: [unknown][] };
|
||||||
|
}
|
||||||
|
).mock.calls.some(([updater]) => {
|
||||||
|
if (typeof updater !== "function") return false;
|
||||||
|
const out = (updater as (p: { id: string }[]) => { id: string }[])([
|
||||||
|
{ id: "a2" },
|
||||||
|
{ id: "u1" },
|
||||||
|
]);
|
||||||
|
return !out.some((m) => m.id === "a2");
|
||||||
|
});
|
||||||
|
expect(removedLivePartial).toBe(true);
|
||||||
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
|
advanceToAttempt(1);
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
// Anchor was nulled -> replay-from-start (no params) / 204 -> poll; never a stale
|
||||||
|
// ?anchor=&n= over the live partial.
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#488 (browser QA): the reconnect banner is SHOWN, not masked by the residual useChat error", async () => {
|
||||||
// The drop sets useChat `error` (real SDK), and the terminal errorView describes
|
// The drop sets useChat `error` (real SDK), and the terminal errorView describes
|
||||||
// it ("Lost connection to the server"). The FSM phase-gate must let the
|
// it ("Lost connection to the server"). The FSM phase-gate must let the
|
||||||
// `reconnecting` banner WIN over that residual error. MUTATION-VERIFY: revert the
|
// `reconnecting` banner WIN over that residual error. MUTATION-VERIFY: revert the
|
||||||
// errorView phase-gate (show errorView whenever error is set) and the terminal
|
// errorView phase-gate (show errorView whenever error is set) and the terminal
|
||||||
// banner masks "reconnecting…" -> red.
|
// banner masks "reconnecting…" -> red.
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
expect(h.state.error).not.toBeNull(); // the SDK error IS set during recovery
|
expect(h.state.error).not.toBeNull(); // the SDK error IS set during recovery
|
||||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
// The terminal "Lost connection… reload" banner must NOT be showing.
|
// The terminal "Lost connection… reload" banner must NOT be showing.
|
||||||
expect(screen.queryByText(/reload and try again/i)).toBeNull();
|
expect(screen.queryByText(/reload and try again/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", () => {
|
it("#488 commit 2: a disconnect BEFORE the first assistant frame reconnects with NO anchor", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect(null); // no assistant message yet (pre-first-frame break)
|
// No persisted assistant row for a pre-first-frame break -> no anchor.
|
||||||
|
h.state.getRun.mockResolvedValue({ run: null, message: null });
|
||||||
|
await disconnect(null); // no assistant message yet (pre-first-frame break)
|
||||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Connection lost — the answer was interrupted."),
|
screen.queryByText("Connection lost — the answer was interrupted."),
|
||||||
@@ -1074,7 +1158,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
|
|
||||||
it("a live re-attach (2xx) clears the reconnect banner", async () => {
|
it("a live re-attach (2xx) clears the reconnect banner", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
await reconnect({ status: 200, ok: true });
|
await reconnect({ status: 200, ok: true });
|
||||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
@@ -1082,7 +1166,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
|
|
||||||
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
|
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
|
||||||
const { onResumeFallback } = renderLive();
|
const { onResumeFallback } = renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
await reconnect({ status: 204, ok: false });
|
await reconnect({ status: 204, ok: false });
|
||||||
@@ -1094,7 +1178,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
|
|
||||||
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
|
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
for (let n = 1; n <= 5; n++) {
|
for (let n = 1; n <= 5; n++) {
|
||||||
advanceToAttempt(n);
|
advanceToAttempt(n);
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
|
||||||
@@ -1112,22 +1196,23 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
it("#488 commit 3: two breaks in a row produce two reconnect cycles", async () => {
|
it("#488 commit 3: two breaks in a row produce two reconnect cycles", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
// First break -> reconnect -> re-attach live.
|
// First break -> reconnect -> re-attach live.
|
||||||
disconnect();
|
await disconnect();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
await reconnect({ status: 200, ok: true });
|
await reconnect({ status: 200, ok: true });
|
||||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
// The re-attached observer stream drops AGAIN -> a SECOND reconnect cycle
|
// The re-attached observer (live-follow) stream drops AGAIN -> a SECOND reconnect
|
||||||
// (the old one-shot !wasResumed gate sent this to silent poll).
|
// cycle. #491: this too re-seeds from persist before re-attaching (never tail-
|
||||||
disconnect();
|
// applies over the live-follow partial).
|
||||||
|
await disconnect();
|
||||||
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT reconnect when autonomous runs are disabled", () => {
|
it("does NOT reconnect when autonomous runs are disabled", async () => {
|
||||||
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
|
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
|
||||||
disconnect();
|
await disconnect();
|
||||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Connection lost — the answer was interrupted."),
|
screen.getByText("Connection lost — the answer was interrupted."),
|
||||||
@@ -1138,7 +1223,7 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
|
|
||||||
it("#488 commit 4a: the poll idle cap surfaces a stalled banner + Retry (not silent)", async () => {
|
it("#488 commit 4a: the poll idle cap surfaces a stalled banner + Retry (not silent)", async () => {
|
||||||
renderLive();
|
renderLive();
|
||||||
disconnect();
|
await disconnect();
|
||||||
advanceToAttempt(1);
|
advanceToAttempt(1);
|
||||||
await reconnect({ status: 204, ok: false }); // arms the poll (reconnecting)
|
await reconnect({ status: 204, ok: false }); // arms the poll (reconnecting)
|
||||||
// No activity for the whole idle cap -> stalled.
|
// No activity for the whole idle cap -> stalled.
|
||||||
@@ -1169,4 +1254,88 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
});
|
});
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
|
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("#541: getRun HANGS on a live disconnect — the timeout race still enters reconnect (no silent freeze in `streaming`)", async () => {
|
||||||
|
// MUTATION-VERIFY: revert the race to a bare `getRun(cid).then/.catch` and this
|
||||||
|
// reddens — a HUNG (never-settling, NOT rejected) getRun leaves the FSM stuck in
|
||||||
|
// `streaming` with no reconnect banner and no poll (the axios client sets no
|
||||||
|
// request timeout, and the stalled-idle cap only arms AFTER reconnecting/polling).
|
||||||
|
renderLive();
|
||||||
|
h.state.getRun.mockReset();
|
||||||
|
h.state.getRun.mockReturnValue(new Promise(() => {})); // getRun HANGS forever
|
||||||
|
await disconnect(); // live partial = liveMsg (id "a2")
|
||||||
|
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||||
|
// BEFORE the bound fires the FSM is still in the live turn — the very freeze bug.
|
||||||
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
|
// The recovery-start bound fires -> the SAME fallback as the reject path.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4_000);
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
|
// The live partial (a2) was DROPPED from the store (replay-from-start, no stale
|
||||||
|
// tail-apply base). Mirrors the getRun-REJECT test's inspection.
|
||||||
|
const removedLivePartial = (
|
||||||
|
h.state.setMessages as unknown as {
|
||||||
|
mock: { calls: [unknown][] };
|
||||||
|
}
|
||||||
|
).mock.calls.some(([updater]) => {
|
||||||
|
if (typeof updater !== "function") return false;
|
||||||
|
const out = (updater as (p: { id: string }[]) => { id: string }[])([
|
||||||
|
{ id: "a2" },
|
||||||
|
{ id: "u1" },
|
||||||
|
]);
|
||||||
|
return !out.some((m) => m.id === "a2");
|
||||||
|
});
|
||||||
|
expect(removedLivePartial).toBe(true);
|
||||||
|
// ...and the anchor was NULLED -> replay-from-start (no ?anchor=&n= over the partial).
|
||||||
|
advanceToAttempt(1);
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#541: a getRun resolve AFTER the timeout already fired is IGNORED (no double reconnect, no stale re-seed)", async () => {
|
||||||
|
// The timeout wins first and enters the ladder via replay-from-start. When the
|
||||||
|
// hung getRun FINALLY answers, its late `.then` must be a full no-op: it must not
|
||||||
|
// re-seed the store from the (now stale) persisted row, must not re-set the
|
||||||
|
// anchor, and must not re-enter reconnect. The local `settled` flag makes the
|
||||||
|
// resolve/reject/timeout branches mutually exclusive.
|
||||||
|
// MUTATION-VERIFY: drop the `settled` guard (let the late `.then` run) and the
|
||||||
|
// late re-seed re-sets the anchor (URL regains ?anchor=a2&n=3) + calls setMessages.
|
||||||
|
renderLive();
|
||||||
|
let resolveGetRun!: (v: unknown) => void;
|
||||||
|
h.state.getRun.mockReset();
|
||||||
|
h.state.getRun.mockReturnValue(
|
||||||
|
new Promise((r) => {
|
||||||
|
resolveGetRun = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await disconnect();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4_000); // bound fires -> replay-from-start, reconnecting
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
|
advanceToAttempt(1);
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
// Anchor is null -> replay-from-start URL (the pre-condition the late resolve must
|
||||||
|
// not undo).
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
const setMessagesCallsBefore = h.state.setMessages.mock.calls.length;
|
||||||
|
// NOW the hung getRun finally resolves with a persisted anchor (id a2, steps 3).
|
||||||
|
await act(async () => {
|
||||||
|
resolveGetRun(persistedAnchor());
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
// The late resolve did NOT re-seed the store...
|
||||||
|
expect(h.state.setMessages.mock.calls.length).toBe(setMessagesCallsBefore);
|
||||||
|
// ...did NOT re-set the anchor (URL stays replay-from-start, no ?anchor=&n=)...
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
// ...and did NOT trigger a fresh reconnect attach.
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
|||||||
import {
|
import {
|
||||||
isStreamingTail,
|
isStreamingTail,
|
||||||
isSettledAssistantTail,
|
isSettledAssistantTail,
|
||||||
seedRows,
|
stepsPersistedOf,
|
||||||
mergeById,
|
mergeById,
|
||||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||||
import { getRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import { getRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
@@ -81,6 +81,17 @@ const STREAM_THROTTLE_MS = 50;
|
|||||||
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
|
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
|
||||||
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
// #541: the bound on the persist re-seed (getRun) wait when ENTERING the reconnect
|
||||||
|
// ladder after a live SSE drop. The axios client (lib/api-client.ts) sets NO request
|
||||||
|
// timeout, and the stalled-idle cap only arms AFTER the FSM enters reconnecting/
|
||||||
|
// polling — which never happens if getRun HANGS (connection established, no response).
|
||||||
|
// Without this bound a live drop + a hung getRun sticks the FSM in `streaming` with
|
||||||
|
// no banner and no poll until the browser socket timeout (minutes) — the silent-freeze
|
||||||
|
// class #497 eliminates. This is a deliberate RECOVERY-START bound (drop the live
|
||||||
|
// partial + enter the ladder so the poll/idle-cap arms), intentionally much shorter
|
||||||
|
// than any network socket timeout — not a network read timeout.
|
||||||
|
const RECONNECT_RESEED_TIMEOUT_MS = 4_000;
|
||||||
|
|
||||||
/** The #487 active (non-terminal) run statuses — mirrors the server's
|
/** The #487 active (non-terminal) run statuses — mirrors the server's
|
||||||
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
|
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
|
||||||
function isActiveRunStatus(status: string | null | undefined): boolean {
|
function isActiveRunStatus(status: string | null | undefined): boolean {
|
||||||
@@ -266,25 +277,36 @@ export default function ChatThread({
|
|||||||
// is NOT one of the lifecycle flags the FSM replaced.
|
// is NOT one of the lifecycle flags the FSM replaced.
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
// attachStrategy DATA (behind the resumeStream effect; #491 swaps it to tail-only
|
// attachStrategy DATA (behind the resumeStream effect; #491 tail-only, WITHOUT
|
||||||
// WITHOUT touching the FSM). The controller is effect-owned (aborted in cleanup,
|
// touching the FSM). The controller is effect-owned (aborted in cleanup, I5).
|
||||||
// I5). `stripRef`/`strippedRowRef` are the current full-replay+strip anchor.
|
// `anchorRef` is the PERSISTED assistant row that pins the run (server invariant
|
||||||
|
// 6) and its persisted step frontier N: it feeds `?anchor=<id>&n=<stepsPersisted>`
|
||||||
|
// so the tail-only attach returns frames for steps >= N (the seed carries 0..N-1).
|
||||||
|
// It is NOT a "stripped" row — the seed keeps every row (tail-only replaces the
|
||||||
|
// old full-replay+strip). Null when there is no streaming/active tail to resume.
|
||||||
const attachAbortRef = useRef<AbortController | null>(null);
|
const attachAbortRef = useRef<AbortController | null>(null);
|
||||||
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
const anchorRef = useRef<{ id: string; stepsPersisted: number } | null>(
|
||||||
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
(() => {
|
||||||
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
if (chatId === null || !isStreamingTail(initialRows ?? [])) return null;
|
||||||
|
const rows = initialRows ?? [];
|
||||||
|
const tail = rows[rows.length - 1];
|
||||||
|
return { id: tail.id, stepsPersisted: stepsPersistedOf(tail) };
|
||||||
|
})(),
|
||||||
);
|
);
|
||||||
// Effect-owned backoff timers (not lifecycle flags): the reconnect ladder and the
|
// Effect-owned backoff timers (not lifecycle flags): the reconnect ladder and the
|
||||||
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
|
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// #541: the persist re-seed (getRun) timeout race on the disconnect->reconnect
|
||||||
|
// entry. Held in a ref so unmount (DISPOSE cleanup) clears it — no dangling timer.
|
||||||
|
const reseedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
|
||||||
|
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
|
||||||
|
// to it by the SDK continuation (readUIMessageStream({ message })), so it must be
|
||||||
|
// present in the store for the attach to continue the RIGHT message.
|
||||||
const initialMessages = useMemo<UIMessage[]>(
|
const initialMessages = useMemo<UIMessage[]>(
|
||||||
() =>
|
() => (initialRows ?? []).map(rowToUiMessage),
|
||||||
seedRows(
|
|
||||||
initialRows ?? [],
|
|
||||||
stripRef.current && autonomousRunsEnabled === true,
|
|
||||||
).map(rowToUiMessage),
|
|
||||||
[initialRows],
|
[initialRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -335,21 +357,16 @@ export default function ChatThread({
|
|||||||
(eff: RunEffect, epoch: number) => {
|
(eff: RunEffect, epoch: number) => {
|
||||||
switch (eff.type) {
|
switch (eff.type) {
|
||||||
case "resumeStream": {
|
case "resumeStream": {
|
||||||
// The attach GET. Stamp the outcome's generation (I1). A reconnect
|
// The attach GET. Stamp the outcome's generation (I1). #491 tail-only: the
|
||||||
// attempt filters the pinned live row from the store first (the mount
|
// store already holds EXACTLY the persisted steps 0..N-1 (the mount seed IS
|
||||||
// seed already stripped it), so the live replay's text-start rebuilds it
|
// persist; a reconnect was re-seeded from persist BEFORE FINISH_DISCONNECT
|
||||||
// without duplicating parts (#430).
|
// scheduled it — see the onFinish disconnect handler), so there is nothing
|
||||||
|
// to filter here: the SDK continues that seeded message, appending the tail
|
||||||
|
// (steps >= N) without duplicating the pre-drop partial step.
|
||||||
pendingAttachEpochRef.current = epoch;
|
pendingAttachEpochRef.current = epoch;
|
||||||
// The resumed stream's onFinish is stamped with THIS attach generation
|
// The resumed stream's onFinish is stamped with THIS attach generation
|
||||||
// (F1), so a superseded attempt's late finish is dropped.
|
// (F1), so a superseded attempt's late finish is dropped.
|
||||||
turnEpochRef.current = epoch;
|
turnEpochRef.current = epoch;
|
||||||
if (machineRef.current.phase.name === "reconnecting") {
|
|
||||||
const anchor = strippedRowRef.current;
|
|
||||||
if (anchor)
|
|
||||||
setMessagesRef.current?.((prev) =>
|
|
||||||
prev.filter((m) => m.id !== anchor.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
void resumeStreamRef.current?.();
|
void resumeStreamRef.current?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -464,18 +481,23 @@ export default function ChatThread({
|
|||||||
new DefaultChatTransport<UIMessage>({
|
new DefaultChatTransport<UIMessage>({
|
||||||
api: "/api/ai-chat/stream",
|
api: "/api/ai-chat/stream",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
prepareReconnectToStreamRequest: () => ({
|
prepareReconnectToStreamRequest: () => {
|
||||||
// Build the attach URL from the REAL chat id. ?expect=live&anchor=<row id>
|
// #491 tail-only attach URL. When there is an anchor (a streaming/active
|
||||||
// only when a streaming tail was stripped: expect=live opts into a
|
// tail to resume) build `?anchor=<assistantRowId>&n=<stepsPersisted>`: the
|
||||||
// finished-retained replay (safe only because the row is stripped and the
|
// server returns the TAIL — a synthetic `start` frame + frames for steps
|
||||||
// replay rebuilds it), and the anchor pins the replay to OUR run — a
|
// >= n, then live — which the SDK continuation appends to the seeded row.
|
||||||
// mismatching (newer) run 204s into the restore+poll path instead.
|
// The server 204s (-> restore-noop + poll) when it cannot cover the
|
||||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
// frontier (overflow/rotation gap) or the anchor mismatches (a newer run).
|
||||||
stripRef.current
|
// No anchor (a user tail / pre-first-frame break) => no params.
|
||||||
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
const anchor = anchorRef.current;
|
||||||
: ""
|
return {
|
||||||
}`,
|
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||||
}),
|
anchor
|
||||||
|
? `?anchor=${anchor.id}&n=${anchor.stepsPersisted}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
if ((init.method ?? "GET") !== "GET") {
|
if ((init.method ?? "GET") !== "GET") {
|
||||||
// Send path (POST). #488 commit 5: NO client 409 retry ladder anymore
|
// Send path (POST). #488 commit 5: NO client 409 retry ladder anymore
|
||||||
@@ -562,8 +584,9 @@ export default function ChatThread({
|
|||||||
|
|
||||||
// Attach GET outcome -> FSM event. The epoch guard replaces BOTH the one-shot
|
// Attach GET outcome -> FSM event. The epoch guard replaces BOTH the one-shot
|
||||||
// 204 guard (noStreamHandledRef) and the unmount gate: a stale/superseded or
|
// 204 guard (noStreamHandledRef) and the unmount gate: a stale/superseded or
|
||||||
// post-DISPOSE outcome is dropped (I1). For a NONE outcome the attachStrategy
|
// post-DISPOSE outcome is dropped (I1). #491 tail-only: on a NONE outcome there is
|
||||||
// recovery (restore the stripped row + invalidate for a fresh poll) runs first.
|
// NOTHING to restore — the anchor row was never stripped from the view (the seed
|
||||||
|
// keeps it) — so we only invalidate for a fresh poll + dispatch the FSM event.
|
||||||
const handleAttachOutcome = useCallback(
|
const handleAttachOutcome = useCallback(
|
||||||
(ep: number, wasReconnecting: boolean, live: boolean) => {
|
(ep: number, wasReconnecting: boolean, live: boolean) => {
|
||||||
if (ep !== epochRef.current) return; // stale generation — drop
|
if (ep !== epochRef.current) return; // stale generation — drop
|
||||||
@@ -575,10 +598,6 @@ export default function ChatThread({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (strippedRowRef.current)
|
|
||||||
setMessagesRef.current?.((prev) =>
|
|
||||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||||
});
|
});
|
||||||
@@ -661,56 +680,31 @@ export default function ChatThread({
|
|||||||
// keeps executing server-side — must win; only a NON-disconnect error (a
|
// keeps executing server-side — must win; only a NON-disconnect error (a
|
||||||
// provider 500, `{ isError:true, isDisconnect:false }`) is terminal.
|
// provider 500, `{ isError:true, isDisconnect:false }`) is terminal.
|
||||||
if (isDisconnect) {
|
if (isDisconnect) {
|
||||||
if (wasObserver) {
|
if (!mountedRef.current) {
|
||||||
// A resumed/attached OBSERVER stream dropped. Recover via the degraded
|
|
||||||
// poll (restore the stripped row only when there is no visible content;
|
|
||||||
// never clobber a fuller on-screen tail, invariant 9). The FSM decides
|
|
||||||
// reconnect-vs-poll from liveFollow (a live-follow drop reconnects again,
|
|
||||||
// #488 commit 3; a mount-resume drop polls).
|
|
||||||
if (mountedRef.current) {
|
|
||||||
const hasVisible = msgHasVisible;
|
|
||||||
if (!hasVisible && strippedRowRef.current)
|
|
||||||
setMessages((prev) =>
|
|
||||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
|
||||||
});
|
|
||||||
dispatch({
|
|
||||||
type: "FINISH_DISCONNECT",
|
|
||||||
hasVisibleContent: hasVisible,
|
|
||||||
epoch: stampEpoch,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A LOCAL live turn dropped. #488 commit 2: recover by the RUN-FACT, not by
|
// No detached run to recover (legacy, non-autonomous): a plain disconnect —
|
||||||
// the presence of an assistant message — a setup-phase break (before the
|
// terminal notice, no reconnect. (An observer only exists in autonomous mode,
|
||||||
// first frame) still leaves a detached run writing to pages. In autonomous
|
// so this is always a local turn.)
|
||||||
// mode a run is active for the whole turn, so seed the run-fact from the
|
if (autonomousRunsEnabled !== true) {
|
||||||
// start-metadata runId when known, else a sentinel (the attach GET goes by
|
|
||||||
// chatId, not runId). Pin the assistant row as the strip/anchor when present.
|
|
||||||
if (autonomousRunsEnabled === true && mountedRef.current) {
|
|
||||||
const hasAnchor =
|
|
||||||
message?.role === "assistant" && typeof message.id === "string";
|
|
||||||
if (hasAnchor) {
|
|
||||||
strippedRowRef.current = {
|
|
||||||
id: message.id,
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
status: "streaming",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
metadata: { parts: message.parts },
|
|
||||||
};
|
|
||||||
stripRef.current = true;
|
|
||||||
} else {
|
|
||||||
strippedRowRef.current = null;
|
|
||||||
stripRef.current = false;
|
|
||||||
}
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "RUN_FACT",
|
type: "FINISH_DISCONNECT",
|
||||||
runFact: { runId: extractRunId(message) ?? "pending" },
|
hasVisibleContent: false,
|
||||||
|
epoch: stampEpoch,
|
||||||
|
});
|
||||||
|
setStopNotice("disconnect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A mount-resume OBSERVER (one-shot resume, NOT live-follow) drop falls to
|
||||||
|
// the degraded POLL, which merges by id — it does NOT attach, so there is
|
||||||
|
// nothing to re-seed. #491 tail-only: the anchor row was never removed from
|
||||||
|
// the view (the seed keeps it; the continuation only APPENDED), so nothing to
|
||||||
|
// restore either. The FSM routes this to `polling` (ownership observer,
|
||||||
|
// !liveFollow).
|
||||||
|
if (wasObserver && !machineRef.current.ctx.liveFollow) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||||
});
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "FINISH_DISCONNECT",
|
type: "FINISH_DISCONNECT",
|
||||||
@@ -718,14 +712,123 @@ export default function ChatThread({
|
|||||||
epoch: stampEpoch,
|
epoch: stampEpoch,
|
||||||
});
|
});
|
||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
// We will (re-)ENTER THE RECONNECT LADDER (an attach): a LOCAL live turn's
|
||||||
|
// first drop, OR a live-follow observer's SUBSEQUENT drop (#488 commit 3).
|
||||||
|
// #488 commit 2: recover by the RUN-FACT, not by the presence of an assistant
|
||||||
|
// message — a setup-phase break still leaves a detached run writing to pages.
|
||||||
|
//
|
||||||
|
// #491 tail-only (THE crux): the live store holds a PARTIAL step that is AHEAD
|
||||||
|
// of the persisted boundary; tail-applying the reconnect's step frames over it
|
||||||
|
// would DUPLICATE that partial step. So entering reconnecting is ALWAYS via a
|
||||||
|
// RE-SEED FROM PERSIST — never the live store. Fetch the authoritative
|
||||||
|
// persisted assistant row (`getRun` returns the projected `message`), replace
|
||||||
|
// the live partial by id (mergeById -> the store now holds EXACTLY steps
|
||||||
|
// 0..N-1), and set the anchor to `{ id, n = stepsPersisted }`. Only AFTER the
|
||||||
|
// re-seed is applied do we enter the ladder (FINISH_DISCONNECT schedules the
|
||||||
|
// backoff) — so the attach can never tail-apply over the live partial.
|
||||||
|
const cid = chatIdRef.current;
|
||||||
|
// The live-message runId is the run-fact source (the attach GET keys on
|
||||||
|
// chatId, so a sentinel still recovers a setup-phase break).
|
||||||
|
const runId = extractRunId(message ?? undefined) ?? "pending";
|
||||||
|
const enterReconnect = (fact: string): void => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
// Epoch-stamp the run-fact too (I1): the getRun rtt widens the
|
||||||
|
// onFinish->dispatch window, so a concurrent SEND_LOCAL during it must be
|
||||||
|
// able to drop this stale RUN_FACT (else it clobbers the new turn's
|
||||||
|
// runFact.runId). Consistent with the postRun RUN_FACT stamp.
|
||||||
|
dispatch({ type: "RUN_FACT", runFact: { runId: fact }, epoch: stampEpoch });
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "FINISH_DISCONNECT",
|
type: "FINISH_DISCONNECT",
|
||||||
hasVisibleContent: false,
|
hasVisibleContent: msgHasVisible,
|
||||||
epoch: stampEpoch,
|
epoch: stampEpoch,
|
||||||
});
|
});
|
||||||
setStopNotice("disconnect");
|
};
|
||||||
|
// Restore the STRUCTURAL guarantee that the live partial is never the
|
||||||
|
// tail-apply base: drop the live partial from the store by id and null the
|
||||||
|
// anchor, so the reconnect replays from step 0 into a CLEAN store (a full
|
||||||
|
// rebuild) or, past any rotation, 204s -> degraded poll. Used on BOTH the
|
||||||
|
// no-persisted-row and getRun-FAILURE paths — after this there is no path
|
||||||
|
// where the attach tail-applies frames onto a row that already has them
|
||||||
|
// (the #137/#161 duplication class).
|
||||||
|
const dropLivePartialAndReplayFromStart = (): void => {
|
||||||
|
if (message?.role === "assistant" && typeof message.id === "string") {
|
||||||
|
const liveId = message.id;
|
||||||
|
setMessagesRef.current?.((prev) =>
|
||||||
|
prev.filter((m) => m.id !== liveId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
anchorRef.current = null;
|
||||||
|
};
|
||||||
|
if (cid) {
|
||||||
|
// #541: bound the persist re-seed wait with a timeout race. getRun goes
|
||||||
|
// through the axios client, which has NO request timeout; a HUNG getRun
|
||||||
|
// (connection open, no response) — distinct from a REJECT, which the
|
||||||
|
// `.catch` already handles — would otherwise never let us enter the ladder,
|
||||||
|
// leaving the FSM stuck in `streaming` with no banner and no poll until the
|
||||||
|
// browser socket timeout. `settled` makes the three branches (resolve /
|
||||||
|
// reject / timeout) mutually exclusive: whichever fires FIRST wins and the
|
||||||
|
// others become no-ops. So a LATE getRun resolve AFTER the timeout is fully
|
||||||
|
// ignored — it cannot (i) re-enter reconnect a second time, (ii) overwrite
|
||||||
|
// the timeout's replay-from-start with a stale re-seed, or (iii) re-arm any
|
||||||
|
// timer. On timeout we take the SAME fallback as the reject path (drop the
|
||||||
|
// live partial + enter the ladder, so the poll and the stalled-idle cap arm).
|
||||||
|
let settled = false;
|
||||||
|
const finishReseed = (apply: () => void): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (reseedTimerRef.current) {
|
||||||
|
clearTimeout(reseedTimerRef.current);
|
||||||
|
reseedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
reseedTimerRef.current = setTimeout(() => {
|
||||||
|
finishReseed(() => {
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
enterReconnect(runId);
|
||||||
|
});
|
||||||
|
}, RECONNECT_RESEED_TIMEOUT_MS);
|
||||||
|
void getRun(cid)
|
||||||
|
.then((res) => {
|
||||||
|
finishReseed(() => {
|
||||||
|
const persisted = res.message;
|
||||||
|
if (persisted && persisted.role === "assistant") {
|
||||||
|
anchorRef.current = {
|
||||||
|
id: persisted.id,
|
||||||
|
stepsPersisted: stepsPersistedOf(persisted),
|
||||||
|
};
|
||||||
|
// Replace the live partial with the persisted row IN PLACE by id —
|
||||||
|
// the re-seed from persist. The attach's tail (steps >= N) then
|
||||||
|
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
|
||||||
|
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
|
||||||
|
} else {
|
||||||
|
// No persisted assistant row (pre-first-frame break): drop the live
|
||||||
|
// partial + replay from start (no anchor/n) so nothing is duplicated.
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
}
|
||||||
|
enterReconnect(res.run?.id ?? runId);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
finishReseed(() => {
|
||||||
|
// Persist read FAILED: we cannot re-seed from fresh persist, and a
|
||||||
|
// stale mount-time anchor over the live partial would tail-apply
|
||||||
|
// already-present steps -> duplication (a flaky-network blip:
|
||||||
|
// SSE + getRun both fail, network recovers in ~1s, the registry still
|
||||||
|
// covers from the mount frontier). Restore the removed-filter guarantee
|
||||||
|
// instead: drop the live partial + replay from start / 204 -> poll.
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
enterReconnect(runId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
enterReconnect(runId);
|
||||||
}
|
}
|
||||||
|
setStopNotice(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A NON-disconnect stream error (a provider 500 etc.) -> terminal error banner.
|
// A NON-disconnect stream error (a provider 500 etc.) -> terminal error banner.
|
||||||
@@ -746,11 +849,10 @@ export default function ChatThread({
|
|||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
const hasVisible = msgHasVisible;
|
const hasVisible = msgHasVisible;
|
||||||
if (!hasVisible) {
|
if (!hasVisible) {
|
||||||
// Starved replay: restore the stripped row + poll to the real terminal.
|
// Starved replay (the tail carried no new steps). #491 tail-only: the
|
||||||
if (strippedRowRef.current)
|
// seeded steps 0..N-1 are still on screen (the SDK continuation never
|
||||||
setMessages((prev) =>
|
// wiped them — `start` does not reset parts), so there is nothing to
|
||||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
// restore; just poll to the real terminal.
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||||
});
|
});
|
||||||
@@ -846,6 +948,12 @@ export default function ChatThread({
|
|||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
// #541: clear the in-flight persist re-seed timeout (not an FSM effect timer,
|
||||||
|
// so DISPOSE does not touch it) — no dangling setTimeout after unmount.
|
||||||
|
if (reseedTimerRef.current) {
|
||||||
|
clearTimeout(reseedTimerRef.current);
|
||||||
|
reseedTimerRef.current = null;
|
||||||
|
}
|
||||||
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
|
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
|
||||||
};
|
};
|
||||||
// Mount-only by design; the parent remounts per chat via `key`.
|
// Mount-only by design; the parent remounts per chat via `key`.
|
||||||
@@ -863,12 +971,12 @@ export default function ChatThread({
|
|||||||
const tail = rows[rows.length - 1];
|
const tail = rows[rows.length - 1];
|
||||||
if (!tail || tail.role !== "assistant") return;
|
if (!tail || tail.role !== "assistant") return;
|
||||||
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
||||||
// Anchor-mismatch coherence: a restored stripped row A that a DIFFERENT run's
|
// Anchor-mismatch coherence: if a DIFFERENT run's row B has replaced our anchor
|
||||||
// row B has replaced as the tail would linger as an orphan — settle A from
|
// row A as the tail, A would linger as an orphan — reconcile A by id from FRESH
|
||||||
// fresh history so no phantom row survives.
|
// PERSISTED history (not the pinned live row) so no phantom row survives.
|
||||||
const stripped = strippedRowRef.current;
|
const anchor = anchorRef.current;
|
||||||
if (stripped && stripped.id !== tail.id) {
|
if (anchor && anchor.id !== tail.id) {
|
||||||
const historical = rows.find((r) => r.id === stripped.id);
|
const historical = rows.find((r) => r.id === anchor.id);
|
||||||
if (historical)
|
if (historical)
|
||||||
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|||||||
|
|
||||||
import MessageItem from "./message-item";
|
import MessageItem from "./message-item";
|
||||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
import { splitPlainChunks } from "./streaming-plain-text";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
expect(queryByText("streamed answer")).not.toBeNull();
|
expect(queryByText("streamed answer")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PERF SMOKE (#492): the whole point of the incremental streaming render is that
|
||||||
|
// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
|
||||||
|
// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
|
||||||
|
// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
|
||||||
|
// calls (one per tick). With the incremental render each STABILIZED block is parsed
|
||||||
|
// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
|
||||||
|
// the call count collapses to ~= the block count regardless of tick granularity.
|
||||||
|
describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
|
||||||
|
// ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
|
||||||
|
// delimited markdown blocks — so the safe-cut block count is ~2× the section
|
||||||
|
// count. The perf claim is about the BLOCK count (the memoization granularity),
|
||||||
|
// measured directly with splitPlainChunks below, not the section count.
|
||||||
|
const buildAnswer = () => {
|
||||||
|
const SECTIONS = 100;
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
for (let i = 0; i < SECTIONS; i++) {
|
||||||
|
paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
|
||||||
|
}
|
||||||
|
const full = paragraphs.join("\n\n");
|
||||||
|
// The number of memoized markdown blocks the incremental render splits into
|
||||||
|
// (all but the live tail are parsed once each).
|
||||||
|
return { full, blocks: splitPlainChunks(full).length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
|
||||||
|
({
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text, state }],
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const { full, blocks } = buildAnswer();
|
||||||
|
const CHUNK = 128; // a realistic ~20Hz throttled delta size
|
||||||
|
const ticks = Math.ceil(full.length / CHUNK);
|
||||||
|
|
||||||
|
let msg = streamMsg(full.slice(0, CHUNK), "streaming");
|
||||||
|
const { rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
|
||||||
|
msg = streamMsg(full.slice(0, end), "streaming");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Finalize: the streaming→done flip renders the whole answer through ONE
|
||||||
|
// canonical pass (visual parity), so the finished DOM matches the pre-#492
|
||||||
|
// output. This is the single extra parse on top of the per-block ones.
|
||||||
|
const done = streamMsg(full, "done");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = renderChatMarkdownSpy.mock.calls.length;
|
||||||
|
// Sanity: the stream really had far more ticks than blocks (else the test is
|
||||||
|
// vacuous — the point is that calls scale with blocks, not ticks).
|
||||||
|
expect(ticks).toBeGreaterThan(blocks * 3);
|
||||||
|
// O(blocks): each stabilized block parsed once + the single final whole-text
|
||||||
|
// parse. A small constant absorbs the finalize render and the live-tail block;
|
||||||
|
// the load-bearing claim is the bound below.
|
||||||
|
expect(calls).toBeLessThanOrEqual(blocks + 2);
|
||||||
|
// ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
|
||||||
|
expect(calls).toBeLessThan(ticks / 3);
|
||||||
|
expect(calls).toBeGreaterThan(blocks / 2);
|
||||||
|
// MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
|
||||||
|
// MarkdownChunk (so every stable block re-parses each tick) drives `calls`
|
||||||
|
// toward `ticks` (~394), reddening both upper-bound assertions above.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
|
||||||
|
// message-item specs.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MessageItem from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
// The REAL canonical renderer (NOT the spy the memo test installs): this file
|
||||||
|
// exercises the actual markdown output so the visual-regression assertions below
|
||||||
|
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "./ai-chat.module.css";
|
||||||
|
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
extra?: Partial<UIMessage>,
|
||||||
|
): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
|
||||||
|
|
||||||
|
const renderRow = (message: UIMessage, turnStreaming = false) =>
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={message}
|
||||||
|
signature={messageSignature(message)}
|
||||||
|
turnStreaming={turnStreaming}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
|
||||||
|
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
|
||||||
|
const ANSWER = [
|
||||||
|
"# Заголовок",
|
||||||
|
"",
|
||||||
|
"Первый абзац с **жирным** и `кодом`.",
|
||||||
|
"",
|
||||||
|
"- пункт один",
|
||||||
|
"- пункт два",
|
||||||
|
"",
|
||||||
|
"Второй абзац.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
|
||||||
|
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
|
||||||
|
const { container } = renderRow(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "done" }]),
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
|
||||||
|
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
|
||||||
|
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
// The list wrapper is really present (guards against a vacuous empty render).
|
||||||
|
expect(container.querySelectorAll("li p").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
|
||||||
|
// Mount mid-stream (live tail) — the DOM here is the incremental view.
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
|
||||||
|
)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Finish the turn: state flips to done AND the turn is no longer streaming.
|
||||||
|
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// After finish there is exactly ONE canonical markdown container whose HTML is
|
||||||
|
// the whole-text render — identical to the non-streaming path above.
|
||||||
|
const blocks = container.querySelectorAll(`.${classes.markdown}`);
|
||||||
|
expect(blocks.length).toBe(1);
|
||||||
|
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizeInternalLinks is honored on the finalized render", () => {
|
||||||
|
const linkAnswer = "См. [страницу](/p/abc).";
|
||||||
|
const { container } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: linkAnswer, state: "done" }]),
|
||||||
|
)}
|
||||||
|
neutralizeInternalLinks
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block!.innerHTML).toBe(
|
||||||
|
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
|
||||||
|
);
|
||||||
|
// The internal link was made inert (no href) by the neutralization flag.
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.hasAttribute("href")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||||
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||||
|
import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
|
||||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
@@ -86,17 +87,39 @@ interface MessageItemProps {
|
|||||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||||
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
|
||||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
* Props are primitives, so React.memo's default shallow compare is exactly right
|
||||||
* `text` string is compared by value).
|
* (the `text` string is compared by value).
|
||||||
|
*
|
||||||
|
* Streaming gate (#492) — mirrors ReasoningBlock:
|
||||||
|
* - `streaming` (this is the live, actively-growing tail part of an in-flight
|
||||||
|
* turn): render incrementally via StreamingMarkdownText — the stabilized blocks
|
||||||
|
* go through the canonical pipeline (each parsed ONCE, memoized) and only the
|
||||||
|
* live tail is cheap plain text. This makes the per-tick cost O(new blocks),
|
||||||
|
* not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
|
||||||
|
* - finalized (the common case, and the turn-end flip): render the WHOLE text
|
||||||
|
* through ONE canonical pass — byte-identical to the pre-#492 output (visual
|
||||||
|
* parity). The row re-renders on the streaming→done flip because
|
||||||
|
* `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
|
||||||
|
* turn end), so the incremental view always converges to this single render.
|
||||||
*/
|
*/
|
||||||
const MarkdownPart = memo(function MarkdownPart({
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
text,
|
text,
|
||||||
neutralizeInternalLinks,
|
neutralizeInternalLinks,
|
||||||
|
streaming,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
neutralizeInternalLinks: boolean;
|
neutralizeInternalLinks: boolean;
|
||||||
|
streaming: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (streaming) {
|
||||||
|
return (
|
||||||
|
<StreamingMarkdownText
|
||||||
|
text={text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
if (html) {
|
if (html) {
|
||||||
return (
|
return (
|
||||||
@@ -179,47 +202,10 @@ function MessageItem({
|
|||||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||||
</Text>
|
</Text>
|
||||||
{message.parts.map((part, index) => {
|
{message.parts.map((part, index) => {
|
||||||
if (part.type === "reasoning") {
|
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
|
||||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
// they cannot be a `switch` case; the runtime guard handles them, and the
|
||||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
// switch below covers every CLOSED (literal-typed) part kind with a
|
||||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
// compile-time exhaustiveness check in its default.
|
||||||
const text = (part as { text?: string }).text ?? "";
|
|
||||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
|
||||||
return null;
|
|
||||||
// Absent state (persisted rows) and "done" both mean finalized.
|
|
||||||
// `messageSignature` already includes each part's `state`, so the
|
|
||||||
// streaming→done flip changes the row signature and re-renders this
|
|
||||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
|
||||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
|
||||||
// ALSO require the turn to be live: a part stranded at
|
|
||||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
|
||||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
|
||||||
const streaming =
|
|
||||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
|
||||||
return (
|
|
||||||
<ReasoningBlock
|
|
||||||
key={index}
|
|
||||||
text={text}
|
|
||||||
tokens={reasoningTokens}
|
|
||||||
streaming={streaming}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "text") {
|
|
||||||
// Skip empty/whitespace-only text parts (a streaming message often
|
|
||||||
// starts with an empty text part before the first token arrives); the
|
|
||||||
// typing indicator covers that gap until real content streams in.
|
|
||||||
if (!part.text.trim()) return null;
|
|
||||||
return (
|
|
||||||
<MarkdownPart
|
|
||||||
key={index}
|
|
||||||
text={part.text}
|
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isToolPart(part.type)) {
|
if (isToolPart(part.type)) {
|
||||||
return (
|
return (
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
@@ -232,7 +218,76 @@ function MessageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (part.type) {
|
||||||
|
case "reasoning": {
|
||||||
|
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||||
|
// count. Empty/whitespace reasoning with no authoritative count
|
||||||
|
// carries nothing to show, so skip it (avoids an empty 0-token block).
|
||||||
|
const text = part.text ?? "";
|
||||||
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
|
return null;
|
||||||
|
// Absent state (persisted rows) and "done" both mean finalized.
|
||||||
|
// `messageSignature` already includes each part's `state`, so the
|
||||||
|
// streaming→done flip changes the row signature and re-renders this
|
||||||
|
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||||
|
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||||
|
// ALSO require the turn to be live: a part stranded at
|
||||||
|
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||||
|
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<ReasoningBlock
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
tokens={reasoningTokens}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "text": {
|
||||||
|
// Skip empty/whitespace-only text parts (a streaming message often
|
||||||
|
// starts with an empty text part before the first token arrives); the
|
||||||
|
// typing indicator covers that gap until real content streams in.
|
||||||
|
if (!part.text.trim()) return null;
|
||||||
|
// The live, actively-growing tail part of the in-flight turn renders
|
||||||
|
// incrementally (see MarkdownPart); a finalized part (persisted, or
|
||||||
|
// the turn-end flip) renders the whole text through one canonical
|
||||||
|
// pass. Same liveness rule as the reasoning branch above.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<MarkdownPart
|
||||||
|
key={index}
|
||||||
|
text={part.text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "source-url":
|
||||||
|
case "source-document":
|
||||||
|
case "file":
|
||||||
|
case "step-start":
|
||||||
|
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Compile-time exhaustiveness over the CLOSED union members: every
|
||||||
|
// literal-typed part kind is handled above, so the only kinds that
|
||||||
|
// can reach here are the OPEN template-literal ones (`tool-*` — caught
|
||||||
|
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
|
||||||
|
// a NEW closed part kind to UIMessagePart makes this assignment fail
|
||||||
|
// to compile, forcing it to be handled instead of silently ignored
|
||||||
|
// (this replaces the pre-#492 fall-through `return null` + WARNING).
|
||||||
|
const _exhaustive:
|
||||||
|
| `tool-${string}`
|
||||||
|
| "dynamic-tool"
|
||||||
|
| `data-${string}` = part.type;
|
||||||
|
void _exhaustive;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||||
here so it survives a thread remount and shows in reopened history. */}
|
here so it survives a thread remount and shows in reopened history. */}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One STABILIZED markdown block, rendered through the canonical pipeline and
|
||||||
|
* memoized on its string prop. During streaming only the TAIL chunk grows (the
|
||||||
|
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
|
||||||
|
* byte-identical across deltas), so React skips every stable block and each one
|
||||||
|
* is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
|
||||||
|
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
|
||||||
|
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
|
||||||
|
* before it reaches `dangerouslySetInnerHTML`.
|
||||||
|
*
|
||||||
|
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
|
||||||
|
* so a construct that legitimately contains a blank line (e.g. a fenced code block
|
||||||
|
* with an empty line) can be split across chunks and render oddly WHILE it is still
|
||||||
|
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
|
||||||
|
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
|
||||||
|
* with the pre-#492 output). The reasoning path makes the same trade (plain text
|
||||||
|
* while streaming, one markdown parse at the end).
|
||||||
|
*/
|
||||||
|
const MarkdownChunk = memo(function MarkdownChunk({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
|
if (html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.markdown}
|
||||||
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Malformed/unsupported markdown could not render synchronously: raw text.
|
||||||
|
return (
|
||||||
|
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
|
||||||
|
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
|
||||||
|
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
|
||||||
|
* block parsed once, memoized) and only the LIVE tail as flat plain text — so the
|
||||||
|
* user sees formatted output for everything up to the last safe cut, and the not-
|
||||||
|
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
|
||||||
|
* single cheap escaped text node until it stabilizes into a new block.
|
||||||
|
*
|
||||||
|
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
|
||||||
|
* except the LAST is immutable; the last chunk is the live tail. Index keys are
|
||||||
|
* therefore stable (a given index never changes to a different chunk's content).
|
||||||
|
*/
|
||||||
|
export function StreamingMarkdownText({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) =>
|
||||||
|
index < chunks.length - 1 ? (
|
||||||
|
<MarkdownChunk
|
||||||
|
key={index}
|
||||||
|
text={chunk}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// The live tail: flat, React-escaped plain text (no markdown parse, no
|
||||||
|
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
|
||||||
|
// separator newlines are dropped at display time so the block gap comes
|
||||||
|
// from the markdown margins, not a doubled empty line (mirrors
|
||||||
|
// PlainChunk in streaming-plain-text.tsx).
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={classes.markdown}
|
||||||
|
style={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{chunk.replace(/\n+$/, "")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,31 @@ export async function stopRun(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta poll (#491): the chat's message rows changed since `cursor` (a DB-clock
|
||||||
|
* timestamp echoed from the previous poll) plus the current run fact, in ONE
|
||||||
|
* round-trip — the degraded-poll fallback's payload, replacing the old "refetch
|
||||||
|
* ALL infinite-query pages every 2.5s with full parts" poll. Omit `cursor` on the
|
||||||
|
* first poll (returns just a fresh cursor, no rows, to start the chain). The
|
||||||
|
* overlap window guarantees occasional REPEATS, so the caller MUST merge rows
|
||||||
|
* idempotently by id (mergeById). Owner-gated server-side.
|
||||||
|
*/
|
||||||
|
export async function getAiChatMessagesDelta(
|
||||||
|
chatId: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<{
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
cursor: string;
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
}> {
|
||||||
|
const req = await api.post<{
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
cursor: string;
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
}>("/ai-chat/messages/delta", { chatId, cursor });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
||||||
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
|||||||
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||||
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||||
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||||
|
| `POLL_IDLE_CAP` (inactivity cap) | stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (Review #4: a Stop-armed poll with no SDK/terminal backstop gets a bounded exit — NOT `stalled`, Stop was already pressed so nothing to retry) |
|
||||||
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||||
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||||
@@ -121,8 +122,7 @@ holds. **Pending column: empty.**
|
|||||||
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
||||||
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
||||||
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
||||||
| 14 | `stripRef` | **data** (attachStrategy) | strip+replay detail; the `resumeStream` effect reads it |
|
| 14–15 | `anchorRef {id, stepsPersisted}` | **data** (attachStrategy) | #491 tail-only: replaced `stripRef`/`strippedRowRef`. The PERSISTED assistant row that pins the run (server invariant 6) + its step frontier N; feeds `?anchor=<id>&n=<stepsPersisted>`. No strip — the seed keeps every row; entering reconnecting re-seeds from persist |
|
||||||
| 15 | `strippedRowRef` | **data** (attachStrategy) | the anchor row |
|
|
||||||
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
||||||
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
||||||
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
||||||
@@ -151,8 +151,12 @@ message. Sources, in the order they update `ctx.runFact`:
|
|||||||
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
||||||
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
||||||
which cancels recovery (I3 fresh-negative gate).
|
which cancels recovery (I3 fresh-negative gate).
|
||||||
4. **Poll (future resume-stack iteration #491):** the delta will carry the run field;
|
4. **Poll (#491, implemented):** the degraded poll now hits the delta endpoint
|
||||||
until then the poll drives to a terminal ROW, dispatched as `POLL_TERMINAL`.
|
(`POST /ai-chat/messages/delta`), which ALREADY carries the run fact
|
||||||
|
(`run: {id, status} | null`) alongside the changed rows. The client does NOT yet
|
||||||
|
consume that run field — it still drives to a terminal ROW (merged by id),
|
||||||
|
dispatched as `POLL_TERMINAL` — so the run field rides the wire for a future
|
||||||
|
client that settles straight off it.
|
||||||
|
|
||||||
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
||||||
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
||||||
@@ -178,6 +182,9 @@ Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); th
|
|||||||
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
||||||
render-phase refs. A client abort of an already-sent POST does not cancel the
|
render-phase refs. A client abort of an already-sent POST does not cancel the
|
||||||
server action, so disarming on unmount is safe.
|
server action, so disarming on unmount is safe.
|
||||||
- **attachStrategy** (strip+replay today) is behind the `resumeStream` effect; the
|
- **attachStrategy** is behind the `resumeStream` effect; #491 swapped it to
|
||||||
resume-stack iteration (#491) swaps it to tail-only WITHOUT touching the FSM.
|
tail-only (`?anchor=&n=`, `anchorRef` data) WITHOUT touching the FSM. Entering
|
||||||
|
reconnecting always re-seeds from persist; on a getRun failure the live partial
|
||||||
|
is dropped + replay-from-start so it is never the tail-apply base (no #137/#161
|
||||||
|
duplication).
|
||||||
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ export interface IAiChatMessageRow {
|
|||||||
toolCalls?: unknown;
|
toolCalls?: unknown;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
parts?: UIMessage["parts"];
|
parts?: UIMessage["parts"];
|
||||||
|
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
|
||||||
|
// THIS row, written atomically with `parts` server-side (flushAssistant). The
|
||||||
|
// resume client reads it as its persisted step frontier N — the tail-only
|
||||||
|
// attach asks the run-stream registry for the frames of step N onward (the
|
||||||
|
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
|
||||||
|
stepsPersisted?: number;
|
||||||
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
||||||
// figure (sum of every step's usage for the turn); kept for back-compat and
|
// figure (sum of every step's usage for the turn); kept for back-compat and
|
||||||
// as the fallback for older rows that have no `contextTokens`.
|
// as the fallback for older rows that have no `contextTokens`.
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ describe("estimateTokens", () => {
|
|||||||
expect(estimateTokens("")).toBe(0);
|
expect(estimateTokens("")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
||||||
|
// from the old client-only chars/4) so the client counter and the server replay
|
||||||
|
// budgeter can never diverge.
|
||||||
|
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
||||||
expect(estimateTokens("a")).toBe(1);
|
expect(estimateTokens("a")).toBe(1);
|
||||||
expect(estimateTokens("abcd")).toBe(1);
|
expect(estimateTokens("ab")).toBe(1);
|
||||||
expect(estimateTokens("abcde")).toBe(2);
|
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
||||||
expect(estimateTokens("12345678")).toBe(2);
|
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,10 @@
|
|||||||
* Rough client-side token estimation for AI-chat UI affordances.
|
* Rough client-side token estimation for AI-chat UI affordances.
|
||||||
*
|
*
|
||||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
||||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
||||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
||||||
* ("Thinking · N tokens").
|
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
||||||
|
* in-body reasoning counter ("Thinking · N tokens").
|
||||||
*/
|
*/
|
||||||
|
export { estimateTokens } from "@docmost/token-estimate";
|
||||||
/**
|
|
||||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
|
||||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
|
||||||
* non-empty text counts as at least one token.
|
|
||||||
*/
|
|
||||||
export function estimateTokens(text: string): number {
|
|
||||||
if (!text) return 0;
|
|
||||||
return Math.ceil(text.length / 4);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ describe("describeChatError", () => {
|
|||||||
expect(view.title).not.toBe("AI provider not configured");
|
expect(view.title).not.toBe("AI provider not configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||||
|
// The exact reason the server persists in metadata.error on a degeneration
|
||||||
|
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||||
|
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||||
|
// so live and refetch never disagree.
|
||||||
|
const view = describeChatError(
|
||||||
|
"Output degeneration detected (repeated token loop)",
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(view.title).toBe("Response stopped.");
|
||||||
|
expect(view.detail).toBe(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
);
|
||||||
|
// Regression guard: it must NOT fall through to the generic heading.
|
||||||
|
expect(view.title).not.toBe("Something went wrong");
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||||
expect(
|
expect(
|
||||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ export function describeChatError(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||||
|
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||||
|
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||||
|
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||||
|
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||||
|
// later refetch show two different texts for one event. The detail explains the
|
||||||
|
// loop-guard cause without contradicting the shared heading.
|
||||||
|
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Response stopped."),
|
||||||
|
detail: t(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||||
return {
|
return {
|
||||||
title: t("AI chat is disabled"),
|
title: t("AI chat is disabled"),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
import {
|
import {
|
||||||
isStreamingTail,
|
isStreamingTail,
|
||||||
isSettledAssistantTail,
|
isSettledAssistantTail,
|
||||||
seedRows,
|
stepsPersistedOf,
|
||||||
|
mergeDeltaRowsIntoPages,
|
||||||
mergeById,
|
mergeById,
|
||||||
} from "./resume-helpers.ts";
|
} from "./resume-helpers.ts";
|
||||||
|
|
||||||
@@ -12,8 +13,18 @@ function row(
|
|||||||
id: string,
|
id: string,
|
||||||
role: string,
|
role: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
|
stepsPersisted?: number,
|
||||||
): IAiChatMessageRow {
|
): IAiChatMessageRow {
|
||||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
return {
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
content: "",
|
||||||
|
status,
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
...(stepsPersisted !== undefined
|
||||||
|
? { metadata: { stepsPersisted } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMsg(id: string, text: string): UIMessage {
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
@@ -65,23 +76,92 @@ describe("isSettledAssistantTail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("seedRows", () => {
|
describe("stepsPersistedOf", () => {
|
||||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
it("reads metadata.stepsPersisted", () => {
|
||||||
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 3))).toBe(3);
|
||||||
it("returns the rows unchanged when not stripping", () => {
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 0))).toBe(0);
|
||||||
expect(seedRows(rows, false)).toBe(rows);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the last row when stripping", () => {
|
it("defaults to 0 for a pre-#491 row (absent), null/undefined, or a bad value", () => {
|
||||||
const seeded = seedRows(rows, true);
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming"))).toBe(0);
|
||||||
expect(seeded).toHaveLength(1);
|
expect(stepsPersistedOf(null)).toBe(0);
|
||||||
expect(seeded[0].id).toBe("u1");
|
expect(stepsPersistedOf(undefined)).toBe(0);
|
||||||
|
expect(
|
||||||
|
stepsPersistedOf({
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: "x",
|
||||||
|
metadata: { stepsPersisted: -2 },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when stripping a single-row list", () => {
|
it("floors a non-integer count", () => {
|
||||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
expect(
|
||||||
0,
|
stepsPersistedOf({
|
||||||
);
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: "x",
|
||||||
|
metadata: { stepsPersisted: 2.9 },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeDeltaRowsIntoPages", () => {
|
||||||
|
const pages = () => [
|
||||||
|
{ items: [row("u1", "user"), row("a1", "assistant", "streaming", 1)], meta: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the pages unchanged for an empty delta", () => {
|
||||||
|
const p = pages();
|
||||||
|
expect(mergeDeltaRowsIntoPages(p, [])).toBe(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a genuinely new row to the last page in chronological order", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages(pages(), [row("a2", "assistant", "streaming", 0)]);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a grown row in place (per-step growth), never appends a duplicate", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages(pages(), [
|
||||||
|
row("a1", "assistant", "streaming", 2),
|
||||||
|
]);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1"]);
|
||||||
|
// the in-place replacement carries the grown step frontier.
|
||||||
|
expect(stepsPersistedOf(merged[0].items[1])).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input pages", () => {
|
||||||
|
const input = pages();
|
||||||
|
const before = input[0].items.slice();
|
||||||
|
mergeDeltaRowsIntoPages(input, [row("a2", "assistant", "streaming", 0)]);
|
||||||
|
expect(input[0].items).toEqual(before); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
// #491 CONTRACT: the delta overlap window re-delivers the same rows, so merging
|
||||||
|
// MUST be idempotent — applying a delta twice equals applying it once (no growth,
|
||||||
|
// no reorder). A regression re-introduces duplicate assistant bubbles per poll.
|
||||||
|
it("is idempotent: applying the same delta twice equals once", () => {
|
||||||
|
const delta = [
|
||||||
|
row("a1", "assistant", "streaming", 2), // grown existing row
|
||||||
|
row("a2", "assistant", "streaming", 0), // new row
|
||||||
|
];
|
||||||
|
const once = mergeDeltaRowsIntoPages(pages(), delta);
|
||||||
|
const twice = mergeDeltaRowsIntoPages(once, delta);
|
||||||
|
const thrice = mergeDeltaRowsIntoPages(twice, delta);
|
||||||
|
expect(once[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice).toEqual(once);
|
||||||
|
expect(thrice).toEqual(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds a first page when the cache is empty", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages([], [row("u1", "user")]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,4 +189,37 @@ describe("mergeById", () => {
|
|||||||
expect(mergeById(prev, null)).toBe(prev);
|
expect(mergeById(prev, null)).toBe(prev);
|
||||||
expect(mergeById(prev, undefined)).toBe(prev);
|
expect(mergeById(prev, undefined)).toBe(prev);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #491 CONTRACT: the delta poll's overlap window GUARANTEES the same row is
|
||||||
|
// re-delivered across close polls, so merging must be IDEMPOTENT by id — merging
|
||||||
|
// the same row (or an equal-length list of rows) twice must not duplicate or
|
||||||
|
// reorder. This is the property the whole delta-poll design leans on; a
|
||||||
|
// regression here would re-introduce duplicate assistant bubbles on every poll.
|
||||||
|
it("is idempotent by id: re-merging the same row does not duplicate or reorder", () => {
|
||||||
|
const seed = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const repeat = makeMsg("a1", "step 1"); // the SAME row the overlap re-delivers
|
||||||
|
const once = mergeById(seed, repeat);
|
||||||
|
const twice = mergeById(once, repeat);
|
||||||
|
const thrice = mergeById(twice, repeat);
|
||||||
|
// Length is stable (no growth), order is stable (user then assistant).
|
||||||
|
expect(once.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
expect(twice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
expect(thrice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
// The repeated merge converges: the row is replaced in place, never appended.
|
||||||
|
expect(twice[1]).toBe(repeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent across a batch of repeated + grown rows (delta re-delivery)", () => {
|
||||||
|
// A delta poll re-delivers a1 (unchanged) and a2 (grown one step). Applying the
|
||||||
|
// batch twice must equal applying it once — the poll can re-send either.
|
||||||
|
const start = [makeMsg("u1", "hi"), makeMsg("a1", "done")];
|
||||||
|
const batch = [makeMsg("a1", "done"), makeMsg("a2", "grown step 2")];
|
||||||
|
const apply = (list: typeof start) =>
|
||||||
|
batch.reduce((acc, row) => mergeById(acc, row), list);
|
||||||
|
const once = apply(start);
|
||||||
|
const twice = apply(once);
|
||||||
|
expect(once.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice).toEqual(once);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
* 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
|
* `status === 'streaming'`. #491 (tail-only): such a tail is seeded UNCHANGED —
|
||||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
* it carries the persisted steps 0..N-1 — and the run-stream registry's tail
|
||||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
* (frames for steps >= N) is APPENDED to it by the SDK's `readUIMessageStream`
|
||||||
|
* continuation. Only the presence of this tail decides WHETHER to attach.
|
||||||
*/
|
*/
|
||||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||||
const tail = rows[rows.length - 1];
|
const tail = rows[rows.length - 1];
|
||||||
@@ -32,15 +33,61 @@ export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
* #491 tail-only anchor: the count of FINISHED steps whose parts are persisted in
|
||||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
* THIS assistant row (`metadata.stepsPersisted`), written atomically with `parts`
|
||||||
* without duplicating parts).
|
* server-side. The resume client reads it as its persisted step frontier N — the
|
||||||
|
* tail-only attach asks the run-stream registry for the frames of step N onward
|
||||||
|
* (the seed already carries steps 0..N-1). Absent on pre-#491 rows => 0.
|
||||||
*/
|
*/
|
||||||
export function seedRows(
|
export function stepsPersistedOf(
|
||||||
|
row: IAiChatMessageRow | null | undefined,
|
||||||
|
): number {
|
||||||
|
const n = row?.metadata?.stepsPersisted;
|
||||||
|
return typeof n === "number" && n >= 0 ? Math.floor(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of the messages infinite-query cache (`{ items, meta }`). */
|
||||||
|
export interface IMessagePage {
|
||||||
|
items: IAiChatMessageRow[];
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #491 delta-poll merge: upsert the delta poll's `rows` into the messages
|
||||||
|
* infinite-query page structure IDEMPOTENTLY by id. The delta endpoint's overlap
|
||||||
|
* window GUARANTEES occasional REPEATS, so this MUST converge: a row already
|
||||||
|
* present is REPLACED IN PLACE (per-step growth of an in-progress row), a new row
|
||||||
|
* is APPENDED to the last page in chronological order (the server returns delta
|
||||||
|
* rows oldest-first). Applying the same delta twice equals applying it once. Never
|
||||||
|
* mutates the input pages (returns fresh page objects with cloned item arrays).
|
||||||
|
*/
|
||||||
|
export function mergeDeltaRowsIntoPages(
|
||||||
|
pages: IMessagePage[],
|
||||||
rows: IAiChatMessageRow[],
|
rows: IAiChatMessageRow[],
|
||||||
strip: boolean,
|
): IMessagePage[] {
|
||||||
): IAiChatMessageRow[] {
|
if (rows.length === 0) return pages;
|
||||||
return strip ? rows.slice(0, -1) : rows;
|
const next: IMessagePage[] = pages.map((p) => ({
|
||||||
|
...p,
|
||||||
|
items: p.items.slice(),
|
||||||
|
}));
|
||||||
|
const locate = (id: string): [number, number] | null => {
|
||||||
|
for (let pi = 0; pi < next.length; pi++) {
|
||||||
|
const ii = next[pi].items.findIndex((it) => it.id === id);
|
||||||
|
if (ii !== -1) return [pi, ii];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (const row of rows) {
|
||||||
|
const at = locate(row.id);
|
||||||
|
if (at) {
|
||||||
|
next[at[0]].items[at[1]] = row; // replace in place — idempotent by id
|
||||||
|
} else if (next.length > 0) {
|
||||||
|
next[next.length - 1].items.push(row); // append chronologically
|
||||||
|
} else {
|
||||||
|
next.push({ items: [row], meta: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readUIMessageStream, type UIMessage } from "ai";
|
||||||
|
import pkg from "../../../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
||||||
|
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
||||||
|
* test, an `ai` bump could silently break attach (the client would append the
|
||||||
|
* live tail to the wrong message, or duplicate a step):
|
||||||
|
*
|
||||||
|
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
||||||
|
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
||||||
|
* the seeded assistant row (the same DB id).
|
||||||
|
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
||||||
|
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
||||||
|
* carries the run-fact metadata).
|
||||||
|
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
||||||
|
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
||||||
|
* the step frontier stays meaningful.
|
||||||
|
*
|
||||||
|
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
||||||
|
* resume path silently corrupting.
|
||||||
|
*/
|
||||||
|
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
||||||
|
it("is pinned to the exact ai version the continuation was verified against", () => {
|
||||||
|
// A caret/range bump is exactly what would silently break attach — require an
|
||||||
|
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
||||||
|
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
||||||
|
"6.0.207",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
||||||
|
// A seeded assistant row with ONE finished step already reconstructed.
|
||||||
|
const seeded: UIMessage = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "step-start" },
|
||||||
|
{ type: "text", text: "STEP0", state: "done" },
|
||||||
|
],
|
||||||
|
} as UIMessage;
|
||||||
|
|
||||||
|
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
||||||
|
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
||||||
|
// decode to).
|
||||||
|
const chunks = [
|
||||||
|
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
||||||
|
{ type: "start-step" },
|
||||||
|
{ type: "text-start", id: "t1" },
|
||||||
|
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
||||||
|
{ type: "text-end", id: "t1" },
|
||||||
|
{ type: "finish-step" },
|
||||||
|
{ type: "finish" },
|
||||||
|
];
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(c) {
|
||||||
|
for (const ch of chunks) c.enqueue(ch);
|
||||||
|
c.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let last: UIMessage | undefined;
|
||||||
|
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
||||||
|
last = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(last).toBeDefined();
|
||||||
|
// Same message id (continuation, not a fresh message).
|
||||||
|
expect(last!.id).toBe("assistant-1");
|
||||||
|
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
||||||
|
// as SEPARATE parts (text did not cross the finish-step boundary).
|
||||||
|
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
||||||
|
expect(shape).toEqual([
|
||||||
|
"step-start:",
|
||||||
|
"text:STEP0",
|
||||||
|
"step-start:",
|
||||||
|
"text:STEP1",
|
||||||
|
]);
|
||||||
|
// The run-fact metadata from the synthetic start frame is applied.
|
||||||
|
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
|
|||||||
import {
|
import {
|
||||||
buildChildrenByParent,
|
buildChildrenByParent,
|
||||||
CommentEditorWithActions,
|
CommentEditorWithActions,
|
||||||
|
sortResolvedByResolvedAt,
|
||||||
} from "./comment-list-with-tabs";
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
({ id, parentCommentId }) as IComment;
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
|
||||||
|
({ id, resolvedAt }) as unknown as IComment;
|
||||||
|
|
||||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
it("returns an empty map for undefined or empty input", () => {
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
@@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
|
||||||
|
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
|
||||||
|
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
|
||||||
|
// the sort must coerce with new Date(...) before .getTime().
|
||||||
|
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
|
||||||
|
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
|
||||||
|
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
|
||||||
|
|
||||||
|
const out = sortResolvedByResolvedAt([older, newest, middle]);
|
||||||
|
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also handles Date instances (optimistic onMutate window)", () => {
|
||||||
|
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
|
||||||
|
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
|
||||||
|
"newer",
|
||||||
|
"older",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input array", () => {
|
||||||
|
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
|
||||||
|
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
|
||||||
|
const input = [a, b];
|
||||||
|
sortResolvedByResolvedAt(input);
|
||||||
|
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable order for equal resolvedAt timestamps", () => {
|
||||||
|
const ts = "2026-03-03T03:03:03.000Z";
|
||||||
|
const x = resolvedAtComment("x", ts);
|
||||||
|
const y = resolvedAtComment("y", ts);
|
||||||
|
const z = resolvedAtComment("z", ts);
|
||||||
|
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function renderReplyEditor() {
|
function renderReplyEditor() {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ export function buildChildrenByParent(
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
|
||||||
|
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
|
||||||
|
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
|
||||||
|
// real Date only during the optimistic onMutate window — so it MUST be coerced
|
||||||
|
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
|
||||||
|
// throws / yields NaN. ES2019's stable sort preserves order for equal
|
||||||
|
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
|
||||||
|
// the non-null assertion is safe.
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
|
||||||
|
return [...resolved].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -91,7 +107,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
(comment: IComment) => comment.resolvedAt,
|
(comment: IComment) => comment.resolvedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
return {
|
||||||
|
activeComments: active,
|
||||||
|
resolvedComments: sortResolvedByResolvedAt(resolved),
|
||||||
|
};
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and
|
||||||
|
* its double-click guard, the terminal 404 branch (drop from cache + clear the
|
||||||
|
* inline mark, no rollback), and the directional error copy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
|
||||||
|
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
|
||||||
|
const editorMock = vi.hoisted(() => ({
|
||||||
|
current: {
|
||||||
|
isDestroyed: false,
|
||||||
|
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||||
|
} as {
|
||||||
|
isDestroyed: boolean;
|
||||||
|
commands: {
|
||||||
|
unsetComment: (id: string) => void;
|
||||||
|
setCommentResolved: (id: string, v: boolean) => void;
|
||||||
|
};
|
||||||
|
} | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn(), hide: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("jotai", () => ({
|
||||||
|
atom: (v: unknown) => v,
|
||||||
|
useAtomValue: () => editorMock.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useResolveCommentMutation,
|
||||||
|
RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [
|
||||||
|
{ items: [comment], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
resolvedAt: null,
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
// Pull the inline Undo button's onClick out of the success toast's message tree.
|
||||||
|
function undoOnClickFromToast(): () => void {
|
||||||
|
const call = vi
|
||||||
|
.mocked(notifications.show)
|
||||||
|
.mock.calls.map((c) => c[0])
|
||||||
|
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||||
|
expect(call).toBeTruthy();
|
||||||
|
const message: any = (call as any).message;
|
||||||
|
// message = Group( Text, Button ); grab the Button element's onClick.
|
||||||
|
const children = message.props.children as any[];
|
||||||
|
const button = children[1];
|
||||||
|
return button.props.onClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useResolveCommentMutation — Undo toast (#542)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
editorMock.current = {
|
||||||
|
isDestroyed: false,
|
||||||
|
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
|
||||||
|
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||||
|
comment({
|
||||||
|
resolvedAt: data.resolved ? (new Date() as any) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const resolveToast = vi
|
||||||
|
.mocked(notifications.show)
|
||||||
|
.mock.calls.map((c) => c[0])
|
||||||
|
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||||
|
expect(resolveToast).toBeTruthy();
|
||||||
|
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
|
||||||
|
expect((resolveToast as any).autoClose).toBe(10000);
|
||||||
|
|
||||||
|
// Now a reopen → plain toast, no autoClose/Undo, no id.
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||||
|
expect(
|
||||||
|
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||||
|
).toBe(false);
|
||||||
|
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
|
||||||
|
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||||
|
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
|
||||||
|
);
|
||||||
|
const { wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const onClick = undoOnClickFromToast();
|
||||||
|
// Fire twice synchronously (notifications.hide is not synchronous).
|
||||||
|
onClick();
|
||||||
|
onClick();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const reopenCalls = vi
|
||||||
|
.mocked(resolveComment)
|
||||||
|
.mock.calls.filter(([d]) => d.resolved === false);
|
||||||
|
expect(reopenCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
// The mark was cleared once via setCommentResolved(id, false).
|
||||||
|
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
|
||||||
|
"c-1",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// The toast was hidden.
|
||||||
|
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Removed from cache (NOT rolled back to a phantom).
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
|
||||||
|
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
|
||||||
|
"c-1",
|
||||||
|
);
|
||||||
|
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment no longer exists",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||||
|
expect(
|
||||||
|
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
|
||||||
|
editorMock.current = null;
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment no longer exists",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
// Seed a RESOLVED comment (the reopen target).
|
||||||
|
const resolved = comment({ resolvedAt: new Date() as any });
|
||||||
|
const { queryClient, wrapper } = seededClient(resolved);
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Failed to re-open comment",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "Failed to resolve comment" }),
|
||||||
|
);
|
||||||
|
// Rolled back: the comment is still present and still resolved.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
|
||||||
|
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
|
||||||
|
vi.mocked(resolveComment).mockResolvedValueOnce(
|
||||||
|
comment({ resolvedAt: new Date() as any }),
|
||||||
|
);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Now the reopen fired by Undo fails with a 500.
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
const onClick = undoOnClickFromToast();
|
||||||
|
onClick();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
|
||||||
|
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
|
||||||
|
// doc would show an active highlight the panel still treats as resolved and
|
||||||
|
// the collab mark would diverge with nothing committed on the server.
|
||||||
|
expect(
|
||||||
|
editorMock.current!.commands.setCommentResolved,
|
||||||
|
).not.toHaveBeenCalledWith("c-1", false);
|
||||||
|
// Cache rolled back: the comment stays resolved and present.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
|
||||||
|
// Read-only view / panel closed: pageEditorAtom is null on the success path.
|
||||||
|
editorMock.current = null;
|
||||||
|
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
|
||||||
|
const resolved = comment({ resolvedAt: new Date() as any });
|
||||||
|
const { queryClient, wrapper } = seededClient(resolved);
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment re-opened successfully",
|
||||||
|
});
|
||||||
|
// Cache updated to reopened (resolvedAt cleared by the server payload).
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Failed to resolve comment",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
// Rolled back to open (previousCache), still present.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,12 +20,19 @@ import {
|
|||||||
ISuggestionOutcome,
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { Button, Group, Text } from "@mantine/core";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
|
// How long the resolve success toast (with its inline Undo) stays up before it
|
||||||
|
// auto-closes. Policy constant — no env override.
|
||||||
|
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
|
||||||
|
|
||||||
export function useCommentsQuery(params: ICommentParams) {
|
export function useCommentsQuery(params: ICommentParams) {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: RQ_KEY(params.pageId),
|
queryKey: RQ_KEY(params.pageId),
|
||||||
@@ -376,7 +383,25 @@ export function useResolveCommentMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
|
||||||
|
// clear the inline comment mark AFTER the originating CommentListItem has
|
||||||
|
// unmounted (resolving pulls the comment out of the Open list, so its item is
|
||||||
|
// already gone by the time the 10s toast is clicked). In read-only view
|
||||||
|
// pageEditorAtom is null and the mark converges via the server's
|
||||||
|
// COMMENT_MARK_UPDATE job instead.
|
||||||
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
|
const editorRef = useRef(editor);
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
|
||||||
|
// long after the triggering component unmounted. Declared BEFORE useMutation
|
||||||
|
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
|
||||||
|
// definition time, so there is no initialization cycle.
|
||||||
|
const mutationRef = useRef<{
|
||||||
|
mutate: (vars: IResolveComment) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||||
@@ -401,7 +426,39 @@ export function useResolveCommentMutation() {
|
|||||||
|
|
||||||
return { previousCache };
|
return { previousCache };
|
||||||
},
|
},
|
||||||
onError: (_err, variables, context) => {
|
onError: (err: any, variables, context) => {
|
||||||
|
// Terminal 404: the comment was really deleted (missing comment or deleted
|
||||||
|
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
|
||||||
|
// roll back (that would resurrect a phantom row in Resolved); instead drop
|
||||||
|
// it from the cache and clear its now-orphaned inline mark. Mirrors
|
||||||
|
// handleDeleteComment and the dismiss-mutation 404 branch.
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ed = editorRef.current;
|
||||||
|
if (ed && !ed.isDestroyed) {
|
||||||
|
try {
|
||||||
|
ed.commands.unsetComment(variables.commentId);
|
||||||
|
} catch {
|
||||||
|
/* editor gone / mark already removed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Comment no longer exists"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
|
||||||
|
// error (resolve vs. reopen), not always "resolve".
|
||||||
if (context?.previousCache) {
|
if (context?.previousCache) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
RQ_KEY(variables.pageId),
|
RQ_KEY(variables.pageId),
|
||||||
@@ -409,7 +466,9 @@ export function useResolveCommentMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to resolve comment"),
|
message: variables.resolved
|
||||||
|
? t("Failed to resolve comment")
|
||||||
|
: t("Failed to re-open comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -430,11 +489,72 @@ export function useResolveCommentMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen keeps the plain toast without an Undo.
|
||||||
|
if (!variables.resolved) {
|
||||||
|
// Clear the inline mark ONLY after the server confirms the reopen, so a
|
||||||
|
// failed reopen never leaves an active highlight the panel still treats
|
||||||
|
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
|
||||||
|
// The button-triggered reopen already set the mark, so this is an
|
||||||
|
// idempotent no-op there.
|
||||||
|
const ed = editorRef.current;
|
||||||
|
if (ed && !ed.isDestroyed) {
|
||||||
|
try {
|
||||||
|
ed.commands.setCommentResolved(variables.commentId, false);
|
||||||
|
} catch {
|
||||||
|
/* editor gone — server COMMENT_MARK_UPDATE converges it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Comment re-opened successfully") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
|
||||||
|
// React.createElement because this is a .ts module (no JSX).
|
||||||
|
const { commentId, pageId } = variables;
|
||||||
|
const notificationId = `resolve-undo-${commentId}`;
|
||||||
|
// Double-click guard: notifications.hide is NOT synchronous, so the button
|
||||||
|
// stays clickable for a frame or two — without this a fast double-click
|
||||||
|
// would fire reopen twice.
|
||||||
|
let done = false;
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: variables.resolved
|
id: notificationId,
|
||||||
? t("Comment resolved successfully")
|
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||||
: t("Comment re-opened successfully"),
|
message: React.createElement(
|
||||||
|
Group,
|
||||||
|
{ justify: "space-between", wrap: "nowrap", gap: "md" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ size: "sm" },
|
||||||
|
t("Comment resolved successfully"),
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
variant: "subtle",
|
||||||
|
size: "compact-sm",
|
||||||
|
onClick: () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
// Reopen via the SAME mutation (read at click time — the
|
||||||
|
// originating item is already unmounted).
|
||||||
|
mutationRef.current?.mutate({
|
||||||
|
commentId,
|
||||||
|
pageId,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
// The inline mark is cleared in the reopen mutation's onSuccess
|
||||||
|
// (bound to server confirmation), NOT here — clearing it eagerly
|
||||||
|
// would desync the doc from the panel if reopen then fails.
|
||||||
|
notifications.hide(notificationId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
t("Undo"),
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
return mutation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import { atom } from "jotai";
|
|||||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
// shell component that reads one of these atoms.
|
// shell component that reads one of these atoms.
|
||||||
import type { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
|
// #370 — the active page's collab provider, published by the page editor so the
|
||||||
|
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
|
||||||
|
// Null when the page is read-only / collab isn't connected. A typed initial
|
||||||
|
// value (rather than an explicit generic) keeps jotai's overload resolution on
|
||||||
|
// the writable PrimitiveAtom branch.
|
||||||
|
const initialCollabProvider: HocuspocusProvider | null = null;
|
||||||
|
export const collabProviderAtom = atom(initialCollabProvider);
|
||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||||
|
|
||||||
|
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||||
|
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||||
|
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||||
|
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||||
|
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||||
|
// silently enabling/disabling the toolbar buttons in production.
|
||||||
|
describe("yHistoryAvailability", () => {
|
||||||
|
it("reports availability from the stack lengths", () => {
|
||||||
|
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: false });
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||||
|
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||||
|
// fields: the caller then falls back to the safe prosemirror-history default
|
||||||
|
// instead of throwing on `.length` of undefined or reading garbage.
|
||||||
|
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||||
|
expect(yHistoryAvailability(null)).toBeNull();
|
||||||
|
expect(yHistoryAvailability({})).toBeNull();
|
||||||
|
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||||
|
// Only one stack present (partial rename) is still not trusted.
|
||||||
|
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const text = doc.getText("prosemirror");
|
||||||
|
const undoManager = new Y.UndoManager(text);
|
||||||
|
|
||||||
|
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||||
|
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||||
|
// the expectation below fails loudly.
|
||||||
|
text.insert(0, "hello");
|
||||||
|
undoManager.stopCapturing();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||||
|
undoManager.undo();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,30 @@ export interface ToolbarState {
|
|||||||
// When neither history backend is installed (the pre-sync static editor —
|
// When neither history backend is installed (the pre-sync static editor —
|
||||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||||
// matching the previous `safeCan` behavior.
|
// matching the previous `safeCan` behavior.
|
||||||
|
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||||
|
//
|
||||||
|
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||||
|
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||||
|
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||||
|
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||||
|
// returns null and the caller falls back to a safe default. The pin-test in
|
||||||
|
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||||
|
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||||
|
export function yHistoryAvailability(
|
||||||
|
undoManager: unknown,
|
||||||
|
): { canUndo: boolean; canRedo: boolean } | null {
|
||||||
|
if (!undoManager || typeof undoManager !== "object") return null;
|
||||||
|
const { undoStack, redoStack } = undoManager as {
|
||||||
|
undoStack?: unknown;
|
||||||
|
redoStack?: unknown;
|
||||||
|
};
|
||||||
|
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||||
|
return {
|
||||||
|
canUndo: undoStack.length > 0,
|
||||||
|
canRedo: redoStack.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function historyAvailability(editor: Editor): {
|
function historyAvailability(editor: Editor): {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): {
|
|||||||
|
|
||||||
// Collaboration history (Yjs) takes precedence when present.
|
// Collaboration history (Yjs) takes precedence when present.
|
||||||
const yState = yUndoPluginKey.getState(state) as
|
const yState = yUndoPluginKey.getState(state) as
|
||||||
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
| { undoManager?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (yState?.undoManager) {
|
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||||
return {
|
if (yAvail) return yAvail;
|
||||||
canUndo: yState.undoManager.undoStack.length > 0,
|
|
||||||
canRedo: yState.undoManager.redoStack.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||||
|
// This is also the safe default when a Yjs UndoManager is present but its
|
||||||
|
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||||
return {
|
return {
|
||||||
canUndo: undoDepth(state) > 0,
|
canUndo: undoDepth(state) > 0,
|
||||||
canRedo: redoDepth(state) > 0,
|
canRedo: redoDepth(state) > 0,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Italic } from "@tiptap/extension-italic";
|
|||||||
import { Link } from "@tiptap/extension-link";
|
import { Link } from "@tiptap/extension-link";
|
||||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
const ZWSP = ""; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #377 — the web-side bridge must append the native host's transcript below the
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
@@ -18,8 +18,9 @@ const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
|||||||
* regression would be caught), asserting the resulting document rather than
|
* regression would be caught), asserting the resulting document rather than
|
||||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
|
||||||
* paragraphs; absent/empty/non-string -> no-op.
|
* serializer block-escapes them, so no client-side ZWSP is needed);
|
||||||
|
* absent/empty/non-string -> no-op.
|
||||||
*/
|
*/
|
||||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
const makeEditor = () =>
|
const makeEditor = () =>
|
||||||
@@ -91,19 +92,22 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
|||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
|
||||||
const editor = makeEditor();
|
const editor = makeEditor();
|
||||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
|
||||||
|
// git-sync serializer now block-escapes a leading trigger itself, so the
|
||||||
|
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
|
||||||
|
// trimmed) — no invisible ZWSP is prepended anymore.
|
||||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
editor,
|
editor,
|
||||||
[
|
[
|
||||||
"- dash",
|
"- dash",
|
||||||
" > quote", // leading indent must be trimmed then neutralized
|
" > quote", // leading indent is trimmed, text otherwise verbatim
|
||||||
"# hash",
|
"# hash",
|
||||||
"1. one",
|
"1. one",
|
||||||
"> [!info] note",
|
"> [!info] note",
|
||||||
"```js",
|
"```js",
|
||||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
"---",
|
||||||
"***",
|
"***",
|
||||||
"___",
|
"___",
|
||||||
"You: normal line",
|
"You: normal line",
|
||||||
@@ -116,20 +120,23 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
|||||||
.map((n: any) => n.content?.[0]?.text)
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
.filter((t: any) => typeof t === "string") as string[];
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
// Each trigger line is stored as its own byte-exact text (indent trimmed);
|
||||||
// trimmed first); the normal `You:` line is left byte-exact.
|
// the git-sync round-trip keeps it a paragraph via the serializer's
|
||||||
|
// block-escape, so no ZWSP is needed here.
|
||||||
expect(texts).toEqual([
|
expect(texts).toEqual([
|
||||||
ZWSP + "- dash",
|
"- dash",
|
||||||
ZWSP + "> quote",
|
"> quote",
|
||||||
ZWSP + "# hash",
|
"# hash",
|
||||||
ZWSP + "1. one",
|
"1. one",
|
||||||
ZWSP + "> [!info] note",
|
"> [!info] note",
|
||||||
ZWSP + "```js",
|
"```js",
|
||||||
ZWSP + "---",
|
"---",
|
||||||
ZWSP + "***",
|
"***",
|
||||||
ZWSP + "___",
|
"___",
|
||||||
"You: normal line",
|
"You: normal line",
|
||||||
]);
|
]);
|
||||||
|
// Guard: no invisible ZWSP leaked into any inserted line.
|
||||||
|
for (const t of texts) expect(t).not.toContain(ZWSP);
|
||||||
|
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -240,45 +240,22 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
|
||||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
|
||||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
|
||||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
|
||||||
const GITMOST_ZWSP = "";
|
|
||||||
|
|
||||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
|
||||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
|
||||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
|
||||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
|
||||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
|
||||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
|
||||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
|
||||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
|
||||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
|
||||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
|
||||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
|
||||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
|
||||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
|
||||||
// and never matches, so prefixed lines are left byte-exact.
|
|
||||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
|
||||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
|
||||||
|
|
||||||
// Append a transcript block BELOW the recording's audio node in a live editor:
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
// line. The transcript is plain text, `\n`-separated, each line already
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
// leak into the display). A line that begins with a col-0 markdown block
|
||||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
|
||||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
|
||||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
|
||||||
// serializer's missing block-escape). This is best-effort and meant to run
|
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
|
||||||
// AFTER the audio has already been inserted; the caller must guard against a
|
// gone. This is best-effort and meant to run AFTER the audio has already been
|
||||||
// throw so a transcript failure never fails the (already successful) recording.
|
// inserted; the caller must guard against a throw so a transcript failure never
|
||||||
// Returns true when a block was inserted, false when there was nothing to
|
// fails the (already successful) recording. Returns true when a block was
|
||||||
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
// inserted, false when there was nothing to insert (transcript
|
||||||
// no-op, not an error.
|
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
|
||||||
export function gitmostInsertTranscriptIntoEditor(
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
transcript: unknown,
|
transcript: unknown,
|
||||||
@@ -288,13 +265,7 @@ export function gitmostInsertTranscriptIntoEditor(
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
// Trim each line and drop blank (whitespace-only) ones.
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter((line) => line.length > 0);
|
||||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
|
||||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
|
||||||
// `Speaker N:`) never match and stay byte-exact.
|
|
||||||
.map((line) =>
|
|
||||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
|
||||||
);
|
|
||||||
if (lines.length === 0) return false;
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
const content = [
|
const content = [
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
|
collabProviderAtom,
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
VERSION_SAVED_MESSAGE_TYPE,
|
||||||
|
type VersionSavedMessage,
|
||||||
|
saveVersionPending,
|
||||||
|
} from "@/features/page-history/version-messages";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -124,6 +131,7 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
|
const setCollabProvider = useSetAtom(collabProviderAtom);
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
@@ -181,6 +189,24 @@ export default function PageEditor({
|
|||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(payload);
|
const message = JSON.parse(payload);
|
||||||
|
// #370 — a version was saved somewhere; live-refresh the history panel
|
||||||
|
// on every client. Only the client that pressed Save (tracked by the
|
||||||
|
// module-level flag) shows the confirmation toast.
|
||||||
|
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
|
||||||
|
const versionMsg = message as VersionSavedMessage;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-history-list"],
|
||||||
|
});
|
||||||
|
if (saveVersionPending.current) {
|
||||||
|
saveVersionPending.current = false;
|
||||||
|
notifications.show({
|
||||||
|
message: versionMsg.alreadySaved
|
||||||
|
? t("Already saved as the latest version")
|
||||||
|
: t("Version saved"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
@@ -238,12 +264,16 @@ export default function PageEditor({
|
|||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
local.on("synced", onLocalSyncedHandler);
|
||||||
providersRef.current = { socket, local, remote };
|
providersRef.current = { socket, local, remote };
|
||||||
|
// #370 — publish the provider so the header menu can emit save-version.
|
||||||
|
setCollabProvider(remote);
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
|
setCollabProvider(providersRef.current.remote);
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
setCollabProvider(null);
|
||||||
providersRef.current?.socket.destroy();
|
providersRef.current?.socket.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
UnstyledButton,
|
||||||
|
Avatar,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
@@ -7,36 +14,59 @@ import clsx from "clsx";
|
|||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
|
||||||
|
* marks the intentional points (manual / agent); autosaves (boundary / idle /
|
||||||
|
* legacy null) are non-versions and get dimmed in the list.
|
||||||
|
*/
|
||||||
|
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
|
||||||
|
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
||||||
|
switch (kind) {
|
||||||
|
case "manual":
|
||||||
|
return { labelKey: "Saved", color: "blue", version: true };
|
||||||
|
case "agent":
|
||||||
|
return { labelKey: "Agent version", color: "violet", version: true };
|
||||||
|
case "boundary":
|
||||||
|
return { labelKey: "Boundary", color: "gray", version: false };
|
||||||
|
default: // "idle" | null | undefined (legacy autosave)
|
||||||
|
return { labelKey: "Autosave", color: "gray", version: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
index: number;
|
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
||||||
onSelect: (id: string, index: number) => void;
|
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
|
||||||
onHover?: (id: string, index: number) => void;
|
// own id — never a list index (which would be the filtered-view index).
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onHover?: (id: string) => void;
|
||||||
onHoverEnd?: () => void;
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
historyItem,
|
historyItem,
|
||||||
index,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const kindMeta = historyKindMeta(historyItem.kind);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(historyItem.id, index);
|
onSelect(historyItem.id);
|
||||||
}, [onSelect, historyItem.id, index]);
|
}, [onSelect, historyItem.id]);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onHover?.(historyItem.id, index);
|
onHover?.(historyItem.id);
|
||||||
}, [onHover, historyItem.id, index]);
|
}, [onHover, historyItem.id]);
|
||||||
|
|
||||||
const contributors = historyItem.contributors;
|
const contributors = historyItem.contributors;
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
@@ -49,8 +79,20 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={onHoverEnd}
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
|
// #370 — dim autosnapshots so intentional versions stand out.
|
||||||
|
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
|
||||||
>
|
>
|
||||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
variant={kindMeta.version ? "filled" : "light"}
|
||||||
|
color={kindMeta.color}
|
||||||
|
>
|
||||||
|
{t(kindMeta.labelKey)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import {
|
|||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
prefetchPageHistory,
|
prefetchPageHistory,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem, {
|
||||||
|
historyKindMeta,
|
||||||
|
} from "@/features/page-history/components/history-item";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -17,9 +19,12 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
|
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
||||||
|
|
||||||
const PREFETCH_DELAY_MS = 150;
|
const PREFETCH_DELAY_MS = 150;
|
||||||
|
|
||||||
@@ -47,6 +52,22 @@ function HistoryList({ pageId }: Props) {
|
|||||||
[pageHistoryData],
|
[pageHistoryData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
|
||||||
|
// null), keep only intentional points (manual/agent). Filtering is over the
|
||||||
|
// already-loaded pages; the diff/restore still targets the true previous
|
||||||
|
// snapshot, so items carry their index within the FULL list.
|
||||||
|
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||||
|
// Reuse historyKindMeta().version — the SAME predicate the badge (HistoryItem)
|
||||||
|
// uses to mark intentional points — so the "Only versions" filter and the badge
|
||||||
|
// can never drift apart when a future intentional kind is added.
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() =>
|
||||||
|
onlyVersions
|
||||||
|
? historyItems.filter((item) => historyKindMeta(item.kind).version)
|
||||||
|
: historyItems,
|
||||||
|
[historyItems, onlyVersions],
|
||||||
|
);
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -60,11 +81,13 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHover = useCallback(
|
const handleHover = useCallback(
|
||||||
(historyId: string, index: number) => {
|
(historyId: string) => {
|
||||||
clearPrefetchTimeout();
|
clearPrefetchTimeout();
|
||||||
prefetchTimeoutRef.current = setTimeout(() => {
|
prefetchTimeoutRef.current = setTimeout(() => {
|
||||||
prefetchPageHistory(historyId);
|
prefetchPageHistory(historyId);
|
||||||
const prevId = historyItems[index + 1]?.id;
|
// The true previous snapshot in the FULL list (not the previous visible
|
||||||
|
// one under the "only versions" filter).
|
||||||
|
const prevId = resolvePrevSnapshotId(historyItems, historyId);
|
||||||
if (prevId) {
|
if (prevId) {
|
||||||
prefetchPageHistory(prevId);
|
prefetchPageHistory(prevId);
|
||||||
}
|
}
|
||||||
@@ -78,9 +101,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, [clearPrefetchTimeout]);
|
}, [clearPrefetchTimeout]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string, index: number) => {
|
(id: string) => {
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(id);
|
||||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
// Baseline = true previous snapshot in the FULL list, so the "only
|
||||||
|
// versions" filter never diffs/restores against the wrong item.
|
||||||
|
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
||||||
},
|
},
|
||||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
);
|
||||||
@@ -128,12 +153,27 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<Group px="xs" py={6} justify="flex-end">
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={onlyVersions}
|
||||||
|
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
||||||
|
label={t("Only versions")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{historyItems.map((historyItem, index) => (
|
{onlyVersions && visibleItems.length === 0 && (
|
||||||
|
<Center py="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("No saved versions yet.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
{visibleItems.map((historyItem) => (
|
||||||
<HistoryItem
|
<HistoryItem
|
||||||
key={historyItem.id}
|
key={historyItem.id}
|
||||||
historyItem={historyItem}
|
historyItem={historyItem}
|
||||||
index={index}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface IPageHistory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
contributors?: IPageHistoryUser[];
|
contributors?: IPageHistoryUser[];
|
||||||
|
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
|
||||||
|
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
|
||||||
|
// autosave. Derived server-side, drives the history badge + "versions" filter.
|
||||||
|
kind?: "manual" | "agent" | "idle" | "boundary" | null;
|
||||||
// Provenance markers copied off the page row when the snapshot was saved.
|
// Provenance markers copied off the page row when the snapshot was saved.
|
||||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||||
// (when present) deep-links to the chat that produced the edit.
|
// (when present) deep-links to the chat that produced the edit.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
|
||||||
|
|
||||||
|
// #370 F4 — the risky client path: with the "only versions" filter active, diff
|
||||||
|
// and restore must still baseline against the TRUE previous snapshot in the FULL
|
||||||
|
// list, never the previous VISIBLE version (which would skip the autosnapshots
|
||||||
|
// between two versions). These pin that the resolution is by FULL-list order.
|
||||||
|
describe("resolvePrevSnapshotId", () => {
|
||||||
|
// Newest-first, as the history list stores it: a version, then two autosaves,
|
||||||
|
// then an older version.
|
||||||
|
const full = [
|
||||||
|
{ id: "v2", kind: "manual" },
|
||||||
|
{ id: "a2", kind: "idle" },
|
||||||
|
{ id: "a1", kind: "boundary" },
|
||||||
|
{ id: "v1", kind: "manual" },
|
||||||
|
{ id: "a0", kind: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the immediate FULL-list successor, not the previous visible version", () => {
|
||||||
|
// Selecting v2 while filtered to versions-only must baseline against a2 (the
|
||||||
|
// real chronological predecessor), NOT v1 (the previous visible version).
|
||||||
|
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an autosnapshot's predecessor by full-list order", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '' for the oldest item (no predecessor)", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '' for an id not in the list", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not depend on a filtered subset — same result whatever is visible", () => {
|
||||||
|
// The helper only ever sees the full list; a filtered view cannot change the
|
||||||
|
// baseline it computes.
|
||||||
|
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* #370 — resolve the TRUE previous snapshot for a history item.
|
||||||
|
*
|
||||||
|
* The history panel can be filtered to "only versions" (manual/agent), but diff
|
||||||
|
* and restore must always compare against the immediately-preceding snapshot in
|
||||||
|
* the FULL, unfiltered list — NOT the previous VISIBLE item. Comparing against
|
||||||
|
* the previous visible version would silently skip the autosnapshots between two
|
||||||
|
* versions and diff/restore the wrong baseline.
|
||||||
|
*
|
||||||
|
* Given the full (newest-first) list and an item id, this returns the id of the
|
||||||
|
* item right after it in the full list (its chronological predecessor), or "" if
|
||||||
|
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
|
||||||
|
* tested without mounting the component.
|
||||||
|
*/
|
||||||
|
export function resolvePrevSnapshotId(
|
||||||
|
fullItems: ReadonlyArray<{ id: string }>,
|
||||||
|
id: string,
|
||||||
|
): string {
|
||||||
|
const index = fullItems.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return "";
|
||||||
|
return fullItems[index + 1]?.id ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* #370 — page-version stateless wire formats. Kept in one place so the client
|
||||||
|
* emitter (Save hotkey / button) and the client listener (page-editor) agree
|
||||||
|
* with the server (PersistenceExtension) on the message shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Client → server: "save a version now". The server derives the tier
|
||||||
|
* (manual/agent) from the signed connection actor, never from this payload. */
|
||||||
|
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
||||||
|
|
||||||
|
/** Server → all clients: a version was saved (or promoted / already existed). */
|
||||||
|
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
||||||
|
|
||||||
|
export interface VersionSavedMessage {
|
||||||
|
type: typeof VERSION_SAVED_MESSAGE_TYPE;
|
||||||
|
historyId: string;
|
||||||
|
kind: "manual" | "agent";
|
||||||
|
/** True when the latest snapshot was already a manual version (a no-op save). */
|
||||||
|
alreadySaved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-component coordination flag so only the client that pressed Save shows
|
||||||
|
* the confirmation toast, while every other client silently refreshes its
|
||||||
|
* history panel on the broadcast. A module-level ref avoids stale-closure
|
||||||
|
* pitfalls in the editor's long-lived stateless handler.
|
||||||
|
*/
|
||||||
|
export const saveVersionPending = { current: false };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatHeadline,
|
||||||
|
formatDayTotal,
|
||||||
|
formatGapMinutes,
|
||||||
|
} from "./format-work-time";
|
||||||
|
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||||
|
// assert the rounding + branch selection without depending on the i18n catalogue.
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||||
|
|
||||||
|
describe("formatHeadline", () => {
|
||||||
|
it("prefixes ≈ and rounds to a 5-minute step", () => {
|
||||||
|
expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m");
|
||||||
|
expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows hours only / minutes only cleanly", () => {
|
||||||
|
expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h");
|
||||||
|
expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors a tiny non-zero estimate to 5m, never 0", () => {
|
||||||
|
expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty string for zero (widget hidden)", () => {
|
||||||
|
expect(formatHeadline(0, t)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDayTotal", () => {
|
||||||
|
it('renders "h m" and shows — for empty days', () => {
|
||||||
|
expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m");
|
||||||
|
expect(formatDayTotal(0, t)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatGapMinutes", () => {
|
||||||
|
it("converts the tGap ms threshold to whole minutes", () => {
|
||||||
|
expect(formatGapMinutes(15 * MIN)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// #395 — display formatting for the work-time estimate. Pure functions that take
|
||||||
|
// a translator so ru-RU / en-US wording lives in the i18n catalogue and the
|
||||||
|
// rounding logic stays unit-testable.
|
||||||
|
|
||||||
|
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
|
||||||
|
function hm(totalMinutes: number): { hours: number; minutes: number } {
|
||||||
|
return {
|
||||||
|
hours: Math.floor(totalMinutes / 60),
|
||||||
|
minutes: totalMinutes % 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and
|
||||||
|
* prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than
|
||||||
|
* rounding down to "0" (which would read as "no work"). Zero → empty string
|
||||||
|
* (the caller hides the widget).
|
||||||
|
*/
|
||||||
|
export function formatHeadline(workMs: number, t: Translate): string {
|
||||||
|
if (workMs <= 0) return "";
|
||||||
|
let minutes = Math.round(workMs / MIN / 5) * 5;
|
||||||
|
if (minutes === 0) minutes = 5;
|
||||||
|
const { hours, minutes: m } = hm(minutes);
|
||||||
|
if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||||
|
if (hours > 0) return t("≈ {{hours}}h", { hours });
|
||||||
|
return t("≈ {{minutes}}m", { minutes: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */
|
||||||
|
export function formatDayTotal(activeMs: number, t: Translate): string {
|
||||||
|
if (activeMs <= 0) return "—";
|
||||||
|
const minutes = Math.max(1, Math.round(activeMs / MIN));
|
||||||
|
const { hours, minutes: m } = hm(minutes);
|
||||||
|
if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||||
|
if (hours > 0) return t("{{hours}}h", { hours });
|
||||||
|
return t("{{minutes}}m", { minutes: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inactivity threshold, for the "estimate · gap = N min" caption. */
|
||||||
|
export function formatGapMinutes(tGapMs: number): number {
|
||||||
|
return Math.round(tGapMs / MIN);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { IPageWorkTime } from "./work-time.types";
|
||||||
|
import { getPageWorkTime, viewerTimezone } from "./work-time-service";
|
||||||
|
|
||||||
|
const WORK_TIME_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #395 — the "time worked on this article" estimate + per-day punch-card
|
||||||
|
* buckets. The buckets are computed server-side in the viewer's timezone (so a
|
||||||
|
* midnight-crossing session lands on the right calendar day for the reader).
|
||||||
|
* `enabled` is opt-in so the (cheap but non-trivial) projection query only fires
|
||||||
|
* when the number is actually shown.
|
||||||
|
*/
|
||||||
|
export function usePageWorkTime(
|
||||||
|
pageId: string,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<IPageWorkTime, Error> {
|
||||||
|
const tz = viewerTimezone();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-work-time", pageId, tz],
|
||||||
|
queryFn: () => getPageWorkTime(pageId, tz),
|
||||||
|
enabled: enabled && !!pageId,
|
||||||
|
staleTime: WORK_TIME_STALE_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||||
|
import {
|
||||||
|
formatDayTotal,
|
||||||
|
formatGapMinutes,
|
||||||
|
formatHeadline,
|
||||||
|
} from "./format-work-time";
|
||||||
|
import classes from "./work-time.module.css";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||||
|
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||||
|
const EMPTY_RUN_COLLAPSE = 8;
|
||||||
|
|
||||||
|
type Row =
|
||||||
|
| { type: "day"; day: IPerDay }
|
||||||
|
| { type: "gap"; count: number };
|
||||||
|
|
||||||
|
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
let emptyRun: IPerDay[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||||
|
rows.push({ type: "gap", count: emptyRun.length });
|
||||||
|
} else {
|
||||||
|
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||||
|
}
|
||||||
|
emptyRun = [];
|
||||||
|
};
|
||||||
|
for (const d of perDay) {
|
||||||
|
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||||
|
emptyRun.push(d);
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
rows.push({ type: "day", day: d });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayHeading(day: number): string {
|
||||||
|
return new Date(day).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayTrack({
|
||||||
|
day,
|
||||||
|
pSingle,
|
||||||
|
}: {
|
||||||
|
day: IPerDay;
|
||||||
|
pSingle: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ticks = [6, 12, 18];
|
||||||
|
return (
|
||||||
|
<div className={classes.row}>
|
||||||
|
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||||
|
<div className={classes.track}>
|
||||||
|
{ticks.map((h) => (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
className={classes.hourTick}
|
||||||
|
style={{ left: `${(h / 24) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{day.windows.map((w: IDayWindow, i) => {
|
||||||
|
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||||
|
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||||
|
const isSingle = w.end - w.start <= pSingle;
|
||||||
|
const cls = [
|
||||||
|
classes.window,
|
||||||
|
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||||
|
isSingle ? classes.windowSingle : "",
|
||||||
|
].join(" ");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cls}
|
||||||
|
style={{
|
||||||
|
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||||
|
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={classes.daySum}>
|
||||||
|
{formatDayTotal(day.activeMs, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IPageWorkTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkTimePunchCard({ data }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||||
|
const gapMin = formatGapMinutes(data.config.tGap);
|
||||||
|
|
||||||
|
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" py="md">
|
||||||
|
{t("No editing activity recorded yet.")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="lg">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{formatHeadline(data.workMs, t)}
|
||||||
|
</Text>
|
||||||
|
{data.agentOnlyMs > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<span
|
||||||
|
className={`${classes.legendSwatch} ${classes.windowWork}`}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
{t("Work")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<span
|
||||||
|
className={`${classes.legendSwatch} ${classes.windowAgent}`}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
{t("Agent")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{rows.map((row, i) =>
|
||||||
|
row.type === "day" ? (
|
||||||
|
<DayTrack
|
||||||
|
key={row.day.dayISO}
|
||||||
|
day={row.day}
|
||||||
|
pSingle={data.config.pSingle}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||||
|
{t("× {{count}} days without edits", { count: row.count })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||||
|
tz: data.tz,
|
||||||
|
gap: gapMin,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { IPageWorkTime } from "./work-time.types";
|
||||||
|
|
||||||
|
/** The viewer's IANA timezone (browser locale) — the punch-card lays days out
|
||||||
|
* in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */
|
||||||
|
export function viewerTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageWorkTime(
|
||||||
|
pageId: string,
|
||||||
|
tz: string,
|
||||||
|
): Promise<IPageWorkTime> {
|
||||||
|
const req = await api.post<IPageWorkTime>("/pages/history/time", {
|
||||||
|
pageId,
|
||||||
|
tz,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconClockHour4 } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePageWorkTime } from "./use-page-work-time";
|
||||||
|
import { formatGapMinutes, formatHeadline } from "./format-work-time";
|
||||||
|
import WorkTimePunchCard from "./work-time-punch-card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #395 — the clickable "time worked on this article" headline (§6.1). Renders
|
||||||
|
* the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip
|
||||||
|
* (it is an estimate, not a stopwatch). Clicking opens the daily punch-card
|
||||||
|
* (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a
|
||||||
|
* brand-new / never-edited page shows no widget. For an agent-only-edited page
|
||||||
|
* (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled
|
||||||
|
* `agent:`, matching the punch-card) so the punch-card stays reachable (#395:
|
||||||
|
* "how much a HUMAN and separately the AGENT").
|
||||||
|
*/
|
||||||
|
export default function WorkTimeStat({ pageId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const { data } = usePageWorkTime(pageId);
|
||||||
|
|
||||||
|
if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null;
|
||||||
|
|
||||||
|
const agentOnly = data.workMs <= 0;
|
||||||
|
const label = agentOnly
|
||||||
|
? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })
|
||||||
|
: formatHeadline(data.workMs, t);
|
||||||
|
const gapMin = formatGapMinutes(data.config.tGap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
label={t("Estimated time worked (inactivity gap {{gap}} min)", {
|
||||||
|
gap: gapMin,
|
||||||
|
})}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={open}
|
||||||
|
aria-label={t("Show time worked on this page")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<IconClockHour4 size={14} />
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={t("Time worked on this article")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<WorkTimePunchCard data={data} />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||||
|
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr 64px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabel {
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||||
|
.hourTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-3),
|
||||||
|
var(--mantine-color-dark-4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windowWork {
|
||||||
|
background-color: var(--mantine-color-blue-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.windowAgent {
|
||||||
|
background-color: var(--mantine-color-grape-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||||
|
vanishes nor fakes dense work (§6.2). */
|
||||||
|
.windowSingle {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySum {
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gapRow {
|
||||||
|
padding: 6px 0 6px 108px;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSwatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// #395 — client-side mirror of the server work-time payload
|
||||||
|
// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time.
|
||||||
|
|
||||||
|
export type WorkSessionClass = "work" | "agent_only";
|
||||||
|
|
||||||
|
export interface IDayWindow {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
class: WorkSessionClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPerDay {
|
||||||
|
day: number;
|
||||||
|
dayISO: string;
|
||||||
|
activeMs: number;
|
||||||
|
agentMs: number;
|
||||||
|
windows: IDayWindow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkTimeConfig {
|
||||||
|
tGap: number;
|
||||||
|
agentTGap: number;
|
||||||
|
pIn: number;
|
||||||
|
pOut: number;
|
||||||
|
pSingle: number;
|
||||||
|
excludeGit: boolean;
|
||||||
|
burstCapMs?: number;
|
||||||
|
dedupRoundMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPageWorkTime {
|
||||||
|
workMs: number;
|
||||||
|
agentOnlyMs: number;
|
||||||
|
perDay: IPerDay[];
|
||||||
|
config: IWorkTimeConfig;
|
||||||
|
tz: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
|
IconDeviceFloppy,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
@@ -39,12 +40,18 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
|
collabProviderAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import {
|
||||||
|
SAVE_VERSION_MESSAGE_TYPE,
|
||||||
|
saveVersionPending,
|
||||||
|
} from "@/features/page-history/version-messages.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
|
import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import {
|
import {
|
||||||
useFavoriteIds,
|
useFavoriteIds,
|
||||||
@@ -72,9 +79,34 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
});
|
});
|
||||||
const isDeleted = !!page?.deletedAt;
|
const isDeleted = !!page?.deletedAt;
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const collabProvider = useAtomValue(collabProviderAtom);
|
||||||
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
||||||
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||||
|
|
||||||
|
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
|
||||||
|
// human; the server derives the tier from the signed actor. Readers can't save
|
||||||
|
// (the button is hidden and the collab connection is read-only server-side).
|
||||||
|
const handleSaveVersion = useCallback(() => {
|
||||||
|
if (readOnly || !collabProvider) return;
|
||||||
|
// Flag this client as the initiator so only it shows the confirmation toast;
|
||||||
|
// a safety timeout clears it if no broadcast comes back (e.g. offline).
|
||||||
|
saveVersionPending.current = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
saveVersionPending.current = false;
|
||||||
|
}, 5000);
|
||||||
|
collabProvider.sendStateless(
|
||||||
|
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
||||||
|
);
|
||||||
|
}, [readOnly, collabProvider]);
|
||||||
|
|
||||||
|
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
|
||||||
|
// Editable` + empty ignore-list so it fires while typing in the editor/title.
|
||||||
|
useHotkeys(
|
||||||
|
[["mod+S", handleSaveVersion, { preventDefault: true }]],
|
||||||
|
[],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -133,15 +165,16 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PageActionMenu readOnly={readOnly} />
|
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageActionMenuProps {
|
interface PageActionMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
onSaveVersion?: () => void;
|
||||||
}
|
}
|
||||||
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
@@ -233,6 +266,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{page?.id && <WorkTimeStat pageId={page.id} />}
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
shadow="xl"
|
shadow="xl"
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
@@ -303,6 +338,20 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
|
onClick={onSaveVersion}
|
||||||
|
rightSection={
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("Ctrl+S")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Save version")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconHistory size={16} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
|
|||||||
@@ -281,10 +281,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||||
if (
|
// Same "unloaded branch" predicate the realtime insert paths use
|
||||||
node?.hasChildren &&
|
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
||||||
(!node.children || node.children.length === 0)
|
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
||||||
) {
|
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
||||||
|
// does not yet route through it — see #525 follow-up.
|
||||||
|
if (treeModel.isUnloadedBranch(node)) {
|
||||||
const fetched = await fetchAllAncestorChildren({
|
const fetched = await fetchAllAncestorChildren({
|
||||||
pageId: id,
|
pageId: id,
|
||||||
spaceId: node.spaceId,
|
spaceId: node.spaceId,
|
||||||
|
|||||||
@@ -74,6 +74,48 @@ describe("treeModel.isDescendant", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
||||||
|
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
||||||
|
// present locally (canonical form `children: []`, also `undefined`). A parent
|
||||||
|
// without hasChildren is genuinely empty, not unloaded.
|
||||||
|
describe("treeModel.isUnloadedBranch", () => {
|
||||||
|
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
||||||
|
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
||||||
|
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||||
|
});
|
||||||
|
it("true for hasChildren + undefined children", () => {
|
||||||
|
const n: PH = { id: "p", name: "P", hasChildren: true };
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false for hasChildren + already-loaded children", () => {
|
||||||
|
const n: PH = {
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
hasChildren: true,
|
||||||
|
children: [{ id: "c", name: "C" }],
|
||||||
|
};
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
||||||
|
expect(
|
||||||
|
treeModel.isUnloadedBranch({
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
} as PH),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for null/undefined", () => {
|
||||||
|
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
||||||
|
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("treeModel.visible", () => {
|
describe("treeModel.visible", () => {
|
||||||
it("returns only root nodes when no openIds", () => {
|
it("returns only root nodes when no openIds", () => {
|
||||||
const v = treeModel.visible(fixture, new Set());
|
const v = treeModel.visible(fixture, new Set());
|
||||||
@@ -197,43 +239,64 @@ describe("treeModel.insertByPosition", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
type PH = TreeNode<{
|
||||||
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
name: string;
|
||||||
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
position?: string;
|
||||||
// parent's other real children. The node is left to be lazy-loaded; only
|
hasChildren?: boolean;
|
||||||
// `hasChildren` is flagged so the chevron appears.
|
}>;
|
||||||
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
|
||||||
type PH = TreeNode<{
|
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
||||||
name: string;
|
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
||||||
position?: string;
|
// hide the parent's other real children. The canonical unloaded form here is
|
||||||
hasChildren?: boolean;
|
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
||||||
}>;
|
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
||||||
|
// The node is left to be lazy-loaded; the chevron stays enabled.
|
||||||
|
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
||||||
const tree: PH[] = [
|
const tree: PH[] = [
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
||||||
];
|
];
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
const parent = treeModel.find(t, "p");
|
const parent = treeModel.find(t, "p");
|
||||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||||
// full set, including this node, on expand).
|
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
||||||
expect(parent?.children).toBeUndefined();
|
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
||||||
|
// here and reredden this expectation.
|
||||||
|
expect(parent?.children).toEqual([]);
|
||||||
expect(treeModel.find(t, "x")).toBeNull();
|
expect(treeModel.find(t, "x")).toBeNull();
|
||||||
// ...but the chevron is enabled so the user can expand to load it.
|
// ...and the chevron stays enabled so the user can expand to load it.
|
||||||
expect((parent as PH).hasChildren).toBe(true);
|
expect((parent as PH).hasChildren).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
||||||
type PH = TreeNode<{
|
const tree: PH[] = [
|
||||||
name: string;
|
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
||||||
position?: string;
|
];
|
||||||
hasChildren?: boolean;
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
}>;
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
|
const parent = treeModel.find(t, "p");
|
||||||
|
expect(parent?.children).toBeUndefined();
|
||||||
|
expect(treeModel.find(t, "x")).toBeNull();
|
||||||
|
expect((parent as PH).hasChildren).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
||||||
const tree: PH[] = [
|
const tree: PH[] = [
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||||
];
|
];
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
// A loaded (empty) child list is complete, so the node IS inserted.
|
// No server children (`hasChildren: false`), so materializing the first child
|
||||||
|
// is correct — nothing is hidden.
|
||||||
|
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
||||||
|
const tree: PH[] = [
|
||||||
|
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||||
|
];
|
||||||
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ export const treeModel = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
||||||
|
// but none are present locally. The canonical unloaded form in this codebase
|
||||||
|
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
||||||
|
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||||
|
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||||
|
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||||
|
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
||||||
|
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
||||||
|
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
||||||
|
// create-page callers do NOT yet route through this predicate — see #525
|
||||||
|
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
||||||
|
// (no server children) — inserting its first child is correct, not deferred.
|
||||||
|
isUnloadedBranch<T extends object>(
|
||||||
|
node: TreeNode<T> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!node) return false;
|
||||||
|
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
||||||
|
return hasChildren && (node.children == null || node.children.length === 0);
|
||||||
|
},
|
||||||
|
|
||||||
isDescendant<T extends object>(
|
isDescendant<T extends object>(
|
||||||
tree: TreeNode<T>[],
|
tree: TreeNode<T>[],
|
||||||
ancestorId: string,
|
ancestorId: string,
|
||||||
@@ -127,14 +147,15 @@ export const treeModel = {
|
|||||||
}
|
}
|
||||||
const parent = treeModel.find(tree, parentId);
|
const parent = treeModel.find(tree, parentId);
|
||||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||||
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
||||||
|
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
||||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||||
// defeats the lazy-load gate — which fetches only when children are
|
// defeats the lazy-load gate — which fetches only when children are
|
||||||
// absent/empty — so the parent's OTHER real children would never load and the
|
// absent/empty — so the parent's OTHER real children would never load and the
|
||||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||||
// chevron appears; expanding fetches the FULL set (including this node).
|
// chevron appears; expanding fetches the FULL set (including this node).
|
||||||
if (parent && parent.children === undefined) {
|
if (parent && treeModel.isUnloadedBranch(parent)) {
|
||||||
return treeModel.update(
|
return treeModel.update(
|
||||||
tree,
|
tree,
|
||||||
parentId,
|
parentId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import { Group, VisuallyHidden } from "@mantine/core";
|
import { Group, Text, VisuallyHidden } from "@mantine/core";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -84,6 +84,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
spaceId={spaceId}
|
spaceId={spaceId}
|
||||||
/>
|
/>
|
||||||
|
{/* #529: operator hint — matches ANY word by default; "…" for an exact
|
||||||
|
phrase, +term to require, -term to exclude. */}
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('Tip: "exact phrase", +required, -excluded')}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VisuallyHidden role="status" aria-live="polite">
|
<VisuallyHidden role="status" aria-live="polite">
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
|
|
||||||
export interface IPageSearch {
|
export interface IPageSearch {
|
||||||
id: string;
|
id: string;
|
||||||
|
// #529 A7 superset: `pageId` aliases `id`; `rank`/`highlight` are null for
|
||||||
|
// substring-only hits (the UI already falls back to the title/snippet).
|
||||||
|
pageId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
parentPageId: string;
|
parentPageId: string;
|
||||||
@@ -12,9 +15,36 @@ export interface IPageSearch {
|
|||||||
creatorId: string;
|
creatorId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
rank: string;
|
rank: string | number | null;
|
||||||
highlight: string;
|
highlight: string | null;
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
|
// New #529 fields (present from the native Postgres search driver).
|
||||||
|
snippet?: string;
|
||||||
|
score?: number;
|
||||||
|
path?: string[];
|
||||||
|
matchedFields?: string[];
|
||||||
|
matchedTerms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// #529 A5 pagination envelope returned by POST /search (native driver). The web
|
||||||
|
// list helpers read `items`; these travel alongside for pagination + diagnostics.
|
||||||
|
export interface IPageSearchResponse {
|
||||||
|
items: IPageSearch[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
truncatedAtCap: boolean;
|
||||||
|
offset: number;
|
||||||
|
query?: {
|
||||||
|
raw: string;
|
||||||
|
parsed: {
|
||||||
|
positive: string[];
|
||||||
|
required: string[];
|
||||||
|
excluded: string[];
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
mode: "or" | "and";
|
||||||
|
match: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchSuggestionParams {
|
export interface SearchSuggestionParams {
|
||||||
@@ -37,6 +67,10 @@ export interface IPageSearchParams {
|
|||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
|
// #529 A9: match mode (auto default) + pagination.
|
||||||
|
match?: "auto" | "word" | "prefix" | "substring";
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAttachmentSearch {
|
export interface IAttachmentSearch {
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ let currentAlias: IShareAlias | null = null;
|
|||||||
let availabilityResult: {
|
let availabilityResult: {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
} = { valid: true, available: true };
|
||||||
} = { valid: true, available: true, currentPageId: null };
|
|
||||||
|
|
||||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||||
@@ -56,7 +55,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMutateAsync.mockReset();
|
setMutateAsync.mockReset();
|
||||||
currentAlias = null;
|
currentAlias = null;
|
||||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
availabilityResult = { valid: true, available: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||||
@@ -65,7 +64,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
currentPageId: "page-X",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderSection("page-Y");
|
renderSection("page-Y");
|
||||||
@@ -97,7 +95,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
currentPageId: "page-X",
|
|
||||||
};
|
};
|
||||||
// The server rejects the un-confirmed save asking the client to confirm.
|
// The server rejects the un-confirmed save asking the client to confirm.
|
||||||
setMutateAsync.mockRejectedValueOnce({
|
setMutateAsync.mockRejectedValueOnce({
|
||||||
@@ -106,7 +103,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
status: 409,
|
status: 409,
|
||||||
data: {
|
data: {
|
||||||
code: "ALIAS_REASSIGN_REQUIRED",
|
code: "ALIAS_REASSIGN_REQUIRED",
|
||||||
currentPageId: "page-X",
|
|
||||||
currentPageTitle: "Alias Test Page X",
|
currentPageTitle: "Alias Test Page X",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export default function ShareAliasSection({
|
|||||||
const [availability, setAvailability] = useState<{
|
const [availability, setAvailability] = useState<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [reassign, setReassign] = useState<{
|
const [reassign, setReassign] = useState<{
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -76,7 +75,6 @@ export default function ShareAliasSection({
|
|||||||
setAvailability({
|
setAvailability({
|
||||||
valid: res.valid,
|
valid: res.valid,
|
||||||
available: res.available,
|
available: res.available,
|
||||||
currentPageId: res.currentPageId,
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export interface IShareAliasAvailability {
|
|||||||
alias: string;
|
alias: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISharedPageTree {
|
export interface ISharedPageTree {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, act, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Mocks for the dirty shell's side-effecting collaborators.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||||
|
vi.mock("@/lib/reload-guard", () => ({
|
||||||
|
hasAutoReloaded: vi.fn(() => false),
|
||||||
|
markAutoReloaded: vi.fn(() => true),
|
||||||
|
recordReloadBreadcrumb: vi.fn(),
|
||||||
|
takeReloadBreadcrumb: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
__resetGuardedReloadForTests,
|
||||||
|
} from "./guarded-reload";
|
||||||
|
|
||||||
|
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
let reload: ReturnType<typeof vi.fn>;
|
||||||
|
let visibility: DocumentVisibilityState;
|
||||||
|
|
||||||
|
// Test harness mounted inside a router: it installs the navigation hook and
|
||||||
|
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||||
|
let doNavigate: (to: string) => void;
|
||||||
|
function Harness() {
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
doNavigate = navigate;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountHarness() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/start"]}>
|
||||||
|
<Harness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
act(() => {
|
||||||
|
doNavigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockHasAutoReloaded.mockReturnValue(false);
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(true);
|
||||||
|
|
||||||
|
vi.stubGlobal("APP_VERSION", "test-A");
|
||||||
|
|
||||||
|
reload = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload },
|
||||||
|
});
|
||||||
|
|
||||||
|
visibility = "visible";
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => visibility,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("triggerGuardedReload (variant C)", () => {
|
||||||
|
it("noop when versions match: no banner, no reload", () => {
|
||||||
|
triggerGuardedReload("test-A");
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
triggerGuardedReload("");
|
||||||
|
triggerGuardedReload(undefined);
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
expect(show.mock.calls[0][0]).toMatchObject({
|
||||||
|
id: "app-version-reload",
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second navigation must NOT reload again (one-shot was consumed).
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload merely from the tab going to the background", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
visibility = "hidden";
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||||
|
visibility = "hidden";
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the banner's Update button reloads immediately", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
const message = show.mock.calls[0][0].message as {
|
||||||
|
props: { onClick: () => void };
|
||||||
|
};
|
||||||
|
message.props.onClick();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||||
|
mockHasAutoReloaded.mockReturnValue(true);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(false);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||||
|
expect(show).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||||
|
|
||||||
|
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||||
|
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||||
|
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||||
|
// separate from the pure module so the decision stays unit-testable without a
|
||||||
|
// DOM.
|
||||||
|
|
||||||
|
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||||
|
// single banner instead of stacking a new one each time.
|
||||||
|
const BANNER_ID = "app-version-reload";
|
||||||
|
|
||||||
|
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||||
|
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||||
|
// subsequent app-version emits.
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||||
|
// the background (that would silently drop a half-written comment/form). Instead
|
||||||
|
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||||
|
// the user is already leaving the current page, so an in-app navigation would
|
||||||
|
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||||
|
let pendingNavReload = false;
|
||||||
|
|
||||||
|
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||||
|
// the (already-visible) banner.
|
||||||
|
let lastServerVersion = "";
|
||||||
|
let lastClientVersion = "";
|
||||||
|
|
||||||
|
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||||
|
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||||
|
// where Vite's `define` did not run) — an unknown client version makes the
|
||||||
|
// pure decision no-op (fail-safe).
|
||||||
|
function readClientVersion(): string {
|
||||||
|
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual reload — but only after the shared one-shot flag is
|
||||||
|
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||||
|
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||||
|
// to the manual banner so the user can still recover.
|
||||||
|
function performAutoReload(): void {
|
||||||
|
if (!markAutoReloaded()) {
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Trace right before the reload (which clears the console): a persistent
|
||||||
|
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: lastServerVersion,
|
||||||
|
clientVersion: lastClientVersion,
|
||||||
|
});
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||||
|
);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReloadBanner(): void {
|
||||||
|
notifications.show({
|
||||||
|
id: BANNER_ID,
|
||||||
|
title: i18n.t("A new version is available"),
|
||||||
|
message: (
|
||||||
|
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||||
|
{i18n.t("Update")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||||
|
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||||
|
* the next in-app navigation (variant C).
|
||||||
|
*
|
||||||
|
* - real mismatch (window budget available) → banner + arm navigation reload.
|
||||||
|
* The banner's "Update" button reloads immediately (same shared window guard).
|
||||||
|
* The tab is NOT reloaded on visibility change.
|
||||||
|
* - auto-reload already used this window / storage error → banner only (no arm),
|
||||||
|
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
||||||
|
* safety).
|
||||||
|
* - in sync / unknown version → noop (fail-safe).
|
||||||
|
*/
|
||||||
|
export function triggerGuardedReload(
|
||||||
|
rawServerVersion: string | undefined | null,
|
||||||
|
): void {
|
||||||
|
const serverVersion = (rawServerVersion ?? "").trim();
|
||||||
|
const clientVersion = readClientVersion();
|
||||||
|
|
||||||
|
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||||
|
// reloading (banner only).
|
||||||
|
const autoReloadUsed = hasAutoReloaded();
|
||||||
|
|
||||||
|
const action = decideVersionAction({
|
||||||
|
serverVersion,
|
||||||
|
clientVersion,
|
||||||
|
autoReloadUsed,
|
||||||
|
});
|
||||||
|
if (action === "noop") return;
|
||||||
|
|
||||||
|
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||||
|
// emits (reconnects) once we've already acted.
|
||||||
|
if (handled) return;
|
||||||
|
handled = true;
|
||||||
|
|
||||||
|
lastServerVersion = serverVersion;
|
||||||
|
lastClientVersion = clientVersion;
|
||||||
|
|
||||||
|
if (action === "banner") {
|
||||||
|
// Entered banner-only (permanent skew, node oscillation, or the window's
|
||||||
|
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||||
|
"auto-reload budget already spent this window — showing manual banner",
|
||||||
|
);
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||||
|
// to the next in-app navigation instead of reloading now / on visibility.
|
||||||
|
showReloadBanner();
|
||||||
|
pendingNavReload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume the armed one-shot navigation reload, if any. Called by
|
||||||
|
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||||
|
*/
|
||||||
|
export function consumeNavigationReload(): void {
|
||||||
|
if (!pendingNavReload) return;
|
||||||
|
pendingNavReload = false;
|
||||||
|
performAutoReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||||
|
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||||
|
* render so it only reacts to real navigations, not the first location.
|
||||||
|
*/
|
||||||
|
export function useVersionReloadOnNavigation(): void {
|
||||||
|
const location = useLocation();
|
||||||
|
const firstRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstRender.current) {
|
||||||
|
firstRender.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumeNavigationReload();
|
||||||
|
}, [location.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||||
|
* report diagnosable. Call once on app startup.
|
||||||
|
*/
|
||||||
|
export function surfacePreviousReloadBreadcrumb(): void {
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
if (!crumb) return;
|
||||||
|
console.info(
|
||||||
|
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||||
|
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||||
|
`at=${new Date(crumb.at).toISOString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: reset module-level latches between cases.
|
||||||
|
export function __resetGuardedReloadForTests(): void {
|
||||||
|
handled = false;
|
||||||
|
pendingNavReload = false;
|
||||||
|
lastServerVersion = "";
|
||||||
|
lastClientVersion = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, act, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
||||||
|
// the reactive chunk-load boundary and the proactive version-coherence path both
|
||||||
|
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
||||||
|
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
||||||
|
// side-effecting collaborators are mocked — the reload guard is intentionally
|
||||||
|
// REAL so this exercises the actual shared sessionStorage budget.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||||
|
|
||||||
|
import { handleError } from "@/components/chunk-load-error-boundary";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
__resetGuardedReloadForTests,
|
||||||
|
} from "./guarded-reload";
|
||||||
|
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
||||||
|
|
||||||
|
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
||||||
|
const T0 = 1_000_000_000_000;
|
||||||
|
|
||||||
|
let reload: ReturnType<typeof vi.fn>;
|
||||||
|
let nowMock: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
// Harness mounted inside a router: installs the navigation hook and exposes
|
||||||
|
// `navigate` so a test can drive an in-app router navigation (the point where the
|
||||||
|
// proactive path fires its armed reload).
|
||||||
|
let doNavigate: (to: string) => void;
|
||||||
|
function Harness() {
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
doNavigate = useNavigate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function mountHarness() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/start"]}>
|
||||||
|
<Harness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
act(() => {
|
||||||
|
doNavigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setNow(t: number) {
|
||||||
|
nowMock.mockReturnValue(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
||||||
|
vi.stubGlobal("APP_VERSION", "test-A");
|
||||||
|
reload = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared window-based reload budget (invariant a)", () => {
|
||||||
|
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
||||||
|
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
||||||
|
// is spent, so the version mismatch degrades to the banner and never reloads.
|
||||||
|
setNow(T0 + 60_000);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
||||||
|
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
||||||
|
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
||||||
|
setNow(T0 + 2 * 60_000);
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
||||||
|
// First auto-reload (reactive) stamps the window.
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// A second deploy arrives after the window has fully elapsed → the proactive
|
||||||
|
// path is allowed to reload again (window, not a permanent one-shot).
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
||||||
|
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
||||||
|
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
||||||
|
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
||||||
|
// returns false and that guard MUST bail — otherwise the reactive path would
|
||||||
|
// reload on every stale-chunk error with no persisted budget (an unguarded
|
||||||
|
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
||||||
|
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
||||||
|
// fails".
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("quota exceeded");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
||||||
|
// The real guard fails toward NOT reloading when storage throws.
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
surfacePreviousReloadBreadcrumb,
|
||||||
|
} from "@/features/user/guarded-reload.tsx";
|
||||||
|
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
|
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||||
|
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
|
||||||
|
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||||
|
// (the reload cleared the console) so a field report stays diagnosable.
|
||||||
|
useEffect(() => {
|
||||||
|
surfacePreviousReloadBreadcrumb();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isError) {
|
if (isLoading || isError) {
|
||||||
return;
|
return;
|
||||||
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
handleConnect();
|
handleConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||||
|
// connects: the server emits `app-version` immediately in handleConnection,
|
||||||
|
// so a listener attached after connect would miss it on a fast localhost
|
||||||
|
// connect. On a version mismatch the client shows a banner and defers the
|
||||||
|
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||||
|
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||||
|
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||||
|
triggerGuardedReload(payload?.version);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideVersionAction } from "./version-coherence";
|
||||||
|
|
||||||
|
describe("decideVersionAction", () => {
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the client version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when versions are equal (in sync)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload on a real mismatch the first time this session", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("reload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||||
|
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||||
|
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||||
|
// `operation`), so it never touches use-query-subscription.
|
||||||
|
export type AppVersionSocketPayload = { version: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision for the version-coherence guard.
|
||||||
|
*
|
||||||
|
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||||
|
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||||
|
*
|
||||||
|
* - `autoReloadUsed` = an automatic reload has already happened within the
|
||||||
|
* current ~5-min window, so we must not auto-reload again (loop safety,
|
||||||
|
* shared window budget with the reactive chunk-load boundary).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||||
|
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||||
|
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||||
|
*/
|
||||||
|
export function decideVersionAction(args: {
|
||||||
|
serverVersion: string;
|
||||||
|
clientVersion: string;
|
||||||
|
autoReloadUsed: boolean;
|
||||||
|
}): "reload" | "banner" | "noop" {
|
||||||
|
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||||
|
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||||
|
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||||
|
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
||||||
|
return "reload"; // real mismatch, window budget available
|
||||||
|
}
|
||||||
@@ -82,17 +82,19 @@ describe("applyMoveTreeNode", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
||||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
||||||
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
||||||
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
||||||
// other real children. Now the move leaves children unloaded (so expanding
|
// The pre-#525 predicate (`children === undefined`) missed this form and
|
||||||
// fetches the FULL set, including src) and just flags hasChildren.
|
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
||||||
|
// HIDING the parent's other real children. Now the move leaves children
|
||||||
|
// unloaded (so expanding fetches the FULL set, including src).
|
||||||
const tree: SpaceTreeNode[] = [
|
const tree: SpaceTreeNode[] = [
|
||||||
node("dstCollapsed", {
|
node("dstCollapsed", {
|
||||||
position: "a0",
|
position: "a0",
|
||||||
hasChildren: false,
|
hasChildren: true,
|
||||||
children: undefined as unknown as SpaceTreeNode[],
|
children: [],
|
||||||
}),
|
}),
|
||||||
node("src", { position: "a9" }),
|
node("src", { position: "a9" }),
|
||||||
];
|
];
|
||||||
@@ -105,9 +107,10 @@ describe("applyMoveTreeNode", () => {
|
|||||||
pageData: {},
|
pageData: {},
|
||||||
});
|
});
|
||||||
const dst = treeModel.find(next, "dstCollapsed");
|
const dst = treeModel.find(next, "dstCollapsed");
|
||||||
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
||||||
// src) on expand, rather than showing a misleading partial [src] list.
|
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
||||||
expect(dst?.children).toBeUndefined();
|
// `=== undefined` predicate would insert [src] here and redden this.
|
||||||
|
expect(dst?.children).toEqual([]);
|
||||||
expect(dst?.hasChildren).toBe(true);
|
expect(dst?.hasChildren).toBe(true);
|
||||||
// src moved away from its old root slot (it lives under dstCollapsed
|
// src moved away from its old root slot (it lives under dstCollapsed
|
||||||
// server-side and reappears when the parent is expanded/loaded).
|
// server-side and reappears when the parent is expanded/loaded).
|
||||||
|
|||||||
+10
-2
@@ -28,6 +28,7 @@ import {
|
|||||||
IAiMcpServerCreate,
|
IAiMcpServerCreate,
|
||||||
IAiMcpServerUpdate,
|
IAiMcpServerUpdate,
|
||||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -121,13 +122,20 @@ export default function AiMcpServerForm({
|
|||||||
async function handleSubmit(values: FormValues) {
|
async function handleSubmit(values: FormValues) {
|
||||||
const headers = resolveHeaders();
|
const headers = resolveHeaders();
|
||||||
|
|
||||||
|
// An empty tag field means "no restriction" (sent as null) — since #476 the
|
||||||
|
// server persists a literal `[]` as deny-all (zero tools). But a server that
|
||||||
|
// was ALREADY deny-all loads into an empty field too; sending null there
|
||||||
|
// would silently widen it to allow-all on a routine edit, so preserve `[]`.
|
||||||
|
// See resolveToolAllowlist for the full rationale.
|
||||||
|
const toolAllowlist = resolveToolAllowlist(values.toolAllowlist, server);
|
||||||
|
|
||||||
if (isEdit && server) {
|
if (isEdit && server) {
|
||||||
const payload: IAiMcpServerUpdate = {
|
const payload: IAiMcpServerUpdate = {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist: values.toolAllowlist,
|
toolAllowlist,
|
||||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
@@ -140,7 +148,7 @@ export default function AiMcpServerForm({
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist: values.toolAllowlist,
|
toolAllowlist,
|
||||||
// Blank => server stores null (no guidance).
|
// Blank => server stores null (no guidance).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||||
|
|
||||||
|
describe("resolveToolAllowlist", () => {
|
||||||
|
it("sends the typed tools when the field is non-empty", () => {
|
||||||
|
expect(resolveToolAllowlist(["a", "b"], { toolAllowlist: null })).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates as null (unrestricted) when empty and there is no server", () => {
|
||||||
|
expect(resolveToolAllowlist([], undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends null for an empty field on a previously-unrestricted server", () => {
|
||||||
|
expect(resolveToolAllowlist([], { toolAllowlist: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves deny-all: an empty field on a `[]` server stays `[]`, not null", () => {
|
||||||
|
// The core #476/#477 guard: editing a deny-all server (rename/toggle) with
|
||||||
|
// an empty tag field must NOT silently widen it to allow-all.
|
||||||
|
expect(resolveToolAllowlist([], { toolAllowlist: [] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sends explicit tools even if the server was deny-all", () => {
|
||||||
|
expect(resolveToolAllowlist(["x"], { toolAllowlist: [] })).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
|
||||||
|
// Resolve the tool allowlist value to persist from the form field.
|
||||||
|
//
|
||||||
|
// An empty tag field normally means "no restriction" and is sent as null so
|
||||||
|
// the server drops the column (all tools allowed). But a server that was
|
||||||
|
// ALREADY deny-all (a stored literal `[]`, meaning zero tools — creatable via
|
||||||
|
// the API) loads into the form as an empty field too. Coercing that empty
|
||||||
|
// field to null on submit would SILENTLY widen a deny-all server to allow-all
|
||||||
|
// on any routine edit (rename, toggle) — the exact silent-widen class #476
|
||||||
|
// closed on the read side. So when the edited server was deny-all, preserve
|
||||||
|
// `[]` (deny-all); only a genuinely-unrestricted server (stored null/absent)
|
||||||
|
// stays null.
|
||||||
|
export function resolveToolAllowlist(
|
||||||
|
fieldValue: string[],
|
||||||
|
server?: Pick<IAiMcpServer, "toolAllowlist">,
|
||||||
|
): string[] | null {
|
||||||
|
if (fieldValue.length > 0) return fieldValue;
|
||||||
|
const wasDenyAll =
|
||||||
|
Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0;
|
||||||
|
return wasDenyAll ? [] : null;
|
||||||
|
}
|
||||||
+124
@@ -6,6 +6,8 @@ import {
|
|||||||
nextReindexPollInterval,
|
nextReindexPollInterval,
|
||||||
isReindexComplete,
|
isReindexComplete,
|
||||||
isReindexButtonLoading,
|
isReindexButtonLoading,
|
||||||
|
reindexRunKey,
|
||||||
|
isNewReindexRun,
|
||||||
} from './ai-provider-settings';
|
} from './ai-provider-settings';
|
||||||
|
|
||||||
describe('resolveCardStatus', () => {
|
describe('resolveCardStatus', () => {
|
||||||
@@ -221,6 +223,128 @@ describe('isReindexComplete', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reindexRunKey', () => {
|
||||||
|
it('is null when the status carries no run identity', () => {
|
||||||
|
expect(reindexRunKey(undefined)).toBeNull();
|
||||||
|
expect(
|
||||||
|
reindexRunKey({ reindexing: false, indexedPages: 5, totalPages: 5 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null for a legacy/degraded record with an empty runId', () => {
|
||||||
|
// The server sends runId='' for a record written before the field existed;
|
||||||
|
// the client must treat that as "no identity" (fall back to prior behaviour).
|
||||||
|
expect(
|
||||||
|
reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: '',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds runId and startedAt into one stable key', () => {
|
||||||
|
expect(
|
||||||
|
reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBe('run-a:1000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when the runId changes for the same startedAt', () => {
|
||||||
|
const a = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
const b = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-b',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when the same runId restarts at a new startedAt', () => {
|
||||||
|
const a = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
const b = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 2000,
|
||||||
|
});
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNewReindexRun (poll keying on runId)', () => {
|
||||||
|
// Derive the status shape from the helper itself so the test needs no export
|
||||||
|
// of the component-internal ReindexStatus type.
|
||||||
|
type ReindexStatusLike = NonNullable<Parameters<typeof reindexRunKey>[0]>;
|
||||||
|
const run = (runId: string, startedAt: number): ReindexStatusLike => ({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId,
|
||||||
|
reindexStartedAt: startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first identity after none latched is a NEW run', () => {
|
||||||
|
expect(isNewReindexRun(null, run('run-a', 1000))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the SAME identity is not a new run (same run being watched)', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(isNewReindexRun(key, run('run-a', 1000))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a DIFFERENT runId is a new run (reset per-run poll state)', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(isNewReindexRun(key, run('run-b', 1000))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an identity-less poll (no runId / cleared record) is never a new run', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(
|
||||||
|
isNewReindexRun(key, {
|
||||||
|
reindexing: false,
|
||||||
|
indexedPages: 10,
|
||||||
|
totalPages: 10,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy empty-runId poll does not spuriously reset a latched run', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(
|
||||||
|
isNewReindexRun(key, {
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 3,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: '',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isReindexButtonLoading', () => {
|
describe('isReindexButtonLoading', () => {
|
||||||
it('loads while the POST mutation is pending', () => {
|
it('loads while the POST mutation is pending', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+54
-1
@@ -173,9 +173,43 @@ export function resolveKeyField(
|
|||||||
// Subset of the status payload that drives the reindex poll decisions.
|
// Subset of the status payload that drives the reindex poll decisions.
|
||||||
type ReindexStatus = Pick<
|
type ReindexStatus = Pick<
|
||||||
IAiSettings,
|
IAiSettings,
|
||||||
"reindexing" | "indexedPages" | "totalPages"
|
"reindexing" | "indexedPages" | "totalPages" | "runId" | "reindexStartedAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable per-RUN key for the reindex poll: `runId:startedAt`, or `null` when
|
||||||
|
* the status carries no run identity (no active run, or a legacy/degraded
|
||||||
|
* server record with an empty runId). Two polls of the SAME run share a key; a
|
||||||
|
* new run mints a fresh runId and so a different key.
|
||||||
|
*
|
||||||
|
* This is the single place the client turns the server's run identity into the
|
||||||
|
* value it keys on — it removes the "is this the same run I've been watching or
|
||||||
|
* a brand-new one?" ambiguity that made a class of reindex-status bugs (a stale
|
||||||
|
* pre-reindex snapshot vs a fresh run) get fixed twice (#262). `startedAt` is
|
||||||
|
* folded in so a run that somehow reuses a runId but restarted is still new.
|
||||||
|
*/
|
||||||
|
export function reindexRunKey(status: ReindexStatus | undefined): string | null {
|
||||||
|
const runId = status?.runId;
|
||||||
|
if (!runId) return null;
|
||||||
|
return `${runId}:${status?.reindexStartedAt ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the latest poll represents a NEW reindex run relative to the
|
||||||
|
* run key the client last latched (`prevKey`, `null` if none yet). True only
|
||||||
|
* when the status carries an identity AND it differs from the latched one — the
|
||||||
|
* signal to reset any per-run poll state (the "seen active" latch / progress the
|
||||||
|
* UI held). The same identity (or no identity) is NOT a new run, so an unchanged
|
||||||
|
* or identity-less poll never resets mid-run.
|
||||||
|
*/
|
||||||
|
export function isNewReindexRun(
|
||||||
|
prevKey: string | null,
|
||||||
|
status: ReindexStatus | undefined,
|
||||||
|
): boolean {
|
||||||
|
const key = reindexRunKey(status);
|
||||||
|
return key !== null && key !== prevKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||||
@@ -320,6 +354,13 @@ export default function AiProviderSettings() {
|
|||||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||||
// trigger a render and is only ever read where `reindexing` is already false.
|
// trigger a render and is only ever read where `reindexing` is already false.
|
||||||
const reindexSeenActiveRef = useRef(false);
|
const reindexSeenActiveRef = useRef(false);
|
||||||
|
// The run identity (runId:startedAt) the current poll window is keyed on. When
|
||||||
|
// a poll reports a DIFFERENT runId the server has started a NEW run, so we
|
||||||
|
// re-latch to it and reset `reindexSeenActiveRef` — a fresh run must never
|
||||||
|
// inherit the previous run's "seen active"/completion state (which would stop
|
||||||
|
// polling immediately or read the old run's counters as this run's). null =
|
||||||
|
// no run keyed yet (steady state, or a legacy record without a runId).
|
||||||
|
const reindexRunKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||||
@@ -336,6 +377,14 @@ export default function AiProviderSettings() {
|
|||||||
// unmount because the deadline state goes away with the component.
|
// unmount because the deadline state goes away with the component.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reindexDeadline === null) return;
|
if (reindexDeadline === null) return;
|
||||||
|
// Key the poll on the run identity: if this poll carries a runId different
|
||||||
|
// from the one we latched, the server started a NEW run, so adopt it and
|
||||||
|
// drop the per-run "seen active" latch (a fresh run must not inherit the
|
||||||
|
// previous run's completion state). Same runId => same run, leave it alone.
|
||||||
|
if (isNewReindexRun(reindexRunKeyRef.current, settings)) {
|
||||||
|
reindexRunKeyRef.current = reindexRunKey(settings);
|
||||||
|
reindexSeenActiveRef.current = false;
|
||||||
|
}
|
||||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||||
// completion check below (and the refetchInterval's) only fires once the run
|
// completion check below (and the refetchInterval's) only fires once the run
|
||||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||||
@@ -1220,6 +1269,10 @@ export default function AiProviderSettings() {
|
|||||||
// immediately.
|
// immediately.
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
reindexSeenActiveRef.current = false;
|
reindexSeenActiveRef.current = false;
|
||||||
|
// Forget the previous run's identity so the first poll of
|
||||||
|
// this window (carrying the new run's runId) is recognized
|
||||||
|
// as a new run and keyed afresh.
|
||||||
|
reindexRunKeyRef.current = null;
|
||||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export interface IAiMcpServerCreate {
|
|||||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||||
// never returned.
|
// never returned.
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
toolAllowlist?: string[];
|
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
||||||
|
// deny-all (zero tools) since #476.
|
||||||
|
toolAllowlist?: string[] | null;
|
||||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -43,7 +45,9 @@ export interface IAiMcpServerUpdate {
|
|||||||
transport?: McpTransport;
|
transport?: McpTransport;
|
||||||
url?: string;
|
url?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
toolAllowlist?: string[];
|
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||||
|
// and means deny-all (zero tools) since #476.
|
||||||
|
toolAllowlist?: string[] | null;
|
||||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface IAiSettings {
|
|||||||
// True while a full workspace reindex is actively running; the counts above
|
// True while a full workspace reindex is actively running; the counts above
|
||||||
// then reflect the live run progress (done climbs 0 -> total).
|
// then reflect the live run progress (done climbs 0 -> total).
|
||||||
reindexing?: boolean;
|
reindexing?: boolean;
|
||||||
|
// Identity of the ACTIVE reindex run (present only while `reindexing`). The
|
||||||
|
// poll keys on `runId`: a changed value means a NEW run (reset the per-run
|
||||||
|
// poll state the UI latched), the same value is the run already being watched.
|
||||||
|
// Absent/empty ('') => no identity available; the client keeps prior behaviour.
|
||||||
|
runId?: string;
|
||||||
|
// Epoch-ms the active run started; paired with `runId` so a restart with a
|
||||||
|
// recycled id is still detected as a new run.
|
||||||
|
reindexStartedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
shouldAutoReload,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
RELOAD_WINDOW_MS,
|
||||||
|
} from "./reload-guard";
|
||||||
|
|
||||||
|
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
||||||
|
// reactive chunk-load boundary and the proactive version-coherence path read and
|
||||||
|
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
||||||
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
describe("reload-guard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||||
|
expect(markAutoReloaded(NOW)).toBe(true);
|
||||||
|
// Same key both paths share; stores the reload timestamp, not a flag.
|
||||||
|
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
||||||
|
// Inside the window → budget spent → true (fall through to manual UI).
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(true);
|
||||||
|
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
||||||
|
markAutoReloaded(NOW);
|
||||||
|
// Strictly older than the window → a new deploy's mismatch may reload again.
|
||||||
|
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(hasAutoReloaded()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(markAutoReloaded()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
expect(crumb).toMatchObject({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
expect(typeof crumb?.at).toBe("number");
|
||||||
|
// Cleared on read → a second take returns null.
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(() =>
|
||||||
|
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
||||||
|
// across several deploys in one tab (each > window apart) while still stopping an
|
||||||
|
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
||||||
|
// < window). Moved here from the chunk-load boundary now that it is the shared
|
||||||
|
// guard both paths route through.
|
||||||
|
describe("shouldAutoReload", () => {
|
||||||
|
const WINDOW = RELOAD_WINDOW_MS;
|
||||||
|
|
||||||
|
it("allows a reload when we have never auto-reloaded", () => {
|
||||||
|
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Shared, window-based auto-reload budget.
|
||||||
|
//
|
||||||
|
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||||
|
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||||
|
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||||
|
// they share ONE window-scoped reload budget: at most a single automatic
|
||||||
|
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
||||||
|
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
||||||
|
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
||||||
|
// chunk still degrades to a manual banner/UI after the first reload instead of
|
||||||
|
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
||||||
|
// manual UI — no unguarded reload.
|
||||||
|
|
||||||
|
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
||||||
|
// (shared by both paths).
|
||||||
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
|
|
||||||
|
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||||
|
// by a single reload, so anything inside the window is treated as a reload loop
|
||||||
|
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
||||||
|
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
||||||
|
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
||||||
|
* than the window. Anything inside the window is suppressed to break an infinite
|
||||||
|
* reload loop.
|
||||||
|
*/
|
||||||
|
export function shouldAutoReload(
|
||||||
|
now: number,
|
||||||
|
lastReloadAt: number | null,
|
||||||
|
windowMs: number,
|
||||||
|
): boolean {
|
||||||
|
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||||
|
return now - lastReloadAt > windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has an automatic reload already happened within the current window (so the
|
||||||
|
* shared budget is spent right now)? Both paths check this before reloading; a
|
||||||
|
* `true` return means fall through to the manual banner/UI instead of reloading.
|
||||||
|
*
|
||||||
|
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||||
|
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||||
|
* tab the user can reload manually. Note a window (not a permanent flag): once
|
||||||
|
* the window elapses a later deploy's mismatch is allowed to reload again.
|
||||||
|
*/
|
||||||
|
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||||
|
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||||
|
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp the shared window as consumed now — record that an automatic reload is
|
||||||
|
* being performed within the current RELOAD_WINDOW_MS window.
|
||||||
|
*
|
||||||
|
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||||
|
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
||||||
|
* the reload could loop.
|
||||||
|
*/
|
||||||
|
export function markAutoReloaded(now: number = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||||
|
// window.location.reload() (which clears the console) and read back on the next
|
||||||
|
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||||
|
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||||
|
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||||
|
// survives a same-tab reload, unlike the console.
|
||||||
|
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||||
|
|
||||||
|
export type ReloadBreadcrumb = {
|
||||||
|
path: "proactive" | "chunk-boundary";
|
||||||
|
serverVersion?: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||||
|
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||||
|
* block or alter the reload decision.
|
||||||
|
*/
|
||||||
|
export function recordReloadBreadcrumb(
|
||||||
|
entry: Omit<ReloadBreadcrumb, "at">,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
RELOAD_BREADCRUMB_KEY,
|
||||||
|
JSON.stringify({ ...entry, at: Date.now() }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort diagnostics only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load. Cleared on read so it surfaces exactly once per reload.
|
||||||
|
*/
|
||||||
|
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { templateRoute } from "./route-template";
|
import { templateRoute, KNOWN_ROUTE_TEMPLATES } from "./route-template";
|
||||||
|
|
||||||
describe("templateRoute", () => {
|
describe("templateRoute", () => {
|
||||||
it("templates a space page path (never leaks slugs)", () => {
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
@@ -32,4 +32,30 @@ describe("templateRoute", () => {
|
|||||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The server's /api/telemetry/vitals mirror (ALLOWED_ROUTE_TEMPLATES) drops any
|
||||||
|
// route outside KNOWN_ROUTE_TEMPLATES, so templateRoute must NEVER emit a label
|
||||||
|
// that is not in that dictionary — otherwise legit client metrics get dropped.
|
||||||
|
it("only ever emits labels contained in KNOWN_ROUTE_TEMPLATES (#495)", () => {
|
||||||
|
const samples = [
|
||||||
|
"/",
|
||||||
|
"/home",
|
||||||
|
"/settings/members",
|
||||||
|
"/settings/groups/g-1",
|
||||||
|
"/s/team",
|
||||||
|
"/s/team/trash",
|
||||||
|
"/s/team/p/slug",
|
||||||
|
"/p/slug",
|
||||||
|
"/share/abc",
|
||||||
|
"/share/abc/p/slug",
|
||||||
|
"/share/p/slug",
|
||||||
|
"/labels/urgent",
|
||||||
|
"/invites/inv-1",
|
||||||
|
"/weird/unknown/thing", // -> "other"
|
||||||
|
"/deep/unmatched/x/y/z", // -> "other"
|
||||||
|
];
|
||||||
|
for (const path of samples) {
|
||||||
|
expect(KNOWN_ROUTE_TEMPLATES.has(templateRoute(path))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ const STATIC_ROUTES = new Set<string>([
|
|||||||
'/settings/sharing',
|
'/settings/sharing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The COMPLETE, finite vocabulary `templateRoute` can ever emit: the two
|
||||||
|
* synthetic labels (`/` and `other`), the static routes, and the dynamic
|
||||||
|
* templates. Exported so the public `/api/telemetry/vitals` endpoint can reject
|
||||||
|
* any `route` outside this dictionary server-side (the endpoint is anonymous, so
|
||||||
|
* an un-checked `route` is a free-text write surface). The server keeps a mirror
|
||||||
|
* (`ALLOWED_ROUTE_TEMPLATES` in client-metrics.constants.ts) — this is the
|
||||||
|
* canonical source; keep them in lockstep.
|
||||||
|
*/
|
||||||
|
export const KNOWN_ROUTE_TEMPLATES: ReadonlySet<string> = new Set<string>([
|
||||||
|
'/',
|
||||||
|
'other',
|
||||||
|
...STATIC_ROUTES,
|
||||||
|
...ROUTE_PATTERNS.map((p) => p.template),
|
||||||
|
]);
|
||||||
|
|
||||||
export function templateRoute(pathname: string): string {
|
export function templateRoute(pathname: string): string {
|
||||||
// Normalise a trailing slash (except root).
|
// Normalise a trailing slash (except root).
|
||||||
const path =
|
const path =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "@mantine/spotlight/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import "@/styles/a11y-overrides.css";
|
import "@/styles/a11y-overrides.css";
|
||||||
|
import "@/styles/notification-overrides.css";
|
||||||
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
@@ -47,7 +48,15 @@ function renderApp() {
|
|||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
{/* top-center: toasts sit in the top of the viewport, in the line
|
||||||
|
of sight, and no longer cover centered content (e.g. "Load
|
||||||
|
more"). The below-chrome vertical offset is applied via a
|
||||||
|
position-scoped CSS rule in notification-overrides.css (NOT an
|
||||||
|
inline `style`): Mantine renders all six position containers at
|
||||||
|
once and an inline root style would land on every one, giving the
|
||||||
|
bottom-* containers both top+bottom → full-viewport transparent
|
||||||
|
overlays that swallow clicks. */}
|
||||||
|
<Notifications position="top-center" limit={3} zIndex={10000} />
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
404 after a deploy is caught and recovered here instead of
|
404 after a deploy is caught and recovered here instead of
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Toast (Mantine Notification) visibility overrides.
|
||||||
|
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
||||||
|
* background: white in light mode) with a faint shadow, so on white pages the
|
||||||
|
* card has no visible edge. These rules give every toast a type-tinted
|
||||||
|
* background, a WCAG-checked border and a stronger shadow so it separates from
|
||||||
|
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
||||||
|
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
||||||
|
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
||||||
|
* --notification-color is defined on the same element (defaults to primary,
|
||||||
|
* set per `color` prop), so tint/border follow the toast type. This also covers
|
||||||
|
* the loading/import toast (no accent bar, since the spinner takes the icon
|
||||||
|
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||||
|
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||||
|
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||||
|
*
|
||||||
|
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||||
|
* containers simultaneously (`position` only routes toasts into one via the
|
||||||
|
* store); the root `style` prop would be applied to every one of them by
|
||||||
|
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
||||||
|
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
||||||
|
* them stretch the full viewport height, and the container root has neither
|
||||||
|
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
||||||
|
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
||||||
|
* bottom containers at height:0.
|
||||||
|
*
|
||||||
|
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
||||||
|
* (class + attribute) and beats Mantine's own top rule
|
||||||
|
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
||||||
|
* (the :where() contributes 0), regardless of stylesheet order.
|
||||||
|
*/
|
||||||
|
.mantine-Notifications-root[data-position^='top'] {
|
||||||
|
top: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||||
|
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
||||||
|
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
||||||
|
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
||||||
|
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
||||||
|
type color stays >= 3:1. */
|
||||||
|
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
||||||
|
box-shadow: var(--mantine-shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
||||||
|
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
||||||
|
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
||||||
|
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
||||||
|
box-shadow: var(--mantine-shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
||||||
|
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
||||||
|
keep multi-line colored toasts readable, consistent with the repo's existing
|
||||||
|
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
||||||
|
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
||||||
|
color: var(--mantine-color-gray-7);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||||
|
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||||
|
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||||
|
// global are identical by construction — the single source of truth (no
|
||||||
|
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
let outDir = "dist";
|
||||||
|
return {
|
||||||
|
name: "emit-version-json",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
outDir = config.build.outDir;
|
||||||
|
},
|
||||||
|
writeBundle() {
|
||||||
|
const root = path.resolve(process.cwd(), outDir);
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "version.json"),
|
||||||
|
JSON.stringify({ version }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
const appVersion = resolveAppVersion(envPath);
|
||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(appVersion),
|
||||||
// Emit .br and .gz next to every built asset so the server can serve the
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
compression({
|
compression({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build && pnpm --filter @docmost/mcp build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@docmost/token-estimate": "workspace:*",
|
||||||
"@fastify/compress": "^9.0.0",
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
@@ -206,6 +207,7 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
|
"^@docmost/token-estimate$": "<rootDir>/../../../packages/token-estimate/src/index.ts",
|
||||||
"^src/(.*)$": "<rootDir>/$1",
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
|||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { AuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { McpModule } from './integrations/mcp/mcp.module';
|
import { McpModule } from './integrations/mcp/mcp.module';
|
||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
@@ -55,7 +55,7 @@ try {
|
|||||||
middleware: { mount: true },
|
middleware: { mount: true },
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
NoopAuditModule,
|
AuditModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -141,7 +141,57 @@ export function htmlToJson(html: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToText(tiptapJson: JSONContent) {
|
/**
|
||||||
|
* Deterministic text-serializer overrides for the `format:"text"` page read
|
||||||
|
* (#502). Non-text nodes render to a STABLE placeholder instead of their
|
||||||
|
* (structure-dependent) inner text, so a machine diff of two text reads is
|
||||||
|
* driven only by the page's actual prose — output stability across package
|
||||||
|
* versions IS the contract (pinned by a snapshot test). Returning a string from
|
||||||
|
* a `textSerializer` also stops `generateText` descending into the node, so a
|
||||||
|
* table renders as ONE token rather than its flattened cell text.
|
||||||
|
*
|
||||||
|
* Only nodes with no meaningful flat-text form are overridden; every other node
|
||||||
|
* (paragraph/heading/list/code/blockquote/callout/…) keeps its natural text so
|
||||||
|
* a config written as markdown reads back byte-identical.
|
||||||
|
*/
|
||||||
|
const TEXT_READ_SERIALIZERS: Record<string, (props: { node: any }) => string> =
|
||||||
|
{
|
||||||
|
// Image atom: no inner text -> a fixed placeholder.
|
||||||
|
image: () => '[image]',
|
||||||
|
// Table: `[table RxC]` where R = row count, C = the first row's cell count
|
||||||
|
// (a table's columns are uniform per the schema). Computed from the PM node,
|
||||||
|
// so it is independent of cell contents.
|
||||||
|
table: ({ node }) => {
|
||||||
|
const rows = node?.childCount ?? 0;
|
||||||
|
const cols = rows > 0 ? (node.child(0)?.childCount ?? 0) : 0;
|
||||||
|
return `[table ${rows}x${cols}]`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a ProseMirror/TipTap document to plain text.
|
||||||
|
*
|
||||||
|
* Default (no options): the long-standing search-index behavior — bare
|
||||||
|
* concatenated node text with `generateText`'s default `\n\n` block separator.
|
||||||
|
* This feeds the page `textContent` tsvector and MUST NOT change.
|
||||||
|
*
|
||||||
|
* `deterministic:true` (#502 `format:"text"` page read): a flat, machine-diffable
|
||||||
|
* rendering — one line per block (`\n` block separator; `hardBreak` already
|
||||||
|
* serializes to `\n`), inline marks/anchors/autoformat dropped, and non-text
|
||||||
|
* nodes replaced by the stable placeholders above (`[image]`, `[table RxC]`).
|
||||||
|
*/
|
||||||
|
export function jsonToText(
|
||||||
|
// `any` (like jsonToHtml/jsonToMarkdown) so a loosely-typed DB `page.content`
|
||||||
|
// (JsonValue) can be passed straight through, as the controller does.
|
||||||
|
tiptapJson: any,
|
||||||
|
options?: { deterministic?: boolean },
|
||||||
|
) {
|
||||||
|
if (options?.deterministic) {
|
||||||
|
return generateText(tiptapJson, tiptapExtensions, {
|
||||||
|
blockSeparator: '\n',
|
||||||
|
textSerializers: TEXT_READ_SERIALIZERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
return generateText(tiptapJson, tiptapExtensions);
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
|
||||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
|
||||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
|
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
|
||||||
// within this window collapse to a single delayed job (coalesced by a stable
|
// within this window collapse to a single delayed job (coalesced by a stable
|
||||||
// jobId), so active editing does not pile up expensive re-embeds (external API
|
// jobId), so active editing does not pile up expensive re-embeds (external API
|
||||||
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
|
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
|
||||||
// state at run time, so the last content within the window wins.
|
// state at run time, so the last content within the window wins.
|
||||||
export const EMBED_DEBOUNCE_MS = 30 * 1000;
|
export const EMBED_DEBOUNCE_MS = 30 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — page-history intentionality tiers. Domain of `page_history.kind`.
|
||||||
|
* - 'manual' / 'agent' → Tier 1 versions (intentional points)
|
||||||
|
* - 'idle' / 'boundary' → Tier 0 autosnapshots (safety net)
|
||||||
|
* A legacy `null` kind is treated as an autosave.
|
||||||
|
*/
|
||||||
|
export type PageHistoryKind = 'manual' | 'agent' | 'idle' | 'boundary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — trailing idle-flush windows. A page's pending idle snapshot is
|
||||||
|
* re-armed on every store and fires this long after edits go quiet, so a burst
|
||||||
|
* of edits collapses into a single autosnapshot instead of one-per-store. Human
|
||||||
|
* sessions are noisier and less risky, so they flush less often than the agent.
|
||||||
|
*/
|
||||||
|
export const IDLE_INTERVAL_USER = 60 * 60 * 1000; // 60m
|
||||||
|
export const IDLE_INTERVAL_AGENT = 15 * 60 * 1000; // 15m
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — max-wait ceiling for the idle flush. Pure trailing debounce starves the
|
||||||
|
* safety net: hocuspocus stores at least every ~45s, so a CONTINUOUS editing
|
||||||
|
* session would re-arm the trailing timer forever and never take an idle
|
||||||
|
* snapshot until edits finally go quiet (up to IDLE_INTERVAL_USER = 60m). This
|
||||||
|
* ceiling bounds the actual wait from the FIRST edit of a burst, so an idle
|
||||||
|
* snapshot fires at least this often during a long unbroken session — restoring
|
||||||
|
* a recovery point cadence closer to the old heuristic without one-per-store
|
||||||
|
* noise. Mirrors hocuspocus's own maxDebounce idea.
|
||||||
|
*/
|
||||||
|
export const IDLE_MAX_WAIT_USER = 10 * 60 * 1000; // 10m
|
||||||
|
export const IDLE_MAX_WAIT_AGENT = 5 * 60 * 1000; // 5m
|
||||||
|
|||||||
@@ -1,84 +1,93 @@
|
|||||||
|
import { computeHistoryJob, resolveSource } from './persistence.extension';
|
||||||
import {
|
import {
|
||||||
computeHistoryJob,
|
IDLE_INTERVAL_AGENT,
|
||||||
resolveSource,
|
IDLE_INTERVAL_USER,
|
||||||
} from './persistence.extension';
|
IDLE_MAX_WAIT_AGENT,
|
||||||
import {
|
IDLE_MAX_WAIT_USER,
|
||||||
HISTORY_FAST_INTERVAL,
|
|
||||||
HISTORY_FAST_THRESHOLD,
|
|
||||||
HISTORY_INTERVAL,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
// A fixed clock + fixed createdAt make pageAge deterministic.
|
|
||||||
const NOW = 1_700_000_000_000;
|
|
||||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
|
const page = { id: PAGE_ID };
|
||||||
const pageAged = (ageMs: number) => ({
|
|
||||||
id: PAGE_ID,
|
|
||||||
createdAt: new Date(NOW - ageMs),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('computeHistoryJob', () => {
|
describe('computeHistoryJob (#370 — shared trailing idle pipeline)', () => {
|
||||||
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
|
it('human edit → user idle window, bare page.id job', () => {
|
||||||
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
|
// Humans and the agent now share ONE idle job per page (jobId = page.id).
|
||||||
// The worker re-reads the page row at run time, so any non-zero delay risks
|
// The agent's old delay=0 fast path is GONE — intentional agent points now
|
||||||
// snapshotting content a later human edit has already overwritten. This is
|
// arrive via the explicit save-version signal, not a zero-delay snapshot.
|
||||||
// the load-bearing assertion of this spec — do not relax it.
|
const { jobId, delay } = computeHistoryJob(page, 'user');
|
||||||
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
|
expect(delay).toBe(IDLE_INTERVAL_USER);
|
||||||
expect(delay).toBe(0);
|
|
||||||
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
|
|
||||||
// Even when the page is far older than the fast threshold, the agent path
|
|
||||||
// must short-circuit to 0 — age-based debounce is a human-only concern.
|
|
||||||
const { jobId, delay } = computeHistoryJob(
|
|
||||||
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
|
|
||||||
'agent',
|
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
|
|
||||||
const { jobId, delay } = computeHistoryJob(
|
|
||||||
pageAged(HISTORY_FAST_THRESHOLD - 1),
|
|
||||||
'user',
|
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('human edit on an OLD page (age > threshold) → standard interval', () => {
|
it('agent edit → agent idle window (shorter), still the bare page.id job', () => {
|
||||||
const { jobId, delay } = computeHistoryJob(
|
const { jobId, delay } = computeHistoryJob(page, 'agent');
|
||||||
pageAged(HISTORY_FAST_THRESHOLD + 1),
|
expect(delay).toBe(IDLE_INTERVAL_AGENT);
|
||||||
'user',
|
// No `-agent` suffix anymore: the agent joins the common idle pipeline.
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(HISTORY_INTERVAL);
|
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
|
it('agent flushes sooner than a human', () => {
|
||||||
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
|
expect(IDLE_INTERVAL_AGENT).toBeLessThan(IDLE_INTERVAL_USER);
|
||||||
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
pageAged(HISTORY_FAST_THRESHOLD),
|
|
||||||
'user',
|
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(HISTORY_INTERVAL);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats any non-"agent" source string as human', () => {
|
it('treats any non-"agent" source string as human (keys strictly on === agent)', () => {
|
||||||
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
|
const { jobId, delay } = computeHistoryJob(page, 'user');
|
||||||
// the agent branch keys strictly on === 'agent'.
|
expect(delay).toBe(IDLE_INTERVAL_USER);
|
||||||
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
|
|
||||||
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #370 review round-1 WARNING: the max-wait ceiling prevents autosnapshot
|
||||||
|
// starvation during a continuous editing session (the trailing timer would
|
||||||
|
// otherwise re-arm forever and never fire).
|
||||||
|
describe('max-wait ceiling', () => {
|
||||||
|
const T0 = 1_000_000; // arbitrary fixed epoch for deterministic tests
|
||||||
|
|
||||||
|
it('once a burst is armed, delay clamps to the remaining max-wait budget', () => {
|
||||||
|
// 1 minute into the burst the USER interval (60m) far exceeds the remaining
|
||||||
|
// max-wait budget (10m - 1m = 9m), so the delay is clamped DOWN to that
|
||||||
|
// remaining budget — the full interval is NOT used once a ceiling applies.
|
||||||
|
const { delay } = computeHistoryJob(page, 'user', T0, T0 + 60_000);
|
||||||
|
expect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never waits longer than the max-wait budget from the burst start', () => {
|
||||||
|
// A store arriving right at the ceiling → delay 0 (fire promptly).
|
||||||
|
const { delay } = computeHistoryJob(
|
||||||
|
page,
|
||||||
|
'user',
|
||||||
|
T0,
|
||||||
|
T0 + IDLE_MAX_WAIT_USER,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('past the ceiling never returns a negative delay', () => {
|
||||||
|
const { delay } = computeHistoryJob(
|
||||||
|
page,
|
||||||
|
'user',
|
||||||
|
T0,
|
||||||
|
T0 + IDLE_MAX_WAIT_USER + 5 * 60_000,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the agent ceiling is shorter than the user ceiling', () => {
|
||||||
|
expect(IDLE_MAX_WAIT_AGENT).toBeLessThan(IDLE_MAX_WAIT_USER);
|
||||||
|
const { delay } = computeHistoryJob(
|
||||||
|
page,
|
||||||
|
'agent',
|
||||||
|
T0,
|
||||||
|
T0 + IDLE_MAX_WAIT_AGENT,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a burstStart there is no ceiling (backward-compatible)', () => {
|
||||||
|
expect(computeHistoryJob(page, 'user').delay).toBe(IDLE_INTERVAL_USER);
|
||||||
|
expect(computeHistoryJob(page, 'agent').delay).toBe(IDLE_INTERVAL_AGENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveSource (truth table)', () => {
|
describe('resolveSource (truth table)', () => {
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
let pageHistoryRepo: {
|
let pageHistoryRepo: {
|
||||||
saveHistory: jest.Mock;
|
saveHistory: jest.Mock;
|
||||||
findPageLastHistory: jest.Mock;
|
findPageLastHistory: jest.Mock;
|
||||||
|
updateHistoryKind: jest.Mock;
|
||||||
};
|
};
|
||||||
let aiQueue: { add: jest.Mock };
|
let aiQueue: { add: jest.Mock };
|
||||||
let historyQueue: { add: jest.Mock };
|
let historyQueue: { add: jest.Mock; remove: jest.Mock };
|
||||||
let notificationQueue: { add: jest.Mock };
|
let notificationQueue: { add: jest.Mock };
|
||||||
let collabHistory: { addContributors: jest.Mock };
|
let collabHistory: { addContributors: jest.Mock; popContributors: jest.Mock };
|
||||||
let transclusionService: {
|
let transclusionService: {
|
||||||
syncPageTransclusions: jest.Mock;
|
syncPageTransclusions: jest.Mock;
|
||||||
syncPageReferences: jest.Mock;
|
syncPageReferences: jest.Mock;
|
||||||
@@ -93,13 +94,22 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
pageHistoryRepo = {
|
pageHistoryRepo = {
|
||||||
saveHistory: jest.fn().mockImplementation(async () => {
|
saveHistory: jest.fn().mockImplementation(async () => {
|
||||||
callOrder.push('saveHistory');
|
callOrder.push('saveHistory');
|
||||||
|
return { id: 'history-1' };
|
||||||
}),
|
}),
|
||||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||||
|
updateHistoryKind: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
historyQueue = {
|
||||||
|
add: jest.fn().mockResolvedValue(undefined),
|
||||||
|
// #370 — enqueuePageHistory now removes any pending idle job before re-adding.
|
||||||
|
remove: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
collabHistory = {
|
||||||
|
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||||
|
popContributors: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
transclusionService = {
|
transclusionService = {
|
||||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -165,6 +175,50 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #370 review round-1 SUGGESTION: the boundary was GENERALIZED from a
|
||||||
|
// user→agent special-case to ANY lastUpdatedSource transition. These pin the
|
||||||
|
// generalized behaviour it was rebuilt for.
|
||||||
|
describe('generalized boundary — any source transition', () => {
|
||||||
|
// Same persisted page but with an explicit prior source.
|
||||||
|
const pageWithPriorSource = (prior: string | null) => ({
|
||||||
|
...persistedHumanPage('NEW CONTENT'),
|
||||||
|
lastUpdatedSource: prior,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent→user transition fires the boundary (pins the prior agent revision)', async () => {
|
||||||
|
const document = ydocFor(doc('NEW CONTENT'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageWithPriorSource('agent'));
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
||||||
|
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git→user transition fires the boundary (git-sync overwrite is a source change)', async () => {
|
||||||
|
const document = ydocFor(doc('NEW CONTENT'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageWithPriorSource('git'));
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a null prior source (first-ever edit) does NOT fire the boundary', async () => {
|
||||||
|
const document = ydocFor(doc('NEW CONTENT'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageWithPriorSource(null));
|
||||||
|
|
||||||
|
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
||||||
// The Y.Doc content equals the persisted content deeply → early skip.
|
// The Y.Doc content equals the persisted content deeply → early skip.
|
||||||
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
||||||
@@ -479,4 +533,231 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
||||||
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #370 — explicit save-version (Cmd+S / agent save tool) over the stateless
|
||||||
|
// seam. The tier is derived from the SIGNED connection actor, the store path
|
||||||
|
// is reused, and promote-not-dup avoids duplicating heavy content rows.
|
||||||
|
describe('save-version (#370)', () => {
|
||||||
|
const emitSave = (document: any, actor: 'user' | 'agent') =>
|
||||||
|
ext.onStateless({
|
||||||
|
connection: {
|
||||||
|
readOnly: false,
|
||||||
|
context: { user: { id: USER_ID, name: 'Alice' }, actor },
|
||||||
|
} as any,
|
||||||
|
documentName: `page.${PAGE_ID}`,
|
||||||
|
document: document as any,
|
||||||
|
payload: JSON.stringify({ type: 'save-version' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// findById returns a page whose content already equals the live doc, so the
|
||||||
|
// store path is a no-op and we isolate the versioning decision.
|
||||||
|
const pageMatchingDoc = (document: any) => ({
|
||||||
|
...persistedHumanPage('IGNORED'),
|
||||||
|
content: TiptapTransformer.fromYdoc(document, 'default'),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human save with no prior snapshot → writes a manual version + broadcasts', async () => {
|
||||||
|
const document = ydocFor(doc('VERSION ME'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await emitSave(document, 'user');
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pageHistoryRepo.saveHistory.mock.calls[0][1]).toEqual(
|
||||||
|
expect.objectContaining({ kind: 'manual' }),
|
||||||
|
);
|
||||||
|
// The pending idle autosnapshot is cancelled by the explicit version.
|
||||||
|
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
||||||
|
const msg = JSON.parse(
|
||||||
|
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
||||||
|
);
|
||||||
|
expect(msg).toMatchObject({
|
||||||
|
type: 'version.saved',
|
||||||
|
kind: 'manual',
|
||||||
|
alreadySaved: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent save derives kind=agent from the signed actor', async () => {
|
||||||
|
const document = ydocFor(doc('AGENT VERSION'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await emitSave(document, 'agent');
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory.mock.calls[pageHistoryRepo.saveHistory.mock.calls.length - 1][1]).toEqual(
|
||||||
|
expect.objectContaining({ kind: 'agent' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promote-not-dup: latest snapshot is an autosave with identical content → upgrades in place', async () => {
|
||||||
|
const document = ydocFor(doc('SAME'));
|
||||||
|
const page = pageMatchingDoc(document);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
|
id: 'auto-1',
|
||||||
|
content: page.content,
|
||||||
|
kind: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitSave(document, 'user');
|
||||||
|
|
||||||
|
// No heavy new content row — the existing autosave is promoted to manual.
|
||||||
|
expect(pageHistoryRepo.updateHistoryKind).toHaveBeenCalledWith(
|
||||||
|
'auto-1',
|
||||||
|
'manual',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
const msg = JSON.parse(
|
||||||
|
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
||||||
|
);
|
||||||
|
expect(msg).toMatchObject({ historyId: 'auto-1', alreadySaved: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-op when the latest snapshot is already a manual version of this content', async () => {
|
||||||
|
const document = ydocFor(doc('ALREADY SAVED'));
|
||||||
|
const page = pageMatchingDoc(document);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
|
id: 'ver-1',
|
||||||
|
content: page.content,
|
||||||
|
kind: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitSave(document, 'user');
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
const msg = JSON.parse(
|
||||||
|
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
||||||
|
);
|
||||||
|
expect(msg).toMatchObject({ alreadySaved: true, kind: 'manual' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a read-only connection cannot save a version', async () => {
|
||||||
|
const document = ydocFor(doc('READER'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
||||||
|
|
||||||
|
await ext.onStateless({
|
||||||
|
connection: {
|
||||||
|
readOnly: true,
|
||||||
|
context: { user: { id: USER_ID }, actor: 'user' },
|
||||||
|
} as any,
|
||||||
|
documentName: `page.${PAGE_ID}`,
|
||||||
|
document: document as any,
|
||||||
|
payload: JSON.stringify({ type: 'save-version' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// #370 F8-twin — a COMMIT abort (serialization/deadlock/conn-drop) rejects
|
||||||
|
// OUTSIDE the tx callback, AFTER the destructive popContributors (SPOP) and
|
||||||
|
// saveHistory ran but the INSERT rolled back. onStateless has no retry, so
|
||||||
|
// the outer catch MUST re-add (SADD) the popped set or attribution is lost
|
||||||
|
// irrecoverably. MUTATION: drop the outer catch → addContributors is never
|
||||||
|
// called → this reddens.
|
||||||
|
it('restores popped contributors when the commit aborts after the callback', async () => {
|
||||||
|
const document = ydocFor(doc('VERSION ME'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
||||||
|
// No matching snapshot → fresh version branch → pops contributors.
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
collabHistory.popContributors.mockResolvedValue(['u1', 'u2']);
|
||||||
|
|
||||||
|
// A db whose commit REJECTS after the callback body resolved: the SPOP and
|
||||||
|
// saveHistory already ran, then the tx aborts. onStoreDocument's flush uses
|
||||||
|
// the same db but its content matches (no-op branch) and its own retry loop
|
||||||
|
// swallows the throw, so only the versioning tx exercises the restore.
|
||||||
|
const commitFailingDb = {
|
||||||
|
transaction: () => ({
|
||||||
|
execute: async (fn: (trx: any) => Promise<any>) => {
|
||||||
|
await fn(trxStub);
|
||||||
|
throw new Error('commit aborted (serialization_failure)');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const ext2 = new PersistenceExtension(
|
||||||
|
pageRepo as any,
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
commitFailingDb as any,
|
||||||
|
aiQueue as any,
|
||||||
|
historyQueue as any,
|
||||||
|
notificationQueue as any,
|
||||||
|
collabHistory as any,
|
||||||
|
transclusionService as any,
|
||||||
|
);
|
||||||
|
jest.spyOn(ext2['logger'], 'debug').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ext2['logger'], 'warn').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ext2['logger'], 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ext2.onStateless({
|
||||||
|
connection: {
|
||||||
|
readOnly: false,
|
||||||
|
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
|
||||||
|
} as any,
|
||||||
|
documentName: `page.${PAGE_ID}`,
|
||||||
|
document: document as any,
|
||||||
|
payload: JSON.stringify({ type: 'save-version' }),
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// Attribution preserved: the popped set is SADD-restored, keyed by the page
|
||||||
|
// UUID it was popped under.
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
||||||
|
'u1',
|
||||||
|
'u2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #370 #260 — for a `page.<slugId>` document the idle job is armed under the
|
||||||
|
// page UUID (computeHistoryJob's jobId = page.id), so the supersede-remove
|
||||||
|
// must target page.id, not the raw slugId doc-name id, or it silently misses.
|
||||||
|
it('cancels the superseded idle job by the page UUID for a slugId doc', async () => {
|
||||||
|
const SLUG = 'slug-1'; // persistedHumanPage.slugId
|
||||||
|
const document = ydocFor(doc('VERSION ME'));
|
||||||
|
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await ext.onStateless({
|
||||||
|
connection: {
|
||||||
|
readOnly: false,
|
||||||
|
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'user' },
|
||||||
|
} as any,
|
||||||
|
documentName: `page.${SLUG}`,
|
||||||
|
document: document as any,
|
||||||
|
payload: JSON.stringify({ type: 'save-version' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// remove() keyed by the UUID (the real jobId), never the slugId.
|
||||||
|
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
||||||
|
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
||||||
|
// its sibling per-document maps) or it grows unbounded for every page that was
|
||||||
|
// edited but never manually saved. MUTATION: drop the afterUnloadDocument
|
||||||
|
// delete → the entry survives → this reddens.
|
||||||
|
describe('idleBurstStart housekeeping', () => {
|
||||||
|
it('afterUnloadDocument clears the idle-burst marker armed by a store', async () => {
|
||||||
|
const document = ydocFor(doc('EDIT'));
|
||||||
|
pageRepo.findById.mockResolvedValue(persistedHumanPage('EDIT'));
|
||||||
|
|
||||||
|
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||||
|
|
||||||
|
const map = ext['idleBurstStart'] as Map<string, number>;
|
||||||
|
// Keyed by documentName (buildData uses `page.${PAGE_ID}`).
|
||||||
|
expect(map.has(`page.${PAGE_ID}`)).toBe(true);
|
||||||
|
|
||||||
|
await ext.afterUnloadDocument({
|
||||||
|
documentName: `page.${PAGE_ID}`,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(map.has(`page.${PAGE_ID}`)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ import { Page } from '@docmost/db/types/entity.types';
|
|||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import {
|
import {
|
||||||
EMBED_DEBOUNCE_MS,
|
EMBED_DEBOUNCE_MS,
|
||||||
HISTORY_FAST_INTERVAL,
|
IDLE_INTERVAL_AGENT,
|
||||||
HISTORY_FAST_THRESHOLD,
|
IDLE_INTERVAL_USER,
|
||||||
HISTORY_INTERVAL,
|
IDLE_MAX_WAIT_AGENT,
|
||||||
|
IDLE_MAX_WAIT_USER,
|
||||||
|
PageHistoryKind,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +58,16 @@ import { hasTransclusionFamilyNodes } from '../../core/page/transclusion/utils/t
|
|||||||
*/
|
*/
|
||||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — wire format of the client→server "save a version" signal. Sent by the
|
||||||
|
* human (Cmd+S / Save button) and by the agent's explicit save tool over the
|
||||||
|
* SAME stateless channel. The intentionality tier ('manual' vs 'agent') is
|
||||||
|
* derived SERVER-SIDE from the signed connection actor, never from this
|
||||||
|
* payload, so a version's type is unforgeable. The document is taken from the
|
||||||
|
* connection (not the payload), so the signal cannot be aimed at another page.
|
||||||
|
*/
|
||||||
|
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||||
@@ -92,35 +104,39 @@ export function resolveSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
|
* #370 — compute the BullMQ job id + delay for a page's trailing idle-flush
|
||||||
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
|
* autosnapshot. Pure so the timing is unit-testable.
|
||||||
* (caller passes `Date.now()`) for determinism.
|
|
||||||
*
|
*
|
||||||
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
|
* Both humans and the agent now share ONE idle pipeline (the agent's old
|
||||||
* delay MUST stay 0 — the worker re-reads the page row at run time, so any
|
* `delay=0` fast path is gone — intentional agent points arrive via the
|
||||||
* delay risks reading content a later human edit has already overwritten
|
* explicit save-version signal instead). The job id is the bare `page.id`, so a
|
||||||
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
|
* page has at most one pending idle job; the caller removes-and-re-adds it on
|
||||||
* the job from coalescing with the bare-page.id human job.
|
* every store to keep it debounced to the trailing edge of an edit burst. The
|
||||||
* - Human edits: age-based debounce so rapid human edits coalesce into one
|
* window differs by source only: the agent flushes sooner than a human.
|
||||||
* snapshot; job id is the bare `page.id`.
|
|
||||||
*
|
|
||||||
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
|
|
||||||
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
|
|
||||||
*/
|
*/
|
||||||
export function computeHistoryJob(
|
export function computeHistoryJob(
|
||||||
page: Pick<Page, 'id' | 'createdAt'>,
|
page: Pick<Page, 'id'>,
|
||||||
source: string,
|
source: string,
|
||||||
now: number,
|
// Epoch ms of the FIRST edit in the current burst (when the pending idle job
|
||||||
|
// was first armed). Used to enforce the max-wait ceiling so a continuous
|
||||||
|
// editing session cannot re-arm the trailing timer forever. `now` is injectable
|
||||||
|
// for tests; both default to a live clock / no ceiling when omitted.
|
||||||
|
burstStart?: number,
|
||||||
|
now: number = Date.now(),
|
||||||
): { jobId: string; delay: number } {
|
): { jobId: string; delay: number } {
|
||||||
const isAgent = source === 'agent';
|
const isAgent = source === 'agent';
|
||||||
const pageAge = now - new Date(page.createdAt).getTime();
|
const interval = isAgent ? IDLE_INTERVAL_AGENT : IDLE_INTERVAL_USER;
|
||||||
const delay = isAgent
|
const maxWait = isAgent ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
||||||
? 0
|
|
||||||
: pageAge < HISTORY_FAST_THRESHOLD
|
let delay = interval;
|
||||||
? HISTORY_FAST_INTERVAL
|
if (burstStart !== undefined) {
|
||||||
: HISTORY_INTERVAL;
|
// Time already elapsed since the burst's first edit; the snapshot must fire
|
||||||
const jobId = isAgent ? `${page.id}-agent` : page.id;
|
// no later than `maxWait` after that, so shrink the trailing delay to the
|
||||||
return { jobId, delay };
|
// remaining budget (never negative, so BullMQ fires it promptly).
|
||||||
|
const remaining = burstStart + maxWait - now;
|
||||||
|
delay = Math.max(0, Math.min(interval, remaining));
|
||||||
|
}
|
||||||
|
return { jobId: page.id, delay };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -132,6 +148,28 @@ export class PersistenceExtension implements Extension {
|
|||||||
// coalescing window" per document and OR it across all edits in the window,
|
// coalescing window" per document and OR it across all edits in the window,
|
||||||
// so the snapshot is marked 'agent' regardless of who wrote last.
|
// so the snapshot is marked 'agent' regardless of who wrote last.
|
||||||
private agentTouched: Map<string, boolean> = new Map();
|
private agentTouched: Map<string, boolean> = new Map();
|
||||||
|
// #370 — epoch ms of the FIRST edit in the current idle-flush burst. Keyed by
|
||||||
|
// documentName (like its sibling per-document maps above), NOT by page.id, so
|
||||||
|
// it can be cleaned in afterUnloadDocument alongside `contributors` /
|
||||||
|
// `agentTouched` / `intentionalClear` when the doc unloads — otherwise any page
|
||||||
|
// that was edited but never manually saved (the common case) would keep its
|
||||||
|
// entry forever and the Map would grow unbounded in this long-lived process.
|
||||||
|
// Set when the pending idle job is first armed (empty entry), read to enforce
|
||||||
|
// the max-wait ceiling in computeHistoryJob, and cleared on doc unload or when
|
||||||
|
// a manual save cancels the idle job so the next burst starts a fresh window.
|
||||||
|
//
|
||||||
|
// Single-process assumption (like `contributors` / `agentTouched` above): this
|
||||||
|
// lives only in THIS collab process's memory. A restart, or a page's ownership
|
||||||
|
// moving to another node, loses the burst-start marker. Consequence: a burst
|
||||||
|
// that spans the restart looks like a fresh burst to the surviving process, so
|
||||||
|
// its max-wait ceiling is re-anchored to the first post-restart edit — a single
|
||||||
|
// continuous session straddling a restart can therefore wait up to ~2× the cap
|
||||||
|
// for its idle snapshot (once for the lost pre-restart window, once for the new
|
||||||
|
// one). Bounded and benign (it only DELAYS a safety-net autosnapshot; manual
|
||||||
|
// saves are unaffected and the next quiet period always flushes), but the
|
||||||
|
// assumption and its consequence are recorded here so no one mistakes the
|
||||||
|
// in-memory marker for a durable, cross-process guarantee.
|
||||||
|
private idleBurstStart: Map<string, number> = new Map();
|
||||||
// #251 — per-document "intentional clear pending" flags. Keyed by
|
// #251 — per-document "intentional clear pending" flags. Keyed by
|
||||||
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
||||||
// client reports a deliberate clear; consumed once by the next
|
// client reports a deliberate clear; consumed once by the next
|
||||||
@@ -363,20 +401,19 @@ export class PersistenceExtension implements Extension {
|
|||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approach A — boundary snapshot before the agent's first edit.
|
// #370 — boundary snapshot on ANY source transition. When the store
|
||||||
// When this store is the agent's and the page's currently persisted
|
// flips the page's provenance (user↔agent↔git), pin the OUTGOING
|
||||||
// state was authored by a human, pin that human state as its own
|
// state as its own history version BEFORE the incoming source
|
||||||
// history version BEFORE the agent overwrites it. `page` still holds
|
// overwrites it. `page` still holds the OLD content/provenance here,
|
||||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
// so saveHistory(page) captures the pre-transition state tagged with
|
||||||
// pre-agent state tagged 'user'. The agent's new content is
|
// its own source, kind='boundary'. The incoming content is snapshotted
|
||||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
// later by the debounced idle job. Skip if the page is effectively
|
||||||
// if the prior state is already agent-authored (boundary already
|
// empty or if the latest existing snapshot already equals this state
|
||||||
// pinned on the user->agent transition), if the page is effectively
|
// (the shared isDeepStrictEqual gate — avoids duplicates). Generalizing
|
||||||
// empty, or if the latest existing snapshot already equals this human
|
// beyond the old user→agent special-case also covers git-sync for free.
|
||||||
// state (avoid duplicates).
|
|
||||||
if (
|
if (
|
||||||
lastUpdatedSource === 'agent' &&
|
page.lastUpdatedSource &&
|
||||||
page.lastUpdatedSource !== 'agent'
|
page.lastUpdatedSource !== lastUpdatedSource
|
||||||
) {
|
) {
|
||||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||||
@@ -384,15 +421,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
page.id,
|
page.id,
|
||||||
{ includeContent: true, trx },
|
{ includeContent: true, trx },
|
||||||
);
|
);
|
||||||
const humanBaselineMissing =
|
const baselineMissing =
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||||
if (
|
if (!isEmptyParagraphDoc(page.content as any) && baselineMissing) {
|
||||||
!isEmptyParagraphDoc(page.content as any) &&
|
|
||||||
humanBaselineMissing
|
|
||||||
) {
|
|
||||||
await this.pageHistoryRepo.saveHistory(page, {
|
await this.pageHistoryRepo.saveHistory(page, {
|
||||||
contributorIds: page.contributorIds ?? undefined,
|
contributorIds: page.contributorIds ?? undefined,
|
||||||
|
kind: 'boundary',
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -522,7 +557,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
|
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.enqueuePageHistory(page, lastUpdatedSource);
|
await this.enqueuePageHistory(page, documentName, lastUpdatedSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #402 — report the serialized size for the store histogram's size_bucket.
|
// #402 — report the serialized size for the store histogram's size_bucket.
|
||||||
@@ -554,6 +589,14 @@ export class PersistenceExtension implements Extension {
|
|||||||
return; // unrelated / malformed stateless message
|
return; // unrelated / malformed stateless message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #370 — explicit "save a version" (human Cmd+S / agent save tool). Edit
|
||||||
|
// rights are already enforced by the readOnly reject above (a reader can't
|
||||||
|
// create a version), exactly as intentional-clear requires.
|
||||||
|
if (message?.type === SAVE_VERSION_MESSAGE_TYPE) {
|
||||||
|
await this.handleSaveVersion(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
||||||
|
|
||||||
this.intentionalClear.set(
|
this.intentionalClear.set(
|
||||||
@@ -562,6 +605,160 @@ export class PersistenceExtension implements Extension {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 — persist an intentional version from the live in-memory ydoc.
|
||||||
|
*
|
||||||
|
* One stateless path serves BOTH the human and the agent; the tier is derived
|
||||||
|
* SERVER-SIDE from the signed connection actor ('agent' → 'agent', anything
|
||||||
|
* else → 'manual'), so the version type cannot be spoofed by the client. We
|
||||||
|
* take the fresh ydoc from the collab process memory and run it through the
|
||||||
|
* EXISTING store path first (so pages.content/ydoc reflect the exact content
|
||||||
|
* being versioned — a REST endpoint would race the up-to-10s-stale page row),
|
||||||
|
* then snapshot it into page_history with the intentional kind.
|
||||||
|
*
|
||||||
|
* Promote-not-dup: if the latest history row already holds this exact content
|
||||||
|
* and it is an autosave (idle/boundary/legacy-null), upgrade its kind in place
|
||||||
|
* instead of duplicating a heavy content row; if it is already 'manual', it is
|
||||||
|
* a no-op (the client shows an "already saved" toast). Otherwise a fresh
|
||||||
|
* version row is written, popping the aggregated contributors from Redis.
|
||||||
|
*/
|
||||||
|
private async handleSaveVersion(data: onStatelessPayload): Promise<void> {
|
||||||
|
const { connection, document, documentName } = data;
|
||||||
|
const context = connection?.context;
|
||||||
|
const pageId = getPageId(documentName);
|
||||||
|
// Unforgeable: 'agent' only for a signed agent connection, else 'manual'.
|
||||||
|
const kind: PageHistoryKind =
|
||||||
|
context?.actor === 'agent' ? 'agent' : 'manual';
|
||||||
|
|
||||||
|
// Flush the live ydoc through the normal store path so the page row + ydoc
|
||||||
|
// hold exactly what we are about to version (also fires the idle enqueue we
|
||||||
|
// supersede below, plus any source-transition boundary). onStoreDocument
|
||||||
|
// only needs document/documentName/context.
|
||||||
|
await this.onStoreDocument({
|
||||||
|
document,
|
||||||
|
documentName,
|
||||||
|
context,
|
||||||
|
} as onStoreDocumentPayload);
|
||||||
|
|
||||||
|
let result:
|
||||||
|
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
||||||
|
// must be restored if the version row does not durably land. The inner
|
||||||
|
// try/catch below only covers a throw INSIDE the callback; but executeTx
|
||||||
|
// COMMITS after the callback, so a commit-abort (serialization/deadlock/
|
||||||
|
// connection drop — the transient class the epic retries in the processor)
|
||||||
|
// rejects OUTSIDE the callback, after saveHistory already ran and the SPOP
|
||||||
|
// already happened, while the INSERT rolls back. onStateless does NOT retry,
|
||||||
|
// so an unrestored pop is a one-shot irrecoverable attribution loss (the
|
||||||
|
// processor got exactly this fix: poppedForRestore + an outer catch). We
|
||||||
|
// track the popped set here (keyed by the page UUID it was popped by — never
|
||||||
|
// the doc-name id, which may be a slugId, #260) and restore it in the outer
|
||||||
|
// catch. addContributors is an idempotent Redis SADD, so a double-restore is
|
||||||
|
// harmless. versionedPageId is also reused below to remove the superseded
|
||||||
|
// idle job by its real jobId (page.id).
|
||||||
|
let poppedForRestore: string[] = [];
|
||||||
|
let versionedPageId: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
|
withLock: true,
|
||||||
|
includeContent: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (!page) return;
|
||||||
|
versionedPageId = page.id;
|
||||||
|
// Never version an effectively-empty page (mirrors the processor's
|
||||||
|
// first-history guard); there is nothing intentional to pin.
|
||||||
|
if (isEmptyParagraphDoc(page.content as any)) return;
|
||||||
|
|
||||||
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
|
page.id,
|
||||||
|
{ includeContent: true, trx },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastHistory &&
|
||||||
|
isDeepStrictEqual(lastHistory.content, page.content)
|
||||||
|
) {
|
||||||
|
// Content is already snapshotted. Promote-not-dup.
|
||||||
|
if (lastHistory.kind === 'manual') {
|
||||||
|
result = {
|
||||||
|
historyId: lastHistory.id,
|
||||||
|
kind: 'manual',
|
||||||
|
alreadySaved: true,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.pageHistoryRepo.updateHistoryKind(
|
||||||
|
lastHistory.id,
|
||||||
|
kind,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
result = { historyId: lastHistory.id, kind, alreadySaved: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh version row. Pop the contributors aggregated since the last
|
||||||
|
// snapshot (SPOP); restore them if the write fails so they aren't lost.
|
||||||
|
const contributorIds = await this.collabHistory.popContributors(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
poppedForRestore = contributorIds;
|
||||||
|
try {
|
||||||
|
const saved = await this.pageHistoryRepo.saveHistory(page, {
|
||||||
|
contributorIds,
|
||||||
|
kind,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
result = { historyId: saved.id, kind, alreadySaved: false };
|
||||||
|
} catch (err) {
|
||||||
|
await this.collabHistory.addContributors(page.id, contributorIds);
|
||||||
|
poppedForRestore = [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// A throw here means the tx did NOT commit (callback threw, or the commit
|
||||||
|
// itself failed and rolled back). If we popped contributors and the inner
|
||||||
|
// catch did not already restore them, restore now so attribution is not
|
||||||
|
// lost — onStateless has no retry to recover it. Restore by the page UUID
|
||||||
|
// the pop was keyed under (versionedPageId is always set before the pop).
|
||||||
|
if (poppedForRestore.length && versionedPageId) {
|
||||||
|
await this.collabHistory.addContributors(
|
||||||
|
versionedPageId,
|
||||||
|
poppedForRestore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Housekeeping: this explicit version supersedes the page's pending idle
|
||||||
|
// autosnapshot, so cancel it and end the current idle burst so the next edit
|
||||||
|
// starts a fresh max-wait window. Remove the idle job by its REAL jobId
|
||||||
|
// (page.id UUID — computeHistoryJob arms it under page.id), not the raw
|
||||||
|
// doc-name id which may be a slugId for a `page.<slugId>` doc (#260), or the
|
||||||
|
// remove silently misses. The burst marker is keyed by documentName (like its
|
||||||
|
// sibling per-document maps), and is also cleaned in afterUnloadDocument.
|
||||||
|
if (versionedPageId) {
|
||||||
|
await this.historyQueue.remove(versionedPageId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
this.idleBurstStart.delete(documentName);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
document.broadcastStateless(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'version.saved',
|
||||||
|
historyId: result.historyId,
|
||||||
|
kind: result.kind,
|
||||||
|
alreadySaved: result.alreadySaved,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user?.id;
|
const userId = data.context?.user?.id;
|
||||||
@@ -586,6 +783,10 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.contributors.delete(documentName);
|
this.contributors.delete(documentName);
|
||||||
this.agentTouched.delete(documentName);
|
this.agentTouched.delete(documentName);
|
||||||
this.intentionalClear.delete(documentName);
|
this.intentionalClear.delete(documentName);
|
||||||
|
// #370 — drop the idle-burst marker with the other per-document maps so it
|
||||||
|
// cannot accumulate across the process lifetime for never-manually-saved
|
||||||
|
// pages. The pending idle job (if any) is a self-expiring BullMQ delayed job.
|
||||||
|
this.idleBurstStart.delete(documentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private consumeContributors(documentName: string): string[] {
|
private consumeContributors(documentName: string): string[] {
|
||||||
@@ -617,19 +818,80 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
private async enqueuePageHistory(
|
private async enqueuePageHistory(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
documentName: string,
|
||||||
lastUpdatedSource: string,
|
lastUpdatedSource: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
|
// #370 — trailing idle debounce with a max-wait ceiling. One pending idle
|
||||||
// doc comment for the agent-delay-0 / age-based-debounce invariants).
|
// job per page (jobId = page.id); on every store we remove the pending
|
||||||
|
// delayed job and re-add it, so the snapshot lands `delay` after edits go
|
||||||
|
// quiet rather than once per store (precedent: workspace.service.ts).
|
||||||
|
// remove() on a delayed job simply deletes it (0 if absent, no throw); if the
|
||||||
|
// job is already ACTIVE and the remove is a no-op, the add still de-dups and
|
||||||
|
// the processor's isDeepStrictEqual gate collapses the duplicate content.
|
||||||
|
//
|
||||||
|
// The FIRST arm of a burst records `burstStart`; computeHistoryJob shrinks
|
||||||
|
// the delay to the remaining max-wait budget from that point, so a continuous
|
||||||
|
// session cannot re-arm the trailing timer forever and starve the snapshot.
|
||||||
|
// A burst marker older than THIS TIER's max-wait means the previous idle job
|
||||||
|
// has already fired — start a fresh window instead of firing immediately on
|
||||||
|
// the next edit. Must use the SAME source-specific max-wait computeHistoryJob
|
||||||
|
// uses (agent 5m / user 10m): a hardcoded USER ceiling would leave an agent
|
||||||
|
// burst's marker stale for 5..10m, forcing delay=0 on every store in that
|
||||||
|
// window and writing one idle row per store — exactly the per-store bloat the
|
||||||
|
// debounce exists to prevent, on the continuous-agent path.
|
||||||
|
const maxWait =
|
||||||
|
lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
||||||
|
const now = Date.now();
|
||||||
|
// Keyed by documentName (see the map declaration) so afterUnloadDocument can
|
||||||
|
// clean it; the queue jobId stays page.id (computeHistoryJob) as required.
|
||||||
|
let burstStart = this.idleBurstStart.get(documentName);
|
||||||
|
if (burstStart === undefined || now - burstStart >= maxWait) {
|
||||||
|
burstStart = now;
|
||||||
|
this.idleBurstStart.set(documentName, burstStart);
|
||||||
|
}
|
||||||
|
|
||||||
const { jobId, delay } = computeHistoryJob(
|
const { jobId, delay } = computeHistoryJob(
|
||||||
page,
|
page,
|
||||||
lastUpdatedSource,
|
lastUpdatedSource,
|
||||||
Date.now(),
|
burstStart,
|
||||||
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// remove-then-add trailing-debounce idiom, and its ONE race. We delete the
|
||||||
|
// pending delayed job and re-add it under the same jobId so the timer resets
|
||||||
|
// to the trailing edge of the burst. The race is the small window between
|
||||||
|
// these two awaits: if the delayed job's `delay` elapses in that gap it goes
|
||||||
|
// ACTIVE, and then:
|
||||||
|
// - remove() on an active/locked job is a no-op (BullMQ won't yank a job a
|
||||||
|
// worker holds), and our `.catch(() => undefined)` swallows that too; and
|
||||||
|
// - add() with a jobId that already exists (the now-active job's id) is
|
||||||
|
// DROPPED by BullMQ — a duplicate add is a no-op.
|
||||||
|
// So this store fails to re-arm the trailing job: the just-fired snapshot
|
||||||
|
// captured content up to the moment it went active, and THIS edit is left
|
||||||
|
// without a pending trailing job. It is bounded and self-healing — the NEXT
|
||||||
|
// store re-arms a fresh delayed job (the id is free again once the active job
|
||||||
|
// completes / removeOnComplete frees it), and the processor's
|
||||||
|
// isDeepStrictEqual gate collapses any content-identical duplicate. The only
|
||||||
|
// uncovered case is when the racing store was the LAST in the session: the
|
||||||
|
// tail edits made after the job went active get NO trailing snapshot until
|
||||||
|
// the next edit re-arms one. That is an acceptable safety-net gap (a manual
|
||||||
|
// Save, a source-transition boundary, or simply the next edit all still cover
|
||||||
|
// it), which is why the reviewer accepts documenting it here rather than
|
||||||
|
// adding a post-add "did the add actually arm a job?" re-check.
|
||||||
|
//
|
||||||
|
// NOTE — do NOT "unify" this with the neighbouring embed-debounce idiom
|
||||||
|
// (aiQueue.add of PAGE_CONTENT_UPDATED above): that one uses a STABLE jobId
|
||||||
|
// and NO remove(), relying purely on BullMQ coalescing a repeated add under
|
||||||
|
// the same id, because a re-embed only needs to eventually run once on the
|
||||||
|
// latest content and re-anchoring its delay on every keystroke is undesirable.
|
||||||
|
// THIS idiom deliberately removes-then-adds precisely to PUSH the delay back
|
||||||
|
// to the trailing edge on every store (a true debounce), which coalescing
|
||||||
|
// alone cannot do. Collapsing them would silently change the history cadence.
|
||||||
|
await this.historyQueue.remove(jobId).catch(() => undefined);
|
||||||
|
|
||||||
await this.historyQueue.add(
|
await this.historyQueue.add(
|
||||||
QueueJob.PAGE_HISTORY,
|
QueueJob.PAGE_HISTORY,
|
||||||
{ pageId: page.id } as IPageHistoryJob,
|
{ pageId: page.id, kind: 'idle' } as IPageHistoryJob,
|
||||||
{ jobId, delay },
|
{ jobId, delay },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { jsonToText } from './collaboration.util';
|
||||||
|
|
||||||
|
// #502 READ contract: `jsonToText(json, { deterministic: true })` — the flat,
|
||||||
|
// machine-diffable text rendering behind getPage `format:"text"`. Block-per-line
|
||||||
|
// (`\n` separator), inline marks/anchors dropped, hardBreak -> `\n`, and non-text
|
||||||
|
// nodes replaced by STABLE placeholders (`[image]`, `[table RxC]`). Output
|
||||||
|
// stability across package versions IS the contract, so it is pinned by a
|
||||||
|
// snapshot below. The DEFAULT (no options) path is the search-index serializer
|
||||||
|
// and MUST be unchanged — asserted separately.
|
||||||
|
|
||||||
|
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||||
|
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||||
|
const text = (t: string, marks?: any[]) =>
|
||||||
|
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||||
|
|
||||||
|
describe('jsonToText — default (search index) behavior is unchanged', () => {
|
||||||
|
it('uses the `\\n\\n` block separator and drops non-text nodes to empty', () => {
|
||||||
|
const d = doc(para(text('alpha')), para(text('beta')));
|
||||||
|
expect(jsonToText(d)).toBe('alpha\n\nbeta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an image contributes no text in the default (tsvector) mode', () => {
|
||||||
|
const d = doc(para(text('cap')), { type: 'image', attrs: { src: 's' } });
|
||||||
|
// No `[image]` placeholder leaks into the search index.
|
||||||
|
expect(jsonToText(d)).not.toContain('[image]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('jsonToText — deterministic:true (getPage format:"text")', () => {
|
||||||
|
it('renders one line per block with `\\n` separators, marks dropped', () => {
|
||||||
|
const d = doc(
|
||||||
|
{ type: 'heading', attrs: { level: 2 }, content: [text('Title')] },
|
||||||
|
para(
|
||||||
|
text('hello ', [{ type: 'bold' }]),
|
||||||
|
text('world', [{ type: 'italic' }]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('Title\nhello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a hardBreak renders as a newline', () => {
|
||||||
|
const d = doc(para(text('a'), { type: 'hardBreak' }, text('b')));
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('a\nb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an image node -> the stable `[image]` placeholder', () => {
|
||||||
|
const d = doc(para(text('before')), { type: 'image', attrs: { src: 's' } });
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('before\n[image]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a table -> `[table RxC]` (rows x columns) and its cell text is NOT flattened in', () => {
|
||||||
|
const cell = (t: string) => ({
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [para(text(t))],
|
||||||
|
});
|
||||||
|
const row = (...cells: any[]) => ({ type: 'tableRow', content: cells });
|
||||||
|
const table = {
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
row(cell('CELLONE'), cell('CELLTWO'), cell('CELLTHREE')),
|
||||||
|
row(cell('CELLFOUR'), cell('CELLFIVE'), cell('CELLSIX')),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = doc(para(text('grid:')), table);
|
||||||
|
const out = jsonToText(d, { deterministic: true });
|
||||||
|
expect(out).toBe('grid:\n[table 2x3]');
|
||||||
|
expect(out).not.toContain('CELL'); // cell text is not flattened into the read
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SNAPSHOT: a mixed document renders to a stable, deterministic string', () => {
|
||||||
|
const cell = (t: string) => ({
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [para(text(t))],
|
||||||
|
});
|
||||||
|
const d = doc(
|
||||||
|
{ type: 'heading', attrs: { level: 1 }, content: [text('Config')] },
|
||||||
|
para(text('key = ', [{ type: 'bold' }]), text('value')),
|
||||||
|
{
|
||||||
|
type: 'bulletList',
|
||||||
|
content: [
|
||||||
|
{ type: 'listItem', content: [para(text('one'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('two'))] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'image', attrs: { src: 's' } },
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
content: [{ type: 'tableRow', content: [cell('x'), cell('y')] }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toMatchInlineSnapshot(`
|
||||||
|
"Config
|
||||||
|
key = value
|
||||||
|
|
||||||
|
|
||||||
|
one
|
||||||
|
|
||||||
|
two
|
||||||
|
[image]
|
||||||
|
[table 1x2]"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SCENARIO: a config written as a code block reads back byte-identical (diff empty)', () => {
|
||||||
|
const config =
|
||||||
|
'export TICKET_LIFETIME=$2592000\nservers:\n - www.internal.host';
|
||||||
|
const d = doc({
|
||||||
|
type: 'codeBlock',
|
||||||
|
attrs: { language: 'yaml' },
|
||||||
|
content: [text(config)],
|
||||||
|
});
|
||||||
|
// The code block is one block; its text (dollars, bare domain, newlines) is
|
||||||
|
// preserved verbatim, so a read-as-text of a stored config diffs empty.
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe(config);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,6 +66,15 @@ describe('HistoryProcessor.process', () => {
|
|||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
// #370 F3 — the processor now serializes its find+save under a page-row lock
|
||||||
|
// via executeTx. A 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 }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
||||||
// matches the constructor and avoids the Nest DI container.
|
// matches the constructor and avoids the Nest DI container.
|
||||||
proc = new HistoryProcessor(
|
proc = new HistoryProcessor(
|
||||||
@@ -73,6 +82,7 @@ describe('HistoryProcessor.process', () => {
|
|||||||
pageRepo as any,
|
pageRepo as any,
|
||||||
collabHistory as any,
|
collabHistory as any,
|
||||||
watcherService as any,
|
watcherService as any,
|
||||||
|
db as any,
|
||||||
notificationQueue as any,
|
notificationQueue as any,
|
||||||
generalQueue as any,
|
generalQueue as any,
|
||||||
);
|
);
|
||||||
@@ -126,15 +136,26 @@ describe('HistoryProcessor.process', () => {
|
|||||||
await proc.process(buildJob());
|
await proc.process(buildJob());
|
||||||
|
|
||||||
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||||
|
// #370 F3/F9 — the snapshot decision runs under a page-row lock. Pin the lock
|
||||||
|
// structurally so a refactor that drops withLock/trx (silently reintroducing
|
||||||
|
// the TOCTOU double-insert) turns this red. The tx stub is { __trx: true }.
|
||||||
|
expect(pageRepo.findById).toHaveBeenCalledWith(
|
||||||
|
PAGE_ID,
|
||||||
|
expect.objectContaining({ withLock: true, trx: { __trx: true } }),
|
||||||
|
);
|
||||||
|
// #370 F7 — addPageWatchers MUST receive the trx, or its FK-check runs on a
|
||||||
|
// separate connection and self-deadlocks against our FOR UPDATE. Asserting
|
||||||
|
// the trx arg here is exactly what would have caught that regression.
|
||||||
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
||||||
['u1', 'u2'],
|
['u1', 'u2'],
|
||||||
PAGE_ID,
|
PAGE_ID,
|
||||||
SPACE_ID,
|
SPACE_ID,
|
||||||
WORKSPACE_ID,
|
WORKSPACE_ID,
|
||||||
|
{ __trx: true },
|
||||||
);
|
);
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: PAGE_ID }),
|
expect.objectContaining({ id: PAGE_ID }),
|
||||||
{ contributorIds: ['u1', 'u2'] },
|
{ contributorIds: ['u1', 'u2'], kind: 'idle', trx: { __trx: true } },
|
||||||
);
|
);
|
||||||
expect(generalQueue.add).toHaveBeenCalledWith(
|
expect(generalQueue.add).toHaveBeenCalledWith(
|
||||||
QueueJob.PAGE_BACKLINKS,
|
QueueJob.PAGE_BACKLINKS,
|
||||||
@@ -186,6 +207,48 @@ describe('HistoryProcessor.process', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('COMMIT failure (throw outside the tx callback) → contributors RESTORED', async () => {
|
||||||
|
// #370 F8 — a commit-time failure throws OUTSIDE the callback, so the inner
|
||||||
|
// try/catch does not run; the outer catch must restore the popped set (else a
|
||||||
|
// BullMQ retry writes an unattributed version). Use a db whose execute() runs
|
||||||
|
// the callback THEN throws, simulating a commit abort.
|
||||||
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
});
|
||||||
|
const commitFail = {
|
||||||
|
transaction: () => ({
|
||||||
|
execute: async (fn: (trx: any) => Promise<any>) => {
|
||||||
|
await fn({ __trx: true }); // callback succeeds (saveHistory ok)
|
||||||
|
throw new Error('commit aborted'); // ...but the COMMIT fails
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const procCommitFail = new HistoryProcessor(
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
pageRepo as any,
|
||||||
|
collabHistory as any,
|
||||||
|
watcherService as any,
|
||||||
|
commitFail as any,
|
||||||
|
notificationQueue as any,
|
||||||
|
generalQueue as any,
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(procCommitFail['logger'], 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await expect(procCommitFail.process(buildJob())).rejects.toThrow(
|
||||||
|
'commit aborted',
|
||||||
|
);
|
||||||
|
// The inner catch did NOT run (save succeeded), so only the outer catch can
|
||||||
|
// restore — assert it did.
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
||||||
|
'u1',
|
||||||
|
'u2',
|
||||||
|
]);
|
||||||
|
// And the post-snapshot queue work must NOT have run (we rethrew).
|
||||||
|
expect(generalQueue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
content: { type: 'doc', content: [] },
|
content: { type: 'doc', content: [] },
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
import { isEmptyParagraphDoc } from '../collaboration.util';
|
import { isEmptyParagraphDoc } from '../collaboration.util';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -29,6 +32,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
) {
|
) {
|
||||||
@@ -41,6 +45,9 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
try {
|
try {
|
||||||
const { pageId } = job.data;
|
const { pageId } = job.data;
|
||||||
|
|
||||||
|
// Read the page WITHOUT a lock first, only to bail early on the two cheap
|
||||||
|
// no-write cases (page gone / empty first snapshot) without opening a
|
||||||
|
// transaction. The authoritative check-then-write happens locked below.
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
@@ -51,40 +58,109 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
// #370 F3 — the snapshot decision (findPageLastHistory → saveHistory) must
|
||||||
pageId,
|
// be serialized against manual-save/boundary writers, which run under a
|
||||||
{ includeContent: true },
|
// page-row lock in onStoreDocument. Without it, this processor and a
|
||||||
);
|
// concurrent manual-save each read the same lastHistory (MVCC), both see
|
||||||
|
// content != lastHistory, and both insert — producing two page_history rows
|
||||||
|
// with IDENTICAL content (one 'idle', one 'manual'), defeating
|
||||||
|
// promote-not-dup and the version-vs-autosave split. Taking the same
|
||||||
|
// page-row lock makes the second writer observe the first's committed row so
|
||||||
|
// the isDeepStrictEqual gate collapses the duplicate. Only the read+write
|
||||||
|
// is transacted; the post-snapshot queue work stays outside.
|
||||||
|
let contributorIds: string[] = [];
|
||||||
|
let snapshotWritten = false;
|
||||||
|
let lastHistoryContent: unknown;
|
||||||
|
// #370 F8 — the contributor set popped from Redis (destructive SPOP) must be
|
||||||
|
// restored if the snapshot does not durably land. The inner try/catch only
|
||||||
|
// covers a throw INSIDE the callback; a COMMIT failure (connection drop,
|
||||||
|
// serialization/deadlock abort on commit — the transient class the epic
|
||||||
|
// already retries) throws OUTSIDE it, rolling the snapshot back while the
|
||||||
|
// pop is already gone. We track the popped set here and restore it in the
|
||||||
|
// outer catch so a BullMQ retry re-attributes the version. addContributors
|
||||||
|
// is an idempotent Redis SADD, so a double-restore is harmless.
|
||||||
|
let poppedForRestore: string[] = [];
|
||||||
|
|
||||||
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
|
try {
|
||||||
this.logger.debug(
|
await executeTx(this.db, async (trx) => {
|
||||||
`Skipping first history for page ${pageId}: empty content`,
|
const lockedPage = await this.pageRepo.findById(pageId, {
|
||||||
);
|
includeContent: true,
|
||||||
await this.collabHistory.clearContributors(pageId);
|
withLock: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (!lockedPage) return;
|
||||||
|
|
||||||
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
|
pageId,
|
||||||
|
{ includeContent: true, trx },
|
||||||
|
);
|
||||||
|
lastHistoryContent = lastHistory?.content;
|
||||||
|
|
||||||
|
if (!lastHistory && isEmptyParagraphDoc(lockedPage.content as any)) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping first history for page ${pageId}: empty content`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastHistory &&
|
||||||
|
isDeepStrictEqual(lastHistory.content, lockedPage.content)
|
||||||
|
) {
|
||||||
|
return; // already snapshotted at this content — nothing to write
|
||||||
|
}
|
||||||
|
|
||||||
|
contributorIds = await this.collabHistory.popContributors(pageId);
|
||||||
|
poppedForRestore = contributorIds;
|
||||||
|
try {
|
||||||
|
// Pass `trx` so the watcher insert's FK check (FOR KEY SHARE on
|
||||||
|
// pages[pageId]) runs on the SAME connection that already holds the
|
||||||
|
// FOR UPDATE lock from findById — otherwise it takes the FK lock on a
|
||||||
|
// separate pool connection and self-deadlocks against our own tx.
|
||||||
|
await this.watcherService.addPageWatchers(
|
||||||
|
contributorIds,
|
||||||
|
pageId,
|
||||||
|
lockedPage.spaceId,
|
||||||
|
lockedPage.workspaceId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// #370 — every job on this queue is a trailing idle-flush autosnapshot.
|
||||||
|
await this.pageHistoryRepo.saveHistory(lockedPage, {
|
||||||
|
contributorIds,
|
||||||
|
kind: job.data.kind ?? 'idle',
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
snapshotWritten = true;
|
||||||
|
this.logger.debug(`History created for page: ${pageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||||
|
poppedForRestore = [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// A throw here means the tx did NOT commit (callback threw, or the commit
|
||||||
|
// itself failed and rolled back). If we popped contributors and the inner
|
||||||
|
// catch did not already restore them, restore now so the retry keeps
|
||||||
|
// attribution. snapshotWritten is irrelevant: it is set before commit, so
|
||||||
|
// it can be true even when the commit rolled the snapshot back.
|
||||||
|
if (poppedForRestore.length) {
|
||||||
|
await this.collabHistory.addContributors(pageId, poppedForRestore);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No snapshot written (page vanished / empty-first / unchanged content) →
|
||||||
|
// clear the contributor set for the skip cases and stop.
|
||||||
|
if (!snapshotWritten) {
|
||||||
|
if (!lastHistoryContent && isEmptyParagraphDoc(page.content as any)) {
|
||||||
|
await this.collabHistory.clearContributors(pageId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
{
|
||||||
!lastHistory ||
|
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
|
||||||
) {
|
|
||||||
const contributorIds = await this.collabHistory.popContributors(pageId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.watcherService.addPageWatchers(
|
|
||||||
contributorIds,
|
|
||||||
pageId,
|
|
||||||
page.spaceId,
|
|
||||||
page.workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
|
||||||
this.logger.debug(`History created for page: ${pageId}`);
|
|
||||||
} catch (err) {
|
|
||||||
await this.collabHistory.addContributors(pageId, contributorIds);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentions = extractMentions(page.content);
|
const mentions = extractMentions(page.content);
|
||||||
const pageMentions = extractPageMentions(mentions);
|
const pageMentions = extractPageMentions(mentions);
|
||||||
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
||||||
@@ -102,7 +178,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contributorIds.length > 0 && lastHistory?.content) {
|
if (contributorIds.length > 0 && lastHistoryContent) {
|
||||||
await this.notificationQueue
|
await this.notificationQueue
|
||||||
.add(QueueJob.PAGE_UPDATED, {
|
.add(QueueJob.PAGE_UPDATED, {
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@@ -529,4 +529,107 @@ describe('replaceYjsMarkedText', () => {
|
|||||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||||
expect(text.toDelta()).toEqual(before);
|
expect(text.toDelta()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #496: apply must NOT silently strip the replaced run's inline formatting.
|
||||||
|
// Build a paragraph and format the marked range with extra marks, then assert
|
||||||
|
// the replacement carries them.
|
||||||
|
function buildFormatted(
|
||||||
|
runs: Array<{ text: string; attrs?: Record<string, any> }>,
|
||||||
|
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, runs.map((r) => r.text).join(''));
|
||||||
|
let offset = 0;
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.attrs) text.format(offset, run.text.length, run.attrs);
|
||||||
|
offset += run.text.length;
|
||||||
|
}
|
||||||
|
return { fragment, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves the original run formatting (bold + link) on the replacement', () => {
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{ text: 'see ' },
|
||||||
|
{
|
||||||
|
text: 'old',
|
||||||
|
attrs: {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
bold: true,
|
||||||
|
link: { href: 'https://x.test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: ' end' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'new' });
|
||||||
|
// The comment anchor AND the bold/link marks survive the delete+insert.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'see ' },
|
||||||
|
{
|
||||||
|
insert: 'new',
|
||||||
|
attributes: {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
bold: true,
|
||||||
|
link: { href: 'https://x.test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ insert: ' end' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
|
||||||
|
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
|
||||||
|
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
|
||||||
|
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
|
||||||
|
// bold on the result only holds if the code genuinely selects the LONGEST run.
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
|
||||||
|
{
|
||||||
|
text: 'bolded',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Z',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
|
||||||
|
// Two equal-length runs (2 chars each) with different formatting, same
|
||||||
|
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
|
||||||
|
// (italic) prevails over the later bold one.
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{
|
||||||
|
text: 'AA',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'BB',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Z',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ type MarkedSegment = {
|
|||||||
length: number;
|
length: number;
|
||||||
text: string;
|
text: string;
|
||||||
markAttrs: Record<string, any>;
|
markAttrs: Record<string, any>;
|
||||||
|
// The FULL attribute set of this delta run — the `comment` mark plus any
|
||||||
|
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
|
||||||
|
// original run's formatting onto the replacement instead of dropping it.
|
||||||
|
attributes: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,6 +206,7 @@ export function replaceYjsMarkedText(
|
|||||||
length,
|
length,
|
||||||
text: insert,
|
text: insert,
|
||||||
markAttrs: markAttr,
|
markAttrs: markAttr,
|
||||||
|
attributes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
offset += length;
|
offset += length;
|
||||||
@@ -251,15 +256,25 @@ export function replaceYjsMarkedText(
|
|||||||
return { applied: false, currentText: joinedText };
|
return { applied: false, currentText: joinedText };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. All guards passed: delete the marked run and re-insert newText with the
|
// 3. All guards passed: delete the marked run and re-insert newText at the
|
||||||
// same comment attributes at the same offset. Atomic within the caller's
|
// same offset. Atomic within the caller's transaction.
|
||||||
// transaction.
|
|
||||||
const start = segments[0].offset;
|
const start = segments[0].offset;
|
||||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||||
const markAttrs = segments[0].markAttrs;
|
|
||||||
|
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
|
||||||
|
// with only the `comment` mark silently dropped bold/italic/code/link of the
|
||||||
|
// replaced text. Yjs applies one flat attribute set to the whole insert, so
|
||||||
|
// when the marked run mixes formatting we pick the DOMINANT segment (the one
|
||||||
|
// covering the most characters) and apply its attributes — a v1 that preserves
|
||||||
|
// the common single-format case exactly and, for a mixed run, keeps the
|
||||||
|
// prevailing style rather than losing all of it. `attributes` already carries
|
||||||
|
// the `comment` mark (every collected segment is filtered on it above), so the
|
||||||
|
// anchor is preserved by copying the run's attribute set verbatim.
|
||||||
|
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
|
||||||
|
const insertAttrs = { ...dominant.attributes };
|
||||||
|
|
||||||
node.delete(start, len);
|
node.delete(start, len);
|
||||||
node.insert(start, newText, { comment: markAttrs });
|
node.insert(start, newText, insertAttrs);
|
||||||
|
|
||||||
return { applied: true, currentText: newText };
|
return { applied: true, currentText: newText };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { readClientBuildVersion } from './client-version';
|
||||||
|
|
||||||
|
describe('readClientBuildVersion', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeVersionJson = (content: string) =>
|
||||||
|
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||||
|
|
||||||
|
it('returns the version from a valid version.json', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace in the version', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when version.json is missing', () => {
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" on malformed JSON', () => {
|
||||||
|
writeVersionJson('{ not json');
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is absent', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is not a string', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the path does not exist at all', () => {
|
||||||
|
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the absolute path to the built client bundle directory
|
||||||
|
* (`apps/client/dist`) shipped into the runtime image.
|
||||||
|
*
|
||||||
|
* The `../` depth is anchored on THIS module's compiled location
|
||||||
|
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||||
|
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||||
|
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||||
|
* module at a different depth would silently resolve to the wrong directory.
|
||||||
|
*/
|
||||||
|
export function resolveClientDistPath(): string {
|
||||||
|
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the build version the client bundle was compiled with, from
|
||||||
|
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||||
|
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||||
|
*
|
||||||
|
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||||
|
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||||
|
* the whole version-coherence feature stays silently inert — existing deploys
|
||||||
|
* without the file keep working unchanged.
|
||||||
|
*/
|
||||||
|
export function readClientBuildVersion(clientDistPath: string): string {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||||
|
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||||
|
return typeof version === 'string' ? version.trim() : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
|||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './security-headers';
|
export * from './security-headers';
|
||||||
|
export * from './client-version';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user