Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cafe29b153 | |||
| 35f2c06f42 | |||
| ce9f1a8980 | |||
| 15fe998d3d | |||
| 54f0ba681e | |||
| 27f7791a0e | |||
| 15859e3b9f | |||
| ebf132d5ac | |||
| 05d3456f5c | |||
| d910180772 | |||
| 89a4bf28ce | |||
| da94b1589c | |||
| 40227bbf51 | |||
| a26803a1bc | |||
| 18eee98b7f | |||
| 067fc46170 | |||
| ab1da408e5 | |||
| da7bb95d4f | |||
| 6ab2e989b9 | |||
| 169e34d766 | |||
| 10d5220f5e | |||
| 52ee3c1f3e | |||
| ccee32cb0b | |||
| 79a461f79d | |||
| 24946ad820 | |||
| 84334a1f34 | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| 4c1ee50dc9 | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| bfcee6dddc | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| 43b11d92ab | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| f759084f41 | |||
| 459d636ffb | |||
| e89ac627dd | |||
| f665f6fdd2 | |||
| 7af85b476e | |||
| 5d8364bb5f | |||
| d3209b5aab | |||
| 68899a2c2e | |||
| b9f3de80f5 | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 5edd75da42 | |||
| 24b903aaf3 | |||
| 2637640291 | |||
| aa0428e28b | |||
| 0a3e32e7f6 | |||
| b1ede48319 | |||
| d4d05c8e8b | |||
| 351860ba4b | |||
| 795dde463b | |||
| 0392566af9 | |||
| f43696a1c4 | |||
| 8971912d9e | |||
| 588596fb2f | |||
| ba94def3c8 | |||
| e1b8f81b15 | |||
| 45478098f5 | |||
| 62b818bb36 | |||
| b7c16dc634 | |||
| da952ca536 | |||
| 1458e3e152 | |||
| 13a333632a | |||
| 344b9723b2 | |||
| d57392b5af | |||
| a86e5f409f | |||
| 33d22ff164 | |||
| b861266ff8 | |||
| 8b99b70d73 | |||
| b3d4922efa | |||
| 49c7c4bb64 | |||
| d9517ff3f1 | |||
| 48c1ec46f7 | |||
| cd539558ed | |||
| b62db917de | |||
| ec542a924b | |||
| a9da8f7f15 | |||
| 7c0664d2b3 | |||
| a32fba63ec | |||
| 808a5c70df | |||
| 0210faabea | |||
| 17003fbbc1 | |||
| 0df6242128 | |||
| 36b3539571 | |||
| a63efa6920 | |||
| ccd38152ab | |||
| 8f95c5808e | |||
| f720151c63 | |||
| 2d30ad1fa2 |
+72
-3
@@ -173,9 +173,21 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
||||||
# A pooled connection idle longer than this is closed instead of reused, so a
|
# A pooled connection idle longer than this is closed instead of reused, so a
|
||||||
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
|
||||||
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
|
||||||
# AI_STREAM_KEEPALIVE_MS=10000
|
# the network kills it (fewer resets), while still reusing within a burst of
|
||||||
|
# back-to-back calls. Lower it further if your egress drops idle connections even
|
||||||
|
# faster. Default 4000 (4 s).
|
||||||
|
# AI_STREAM_KEEPALIVE_MS=4000
|
||||||
|
|
||||||
|
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
|
||||||
|
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
|
||||||
|
# socket) is retried on a fresh connection with jittered exponential backoff.
|
||||||
|
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
|
||||||
|
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
|
||||||
|
# retry: a started stream is never replayed, only a connect that never responded.
|
||||||
|
# 0 disables the retry. Default 4.
|
||||||
|
# AI_STREAM_PRE_RESPONSE_RETRIES=4
|
||||||
|
|
||||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||||
@@ -190,6 +202,39 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||||
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||||
|
# active" behavior.
|
||||||
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
|
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||||
|
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||||
|
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||||
|
# it, and a client reconnects/live-follows the run.
|
||||||
|
#
|
||||||
|
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||||
|
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||||
|
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||||
|
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||||
|
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||||
|
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||||
|
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||||
|
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||||
|
#
|
||||||
|
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
||||||
|
# durable run tees its SSE frames into an in-memory registry, and a
|
||||||
|
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
||||||
|
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
||||||
|
# (default) the registry is never populated and attach always answers 204, so a
|
||||||
|
# reopened tab of an active run silently falls back to degraded 2.5s history
|
||||||
|
# polling — every wire path stays byte-for-byte identical to a build without the
|
||||||
|
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
||||||
|
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||||
|
# registry is process-local).
|
||||||
|
# AI_CHAT_RESUMABLE_STREAM=false
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
@@ -223,3 +268,27 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||||
# per rolling day).
|
# per rolling day).
|
||||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||||
|
|
||||||
|
# --- Observability / perf metrics (#355) ---
|
||||||
|
#
|
||||||
|
# Two INDEPENDENT toggles, both OFF by default:
|
||||||
|
#
|
||||||
|
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
||||||
|
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
||||||
|
# collectors, and NOTHING is exposed on the main app port. There is NO
|
||||||
|
# default port — leaving it blank disables it. When set to a port (e.g.
|
||||||
|
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
||||||
|
# only (never on the main :3000 app listener), for a scraper such as
|
||||||
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
|
# METRICS_PORT=9464
|
||||||
|
#
|
||||||
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
||||||
|
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
||||||
|
# endpoint is public and the table has NO app-side retention, so enabling it
|
||||||
|
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
||||||
|
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
||||||
|
# does not exist and the client installs no observers.
|
||||||
|
# CLIENT_TELEMETRY_ENABLED=false
|
||||||
|
|||||||
@@ -18,12 +18,48 @@ env:
|
|||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Run the reusable test suite first so a failing test blocks the image build.
|
# Run the reusable test suite. Together with the e2e jobs below it gates the
|
||||||
|
# publish job (the image push), not the build itself — build runs in parallel.
|
||||||
test:
|
test:
|
||||||
uses: ./.github/workflows/test.yml
|
uses: ./.github/workflows/test.yml
|
||||||
|
|
||||||
|
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
|
||||||
|
# (GHA cache, scope develop-amd64). No push happens here — the publish job
|
||||||
|
# below is the only one that pushes the image.
|
||||||
build:
|
build:
|
||||||
needs: test
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build develop image (warm cache, no push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
|
push: false
|
||||||
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||||
|
|
||||||
|
# The gate: rebuilds from the cache the build job just wrote (near-instant on
|
||||||
|
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
|
||||||
|
# old sequential timing) and pushes :develop only when unit tests AND both
|
||||||
|
# e2e suites AND the build are green.
|
||||||
|
publish:
|
||||||
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -57,13 +93,10 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
cache-from: type=gha,scope=develop-amd64
|
||||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
|
||||||
|
|
||||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
# lists them all).
|
||||||
# to the pusher — that red run + email is the intended notification, not a
|
|
||||||
# deploy block.
|
|
||||||
e2e-server:
|
e2e-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||||
@@ -118,15 +151,19 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||||
|
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||||
|
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
- name: Run server e2e
|
- name: Run server e2e
|
||||||
run: pnpm --filter ./apps/server test:e2e
|
run: pnpm --filter ./apps/server test:e2e
|
||||||
|
|
||||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
# Gates the publish too — see the comment above e2e-server.
|
||||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
|
||||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
|
||||||
e2e-mcp:
|
e2e-mcp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
name: Nightly property fuzz
|
||||||
|
|
||||||
|
# The daily heavy property run for the ProseMirror<->Markdown converter
|
||||||
|
# (packages/prosemirror-markdown). The PR/CI test run keeps NUM_RUNS modest to
|
||||||
|
# stay under budget; this cron cranks up total coverage with random seeds to hunt
|
||||||
|
# for deeper round-trip counterexamples than a fixed-seed PR run can reach.
|
||||||
|
#
|
||||||
|
# WHY SHARDING: a single mega-run (~10000 fast-check runs) OOMs the vitest worker
|
||||||
|
# (empirically ~1625 runs -> "JS heap out of memory", ~2GB) because heap
|
||||||
|
# accumulates across the whole property run in one process. Instead this job runs
|
||||||
|
# SHARDS fresh vitest processes, each a MODERATE per-shard count with a DISTINCT
|
||||||
|
# derived seed, so total coverage ~= SHARDS x PER_SHARD_NUM_RUNS across processes
|
||||||
|
# that never accumulate heap. On the first failing shard we stop and keep that
|
||||||
|
# shard's output for triage.
|
||||||
|
#
|
||||||
|
# Counterexample -> fixture workflow: when a shard fails, fast-check prints the
|
||||||
|
# SHRUNK minimal counterexample plus the reproducing seed. This job files a Gitea
|
||||||
|
# issue containing that seed + counterexample ONLY when the output actually holds
|
||||||
|
# a fast-check counterexample; an infra failure (OOM/tsc/install, no
|
||||||
|
# counterexample) is filed under a DISTINCT title so it can never poison the
|
||||||
|
# counterexample dedup. A human then commits the shrunk doc as a PERMANENT fixture
|
||||||
|
# under packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
||||||
|
# counterexamples.test.ts, and FIXES the converter (never weakens a property to
|
||||||
|
# hide the bug). See packages/prosemirror-markdown/README.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 03:00 UTC daily.
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
num_runs:
|
||||||
|
description: 'fast-check runs PER SHARD (8 shards run in sequence)'
|
||||||
|
required: false
|
||||||
|
default: '600'
|
||||||
|
seed:
|
||||||
|
description: 'base fast-check seed (empty = random); shard i uses base+i'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
property-fuzz:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# No build step: the generative suite imports the converter from src/
|
||||||
|
# directly (e.g. `from '../../src/lib/markdown-converter.js'`), so it runs
|
||||||
|
# against source without the package's build/. Skipping the build also
|
||||||
|
# keeps a tsc build error from masquerading as a property-test failure and
|
||||||
|
# filing a bogus counterexample issue.
|
||||||
|
- name: Resolve base seed and per-shard run count
|
||||||
|
id: params
|
||||||
|
# Dispatch inputs are read via env (NOT interpolated into the shell body)
|
||||||
|
# to avoid script injection through a crafted input value.
|
||||||
|
env:
|
||||||
|
SEED_INPUT: ${{ inputs.seed }}
|
||||||
|
NUM_RUNS_INPUT: ${{ inputs.num_runs }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SEED="${SEED_INPUT:-}"
|
||||||
|
# Empty seed (cron, or a dispatch that left it blank) -> random. Combine
|
||||||
|
# two RANDOMs so the seed spans more than RANDOM's 0..32767 range.
|
||||||
|
[ -z "$SEED" ] && SEED=$(( (RANDOM << 15) | RANDOM ))
|
||||||
|
NUM_RUNS="${NUM_RUNS_INPUT:-}"
|
||||||
|
[ -z "$NUM_RUNS" ] && NUM_RUNS=600
|
||||||
|
echo "seed=$SEED" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "num_runs=$NUM_RUNS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Sharded property fuzz: BASE_SEED=$SEED PER_SHARD_NUM_RUNS=$NUM_RUNS SHARDS=8"
|
||||||
|
|
||||||
|
- name: Run generative property suite (sharded)
|
||||||
|
id: fuzz
|
||||||
|
env:
|
||||||
|
BASE_SEED: ${{ steps.params.outputs.seed }}
|
||||||
|
PER_SHARD_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||||
|
SHARDS: '8'
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
# Give each fresh process headroom, but rely on SHARDING (not a big heap)
|
||||||
|
# to avoid OOM: a moderate per-shard count in a process that starts clean.
|
||||||
|
export NODE_OPTIONS=--max-old-space-size=4096
|
||||||
|
: > property-output.txt
|
||||||
|
FAILED=0
|
||||||
|
FAIL_SEED=""
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$SHARDS" ]; do
|
||||||
|
SHARD_SEED=$(( BASE_SEED + i ))
|
||||||
|
echo "=== shard $((i + 1))/$SHARDS: PROPERTY_SEED=$SHARD_SEED PROPERTY_NUM_RUNS=$PER_SHARD_NUM_RUNS ==="
|
||||||
|
# tee OVERWRITES property-output.txt each shard; since we break on the
|
||||||
|
# first failure, the file ends up holding exactly the failing shard's
|
||||||
|
# output (which carries the shrunk counterexample + reproducing seed).
|
||||||
|
if PROPERTY_SEED="$SHARD_SEED" PROPERTY_NUM_RUNS="$PER_SHARD_NUM_RUNS" \
|
||||||
|
pnpm --filter @docmost/prosemirror-markdown exec \
|
||||||
|
vitest run test/generative/ 2>&1 | tee property-output.txt; then
|
||||||
|
echo "shard $((i + 1)) passed"
|
||||||
|
else
|
||||||
|
echo "shard $((i + 1)) FAILED (seed=$SHARD_SEED) — stopping; keeping its output"
|
||||||
|
FAILED=1
|
||||||
|
FAIL_SEED="$SHARD_SEED"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$(( i + 1 ))
|
||||||
|
done
|
||||||
|
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "fail_seed=$FAIL_SEED" >> "$GITHUB_OUTPUT"
|
||||||
|
exit "$FAILED"
|
||||||
|
|
||||||
|
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||||
|
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||||
|
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||||
|
# the next step under a different title) can never poison this dedup.
|
||||||
|
- name: File counterexample issue
|
||||||
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
|
# exactly when it must run. always() lets it run on the failure path.
|
||||||
|
if: always() && steps.fuzz.outputs.failed == '1'
|
||||||
|
env:
|
||||||
|
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||||
|
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TITLE_PREFIX: 'Nightly property counterexample'
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
# Discriminate counterexample vs infra failure by the fast-check
|
||||||
|
# signature. No signature -> leave it to the infra-failure step.
|
||||||
|
if ! grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||||
|
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||||
|
|
||||||
|
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||||
|
# prefix already exists. A failure of this check must NOT block creation.
|
||||||
|
EXISTING=""
|
||||||
|
if EXISTING=$(curl -sS \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
|
if printf '%s' "$EXISTING" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||||
|
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||||
|
# interpolate the counterexample into JSON).
|
||||||
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||||
|
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||||
|
|
||||||
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|
||||||
|
curl -sS -X POST \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d @payload.json
|
||||||
|
|
||||||
|
# An INFRA failure (OOM, tsc, install) has NO counterexample signature. File
|
||||||
|
# it under a DISTINCT title so it is visible but keeps the counterexample
|
||||||
|
# dedup (above) uncontaminated — a real counterexample can still file even
|
||||||
|
# while an infra issue is open.
|
||||||
|
- name: File infra failure issue
|
||||||
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
|
# exactly when it must run. always() lets it run on the failure path.
|
||||||
|
if: always() && steps.fuzz.outputs.failed == '1'
|
||||||
|
env:
|
||||||
|
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||||
|
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TITLE_PREFIX: 'Nightly property run infra failure'
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
# Only file when there is NO counterexample signature (else the
|
||||||
|
# counterexample step owns it).
|
||||||
|
if grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||||
|
echo "Counterexample present — owned by the counterexample step."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||||
|
|
||||||
|
EXISTING=""
|
||||||
|
if EXISTING=$(curl -sS \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
|
if printf '%s' "$EXISTING" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||||
|
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed WITHOUT a fast-check counterexample (infra failure: OOM / build / install). This is NOT a converter round-trip bug.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nInvestigate the run log (memory, dependency install, or a tsc/import error). The nightly counterexample dedup is intentionally separate from this issue.\n\nTail of the test output:\n\n```\n%s\n```\n' \
|
||||||
|
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$(tail -n 120 property-output.txt)")
|
||||||
|
|
||||||
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|
||||||
|
curl -sS -X POST \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d @payload.json
|
||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -72,6 +115,14 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||||
|
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||||
|
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||||
|
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||||
|
# unless it is built first. Build it before the recursive test run.
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -4,7 +4,20 @@
|
|||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
node_modules
|
||||||
|
|
||||||
|
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||||
|
# so src/ and prod can never silently diverge).
|
||||||
|
packages/git-sync/build/
|
||||||
|
|
||||||
|
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||||
|
# never committed, so src/ and prod can never silently diverge).
|
||||||
|
packages/prosemirror-markdown/build/
|
||||||
|
|
||||||
|
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||||
|
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||||
|
# markdown convention; the package is private and rebuilt at deploy.
|
||||||
|
packages/mcp/build/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -43,6 +56,8 @@ lerna-debug.log*
|
|||||||
.nx/cache
|
.nx/cache
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/tmp/
|
.claude/tmp/
|
||||||
|
# Local Chrome performance traces recorded by the AI-chat perf harness
|
||||||
|
.claude/perf-traces/
|
||||||
|
|
||||||
# TypeScript incremental build artifacts
|
# TypeScript incremental build artifacts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -213,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
|||||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> for the step-by-step and the traps.
|
||||||
|
>
|
||||||
|
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||||
|
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||||
|
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||||
|
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||||
|
> UI tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -223,6 +230,24 @@ pnpm build # nx run-many -t build (all packages)
|
|||||||
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Build the shared packages before running a consumer's `tsc`/tests in
|
||||||
|
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
|
||||||
|
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
|
||||||
|
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
|
||||||
|
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
|
||||||
|
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
|
||||||
|
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
|
||||||
|
> built first:
|
||||||
|
> ```bash
|
||||||
|
> pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
> pnpm --filter @docmost/editor-ext build
|
||||||
|
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||||
|
> ```
|
||||||
|
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
|
||||||
|
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
|
||||||
|
> that order). Reach for it whenever you run a consumer package's checks on their
|
||||||
|
> own rather than through the full `pnpm build`.
|
||||||
|
|
||||||
**Lint** (per package — there is no root lint script):
|
**Lint** (per package — there is no root lint script):
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server lint # eslint --fix on server .ts
|
pnpm --filter server lint # eslint --fix on server .ts
|
||||||
@@ -249,7 +274,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -278,11 +306,12 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. 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`.
|
||||||
|
|
||||||
@@ -292,7 +321,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||||
contain a standalone footnote definition, which canonicalization would drop.
|
contain a standalone footnote definition, which canonicalization would drop.
|
||||||
(#228)
|
(#228)
|
||||||
|
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||||
|
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||||
|
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||||
|
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||||
|
losing the connection no longer aborts the turn — it keeps executing and
|
||||||
|
persisting server-side, and only an explicit Stop ends it. A client can
|
||||||
|
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||||
|
(resolve the latest run + its assistant message for a chat) and
|
||||||
|
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||||
|
enforces one active run per chat, and a startup sweep settles any run left
|
||||||
|
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||||
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
|
deployment. (#184)
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -156,6 +169,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
|
strategy was requested (our server never requests one). Unpatched, those
|
||||||
|
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||||
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
+24
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||||
|
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||||
|
# This stage is discarded, so the toolchain can stay installed.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -38,6 +45,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
|
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||||
|
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||||
|
# revisit at step 6 when #119 lands.)
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
@@ -49,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||||
|
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||||
|
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||||
|
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
USER node
|
||||||
|
|
||||||
RUN mkdir -p /app/data/storage
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,32 @@ Gitmost follows the upstream Docmost setup. See the Docmost
|
|||||||
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
||||||
|
|
||||||
|
### Reverse proxy: SSE streaming paths
|
||||||
|
|
||||||
|
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
|
||||||
|
long-lived `text/event-stream` response and **must bypass response buffering AND response
|
||||||
|
compression** at every proxy in front of the app:
|
||||||
|
|
||||||
|
- `POST /api/ai-chat/stream` — the live agent turn stream
|
||||||
|
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
|
||||||
|
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||||
|
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
|
||||||
|
|
||||||
|
A buffering or compressing proxy does not break these with an error — it silently ruins them:
|
||||||
|
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
|
||||||
|
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
|
||||||
|
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
|
||||||
|
|
||||||
|
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
|
||||||
|
compression middleware is applied by proxy configuration, not headers:
|
||||||
|
|
||||||
|
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
|
||||||
|
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||||
|
- **Traefik** — route these paths through a dedicated router **without** the `compress`
|
||||||
|
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
|
||||||
|
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
|
||||||
|
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||||
|
|
||||||
## Migration from Docmost
|
## Migration from Docmost
|
||||||
|
|
||||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
||||||
|
|||||||
@@ -126,6 +126,32 @@ Gitmost повторяет процесс установки upstream-Docmost.
|
|||||||
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
||||||
|
|
||||||
|
### Reverse proxy: SSE-стриминговые пути
|
||||||
|
|
||||||
|
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
|
||||||
|
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
|
||||||
|
прокси перед приложением:
|
||||||
|
|
||||||
|
- `POST /api/ai-chat/stream` — живой стрим хода агента
|
||||||
|
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
|
||||||
|
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||||
|
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
|
||||||
|
|
||||||
|
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
|
||||||
|
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
|
||||||
|
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
|
||||||
|
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
|
||||||
|
|
||||||
|
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
|
||||||
|
compression-мидлвари управляются конфигом прокси, а не заголовками:
|
||||||
|
|
||||||
|
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
|
||||||
|
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||||
|
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
|
||||||
|
(compress буферизует SSE-кадры до закрытия ответа), например
|
||||||
|
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
|
||||||
|
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||||
|
|
||||||
## Миграция с Docmost
|
## Миграция с Docmost
|
||||||
|
|
||||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ roles:
|
|||||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||||
- [Minor] — an optional improvement to framing or flow.
|
- [Minor] — an optional improvement to framing or flow.
|
||||||
|
|
||||||
|
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context.
|
||||||
|
|
||||||
TONE
|
TONE
|
||||||
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ roles:
|
|||||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity:
|
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||||
- [Minor] — a stylistic improvement to taste.
|
- [Minor] — a stylistic improvement to taste.
|
||||||
@@ -126,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -166,14 +168,17 @@ roles:
|
|||||||
- Don't verify facts — that's the Fact-checker.
|
- Don't verify facts — that's the Fact-checker.
|
||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
|
HOW TO WORK
|
||||||
|
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||||
- [Minor] — optional polish.
|
- [Minor] — optional polish.
|
||||||
|
|
||||||
TONE
|
TONE
|
||||||
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments.
|
To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
|
||||||
|
|
||||||
WHEN UNSURE
|
WHEN UNSURE
|
||||||
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
||||||
@@ -272,7 +277,7 @@ roles:
|
|||||||
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
||||||
|
|
||||||
═══ HOW TO LEAVE NOTES ═══
|
═══ HOW TO LEAVE NOTES ═══
|
||||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing.
|
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
|
||||||
|
|
||||||
═══ TONE ═══
|
═══ TONE ═══
|
||||||
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ roles:
|
|||||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||||
|
|
||||||
|
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
|
||||||
|
|
||||||
ТОН
|
ТОН
|
||||||
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ roles:
|
|||||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||||
- [Критично] — предложение непонятно или искажает смысл.
|
- [Критично] — предложение непонятно или искажает смысл.
|
||||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||||
- [Незначительно] — стилистическое улучшение на вкус.
|
- [Незначительно] — стилистическое улучшение на вкус.
|
||||||
@@ -126,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -167,14 +169,17 @@ roles:
|
|||||||
- Не проверяешь достоверность фактов — это фактчекер.
|
- Не проверяешь достоверность фактов — это фактчекер.
|
||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
|
КАК РАБОТАТЬ
|
||||||
|
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||||
- [Незначительно] — необязательная шлифовка.
|
- [Незначительно] — необязательная шлифовка.
|
||||||
|
|
||||||
ТОН
|
ТОН
|
||||||
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев.
|
По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
|
||||||
|
|
||||||
ПРИ НЕУВЕРЕННОСТИ
|
ПРИ НЕУВЕРЕННОСТИ
|
||||||
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
||||||
@@ -273,7 +278,7 @@ roles:
|
|||||||
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
||||||
|
|
||||||
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
||||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
|
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||||
|
|
||||||
═══ ТОН ═══
|
═══ ТОН ═══
|
||||||
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ roles:
|
|||||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
may prove useful even if the user did not ask about them directly.
|
may prove useful even if the user did not ask about them directly.
|
||||||
- Set a "research budget" — roughly how many searches the task's complexity
|
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||||
|
full: it defines the volume of the research, so keep searching until it is
|
||||||
|
used up. If the user gave no number, estimate one yourself from the task's
|
||||||
|
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||||
|
more).
|
||||||
- Decide which languages it makes sense to search in (see below).
|
- Decide which languages it makes sense to search in (see below).
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
@@ -33,8 +37,17 @@ roles:
|
|||||||
- If the user explicitly asks to work in the current/already-open document,
|
- If the user explicitly asks to work in the current/already-open document,
|
||||||
work in it.
|
work in it.
|
||||||
- If this is not specified, create a NEW document for the report.
|
- If this is not specified, create a NEW document for the report.
|
||||||
- Keep a working draft in the document or in notes: fact → source →
|
- Create this document at the VERY START — right after the plan (STEP 0) and
|
||||||
reliability assessment. Update the structure as you go.
|
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||||
|
and a skeleton of the sections you expect to fill.
|
||||||
|
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||||
|
write it in straight away (fact → source → reliability assessment) and
|
||||||
|
grow or reshape the structure as your understanding evolves.
|
||||||
|
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||||
|
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||||
|
from the first minute and be updated continuously throughout the run, so
|
||||||
|
that by the finalization stage it is already almost complete and only
|
||||||
|
needs cleanup, ordering, and self-verification.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WORK LOOP (repeat until saturation)
|
WORK LOOP (repeat until saturation)
|
||||||
@@ -53,9 +66,19 @@ roles:
|
|||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||||
Do not stop at the first plausible answer. Stop only when further searches
|
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||||
stop yielding new relevant information (saturation / diminishing returns) —
|
only when further searches stop yielding new relevant information
|
||||||
not when it "seems like enough" or when you get tired.
|
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||||
|
you get tired.
|
||||||
|
|
||||||
|
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||||
|
reach: spend it in full even past the point where the topic already feels
|
||||||
|
covered. Do not treat apparent saturation as permission to stop early —
|
||||||
|
instead put the remaining searches to real use: broaden the scope, go
|
||||||
|
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||||
|
facts from independent angles. Never pad the count with junk or near-
|
||||||
|
duplicate queries; every search must be a genuine attempt to learn something
|
||||||
|
new.
|
||||||
|
|
||||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ roles:
|
|||||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
may prove useful even if the user did not ask about them directly.
|
may prove useful even if the user did not ask about them directly.
|
||||||
- Set a "research budget" — roughly how many searches the task's complexity
|
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||||
|
full: it defines the volume of the research, so keep searching until it is
|
||||||
|
used up. If the user gave no number, estimate one yourself from the task's
|
||||||
|
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||||
|
more).
|
||||||
- Decide which languages it makes sense to search in (see below).
|
- Decide which languages it makes sense to search in (see below).
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
@@ -33,8 +37,17 @@ roles:
|
|||||||
- If the user explicitly asks to work in the current/already-open document,
|
- If the user explicitly asks to work in the current/already-open document,
|
||||||
work in it.
|
work in it.
|
||||||
- If this is not specified, create a NEW document for the report.
|
- If this is not specified, create a NEW document for the report.
|
||||||
- Keep a working draft in the document or in notes: fact → source →
|
- Create this document at the VERY START — right after the plan (STEP 0) and
|
||||||
reliability assessment. Update the structure as you go.
|
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||||
|
and a skeleton of the sections you expect to fill.
|
||||||
|
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||||
|
write it in straight away (fact → source → reliability assessment) and
|
||||||
|
grow or reshape the structure as your understanding evolves.
|
||||||
|
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||||
|
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||||
|
from the first minute and be updated continuously throughout the run, so
|
||||||
|
that by the finalization stage it is already almost complete and only
|
||||||
|
needs cleanup, ordering, and self-verification.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WORK LOOP (repeat until saturation)
|
WORK LOOP (repeat until saturation)
|
||||||
@@ -53,9 +66,19 @@ roles:
|
|||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||||
Do not stop at the first plausible answer. Stop only when further searches
|
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||||
stop yielding new relevant information (saturation / diminishing returns) —
|
only when further searches stop yielding new relevant information
|
||||||
not when it "seems like enough" or when you get tired.
|
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||||
|
you get tired.
|
||||||
|
|
||||||
|
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||||
|
reach: spend it in full even past the point where the topic already feels
|
||||||
|
covered. Do not treat apparent saturation as permission to stop early —
|
||||||
|
instead put the remaining searches to real use: broaden the scope, go
|
||||||
|
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||||
|
facts from independent angles. Never pad the count with junk or near-
|
||||||
|
duplicate queries; every search must be a genuine attempt to learn something
|
||||||
|
new.
|
||||||
|
|
||||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: structural-editor
|
- slug: structural-editor
|
||||||
version: 2
|
version: 4
|
||||||
- slug: line-editor
|
- slug: line-editor
|
||||||
version: 2
|
version: 4
|
||||||
- slug: fact-checker
|
- slug: fact-checker
|
||||||
version: 3
|
version: 6
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 3
|
version: 8
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 1
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
name:
|
name:
|
||||||
ru: Исследование
|
ru: Исследование
|
||||||
@@ -33,4 +33,4 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: researcher
|
- slug: researcher
|
||||||
version: 1
|
version: 3
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 3,
|
"version": 6,
|
||||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 2,
|
"version": 4,
|
||||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||||
},
|
},
|
||||||
"narrator": {
|
"narrator": {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 3,
|
"version": 8,
|
||||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 3,
|
||||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
"hash": "8d6b578e86e3be766bbedb7e753ba8dd72bb131608fa17f1a2ec3969e66f9bdc"
|
||||||
},
|
},
|
||||||
"structural-editor": {
|
"structural-editor": {
|
||||||
"version": 2,
|
"version": 4,
|
||||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "1.0.7",
|
"react-drawio": "1.0.7",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.1.1",
|
||||||
"react-helmet-async": "3.0.0",
|
"react-helmet-async": "3.0.0",
|
||||||
"react-i18next": "16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.0.1",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
|
||||||
|
* /perf/ai-chat-perf.html; never part of the production build, which uses the
|
||||||
|
* single default index.html entry).
|
||||||
|
*
|
||||||
|
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
|
||||||
|
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
|
||||||
|
* React mounts so ChatThread's DefaultChatTransport requests to
|
||||||
|
* /api/ai-chat/stream are answered by the synthetic SSE generator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { mantineCssResolver, theme } from "../src/theme.ts";
|
||||||
|
// i18n side-effect init (http-backend). Translations load from /locales in dev;
|
||||||
|
// missing keys fall back to the key text, which is fine for the harness.
|
||||||
|
import "../src/i18n.ts";
|
||||||
|
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
|
||||||
|
import PerfHarness from "./harness.tsx";
|
||||||
|
|
||||||
|
// MUST run before React mounts: ChatThread creates its transport with the
|
||||||
|
// global fetch, so the patch has to be in place before the first send.
|
||||||
|
installAiChatStreamFetchPatch();
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
|
|
||||||
|
ReactDOM.createRoot(container).render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PerfHarness />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI chat perf harness</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ai-chat-perf-main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY perf harness UI for the AI chat feature.
|
||||||
|
*
|
||||||
|
* Left panel: controls + live stats. Right side: a bordered box (~real chat
|
||||||
|
* window size) hosting the REAL ChatThread component.
|
||||||
|
*
|
||||||
|
* Scenario A "Open existing chat": mount ChatThread seeded with a large
|
||||||
|
* persisted transcript and measure click -> post-mount-paint time.
|
||||||
|
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
|
||||||
|
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
|
||||||
|
* through the real useChat pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties, MutableRefObject } from "react";
|
||||||
|
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
|
||||||
|
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
PRESETS,
|
||||||
|
buildPersistedRows,
|
||||||
|
buildTurnScript,
|
||||||
|
setLiveStreamSettings,
|
||||||
|
type PresetKey,
|
||||||
|
} from "./synthetic-turn.ts";
|
||||||
|
|
||||||
|
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
|
||||||
|
const AUTO_SEND_TIMEOUT_MS = 1000;
|
||||||
|
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
|
||||||
|
const STATS_FLUSH_MS = 500;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PerfStats {
|
||||||
|
longtaskCount: number;
|
||||||
|
longtaskTotalMs: number;
|
||||||
|
longtaskMaxMs: number;
|
||||||
|
fps: number;
|
||||||
|
sseChunks: number;
|
||||||
|
sseChars: number;
|
||||||
|
mountAMs: number | null;
|
||||||
|
streamState: "idle" | "streaming" | "done" | "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStats(): PerfStats {
|
||||||
|
return {
|
||||||
|
longtaskCount: 0,
|
||||||
|
longtaskTotalMs: 0,
|
||||||
|
longtaskMaxMs: 0,
|
||||||
|
fps: 0,
|
||||||
|
sseChunks: 0,
|
||||||
|
sseChars: 0,
|
||||||
|
mountAMs: null,
|
||||||
|
streamState: "idle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
|
||||||
|
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
|
||||||
|
* re-renders only this panel — NOT the ChatThread under measurement.
|
||||||
|
*/
|
||||||
|
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
|
||||||
|
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
|
||||||
|
|
||||||
|
// Long tasks (main-thread blocks > 50ms).
|
||||||
|
useEffect(() => {
|
||||||
|
let observer: PerformanceObserver | null = null;
|
||||||
|
try {
|
||||||
|
observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
stats.current.longtaskCount += 1;
|
||||||
|
stats.current.longtaskTotalMs += entry.duration;
|
||||||
|
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe({ type: "longtask", buffered: true });
|
||||||
|
} catch {
|
||||||
|
// longtask entries unsupported in this browser — panel shows zeros.
|
||||||
|
}
|
||||||
|
return () => observer?.disconnect();
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
// FPS: frames rendered within the trailing 1s window.
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
const frames: number[] = [];
|
||||||
|
const loop = (now: number) => {
|
||||||
|
frames.push(now);
|
||||||
|
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
|
||||||
|
stats.current.fps = frames.length;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
// Flush the mutable stats into the display at most 2x/s.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const resetLongtasks = () => {
|
||||||
|
stats.current.longtaskCount = 0;
|
||||||
|
stats.current.longtaskTotalMs = 0;
|
||||||
|
stats.current.longtaskMaxMs = 0;
|
||||||
|
setSnapshot({ ...stats.current });
|
||||||
|
};
|
||||||
|
|
||||||
|
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
|
||||||
|
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
|
||||||
|
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
|
||||||
|
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
|
||||||
|
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
|
||||||
|
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
|
||||||
|
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
|
||||||
|
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
|
||||||
|
<div style={row}>
|
||||||
|
<span>Mount A</span>
|
||||||
|
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
|
||||||
|
Reset long tasks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the composer textarea via the native value setter + an `input` event
|
||||||
|
* (React 18 controlled-input pattern), then click the enabled "Send" button.
|
||||||
|
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
|
||||||
|
*/
|
||||||
|
function autoSend(host: HTMLElement, text: string): void {
|
||||||
|
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const tryClick = () => {
|
||||||
|
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
|
||||||
|
if (button && !button.disabled) {
|
||||||
|
button.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (performance.now() < deadline) requestAnimationFrame(tryClick);
|
||||||
|
else console.error("[perf] auto-send: Send button never became clickable");
|
||||||
|
};
|
||||||
|
|
||||||
|
const trySetValue = () => {
|
||||||
|
const textarea = host.querySelector("textarea");
|
||||||
|
if (!textarea) {
|
||||||
|
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
|
||||||
|
else console.error("[perf] auto-send: textarea not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
setter?.call(textarea, text);
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
// Click on a later frame so React commits the controlled value (which
|
||||||
|
// enables the Send button) before we press it.
|
||||||
|
requestAnimationFrame(tryClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(trySetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MountState {
|
||||||
|
mode: "A" | "B";
|
||||||
|
key: number;
|
||||||
|
chatId: string | null;
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = (): void => {};
|
||||||
|
|
||||||
|
export default function PerfHarness() {
|
||||||
|
const [preset, setPreset] = useState<PresetKey>("20k");
|
||||||
|
const [intervalMs, setIntervalMs] = useState<number>(15);
|
||||||
|
const [mounted, setMounted] = useState<MountState | null>(null);
|
||||||
|
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const statsRef = useRef<PerfStats>(emptyStats());
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const keyCounterRef = useRef(0);
|
||||||
|
const mountStartRef = useRef(0);
|
||||||
|
const pendingMountMeasureRef = useRef(false);
|
||||||
|
|
||||||
|
// The scripted live turn for the current preset (reused across B runs; the
|
||||||
|
// script is immutable data, so rebuilding per run is unnecessary).
|
||||||
|
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
|
||||||
|
|
||||||
|
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
|
||||||
|
|
||||||
|
// Scenario A: mount ChatThread seeded with a large persisted transcript.
|
||||||
|
const handleMountA = () => {
|
||||||
|
const fixture = buildPersistedRows(PRESETS[preset]);
|
||||||
|
setFixtureInfo(
|
||||||
|
`Persisted fixture: ${fixture.rows.length} rows, ` +
|
||||||
|
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
|
||||||
|
);
|
||||||
|
statsRef.current.mountAMs = null;
|
||||||
|
// Mark AFTER fixture generation: we measure mount cost, not generation cost
|
||||||
|
// (production receives its rows from the network).
|
||||||
|
performance.mark("perf:mountA:start");
|
||||||
|
mountStartRef.current = performance.now();
|
||||||
|
pendingMountMeasureRef.current = true;
|
||||||
|
keyCounterRef.current += 1;
|
||||||
|
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measure scenario A: effect runs after the mount commit; double rAF lands
|
||||||
|
// after the first paint of the mounted transcript.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingMountMeasureRef.current) return;
|
||||||
|
pendingMountMeasureRef.current = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
|
||||||
|
performance.mark("perf:mountA:end");
|
||||||
|
try {
|
||||||
|
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
|
||||||
|
} catch {
|
||||||
|
// Marks cleared mid-run — ignore.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
|
||||||
|
const handleStartB = () => {
|
||||||
|
statsRef.current.sseChunks = 0;
|
||||||
|
statsRef.current.sseChars = 0;
|
||||||
|
statsRef.current.streamState = "streaming";
|
||||||
|
setLiveStreamSettings({
|
||||||
|
script: liveScript,
|
||||||
|
chunkIntervalMs: intervalMs,
|
||||||
|
onProgress: (chunks, chars) => {
|
||||||
|
statsRef.current.sseChunks = chunks;
|
||||||
|
statsRef.current.sseChars = chars;
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
statsRef.current.streamState = "done";
|
||||||
|
performance.mark("perf:streamB:end");
|
||||||
|
try {
|
||||||
|
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
|
||||||
|
} catch {
|
||||||
|
// Start mark missing (e.g. marks cleared) — ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAbort: () => {
|
||||||
|
statsRef.current.streamState = "aborted";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
performance.mark("perf:streamB:start");
|
||||||
|
keyCounterRef.current += 1;
|
||||||
|
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
|
||||||
|
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnmount = () => setMounted(null);
|
||||||
|
|
||||||
|
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
|
||||||
|
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* Left: controls + stats */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
flex: "0 0 260px",
|
||||||
|
padding: 12,
|
||||||
|
borderRight: "1px solid #ccc",
|
||||||
|
overflowY: "auto",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
|
||||||
|
|
||||||
|
<label style={label}>Preset</label>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(e.target.value as PresetKey)}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value="5k">5k tokens</option>
|
||||||
|
<option value="20k">20k tokens</option>
|
||||||
|
<option value="50k">50k tokens</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label style={label}>Chunk interval (scenario B)</label>
|
||||||
|
<select
|
||||||
|
value={intervalMs}
|
||||||
|
onChange={(e) => setIntervalMs(Number(e.target.value))}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value={15}>15 ms (normal)</option>
|
||||||
|
<option value={5}>5 ms (stress)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button type="button" style={button} onClick={handleMountA}>
|
||||||
|
Mount persisted chat (A)
|
||||||
|
</button>
|
||||||
|
<button type="button" style={button} onClick={handleStartB}>
|
||||||
|
Start live stream (B)
|
||||||
|
</button>
|
||||||
|
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
|
||||||
|
Unmount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
|
||||||
|
<div>
|
||||||
|
Live turn: {liveScript.totalChars.toLocaleString()} chars ≈{" "}
|
||||||
|
{liveScript.approxTokens.toLocaleString()} tokens
|
||||||
|
</div>
|
||||||
|
{fixtureInfo && <div>{fixtureInfo}</div>}
|
||||||
|
{mounted && (
|
||||||
|
<div>
|
||||||
|
Mounted: scenario {mounted.mode} (key {mounted.key})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
|
||||||
|
<StatsPanel stats={statsRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: the real ChatThread inside a real-window-sized box */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f4f4f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
style={{
|
||||||
|
width: 540,
|
||||||
|
height: 680,
|
||||||
|
border: "1px solid #bbb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
padding: 8,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mounted ? (
|
||||||
|
<ChatThread
|
||||||
|
key={mounted.key}
|
||||||
|
chatId={mounted.chatId}
|
||||||
|
threadKey={`perf-${mounted.key}`}
|
||||||
|
initialRows={mounted.rows}
|
||||||
|
openPage={openPage}
|
||||||
|
roleId={null}
|
||||||
|
roles={[]}
|
||||||
|
onRolePicked={noop}
|
||||||
|
assistantName="Perf agent"
|
||||||
|
onTurnFinished={noop}
|
||||||
|
onServerChatId={noop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
|
||||||
|
ChatThread unmounted. Use the controls on the left.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
|
||||||
|
*
|
||||||
|
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
|
||||||
|
* from a size config, and materializes it two ways:
|
||||||
|
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
|
||||||
|
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
|
||||||
|
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
|
||||||
|
*
|
||||||
|
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
|
||||||
|
* (strict objects — only the exact field names below are accepted).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config / presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 1 token ~= 4 chars — the approximation used throughout this module. */
|
||||||
|
const CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
export interface TurnConfig {
|
||||||
|
/** Number of agent steps; each step = one reasoning block + one tool call. */
|
||||||
|
steps: number;
|
||||||
|
/** Approximate reasoning tokens generated per step. */
|
||||||
|
reasoningTokensPerStep: number;
|
||||||
|
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
|
||||||
|
toolOutputBytes: number;
|
||||||
|
/** Approximate size of the final markdown answer, in tokens. */
|
||||||
|
answerTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PresetKey = "5k" | "20k" | "50k";
|
||||||
|
|
||||||
|
export const PRESETS: Record<PresetKey, TurnConfig> = {
|
||||||
|
"5k": {
|
||||||
|
steps: 3,
|
||||||
|
reasoningTokensPerStep: 500,
|
||||||
|
toolOutputBytes: 10_000,
|
||||||
|
answerTokens: 600,
|
||||||
|
},
|
||||||
|
"20k": {
|
||||||
|
steps: 6,
|
||||||
|
reasoningTokensPerStep: 2500,
|
||||||
|
toolOutputBytes: 20_000,
|
||||||
|
answerTokens: 1500,
|
||||||
|
},
|
||||||
|
"50k": {
|
||||||
|
steps: 10,
|
||||||
|
reasoningTokensPerStep: 4000,
|
||||||
|
toolOutputBytes: 40_000,
|
||||||
|
answerTokens: 3000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text generators
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
|
||||||
|
const REASONING_SENTENCES = [
|
||||||
|
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
|
||||||
|
"First I need to inspect the current page content to understand its overall structure.",
|
||||||
|
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
|
||||||
|
"The table in section three contains the migration matrix that I should cross-check against the summary.",
|
||||||
|
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
|
||||||
|
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
|
||||||
|
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
|
||||||
|
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
|
||||||
|
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
|
||||||
|
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build realistic prose of ~`targetChars` characters, inserting a newline
|
||||||
|
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
|
||||||
|
*/
|
||||||
|
function makeProse(targetChars: number): string {
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let length = 0;
|
||||||
|
let sinceNewline = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (length < targetChars) {
|
||||||
|
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
|
||||||
|
i += 1;
|
||||||
|
pieces.push(sentence);
|
||||||
|
length += sentence.length + 1;
|
||||||
|
sinceNewline += sentence.length + 1;
|
||||||
|
if (sinceNewline >= 200) {
|
||||||
|
pieces.push("\n");
|
||||||
|
sinceNewline = 0;
|
||||||
|
} else {
|
||||||
|
pieces.push(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces.join("").trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
|
||||||
|
function markdownSection(n: number): string {
|
||||||
|
return [
|
||||||
|
`## Section ${n}: migration analysis`,
|
||||||
|
``,
|
||||||
|
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
|
||||||
|
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
|
||||||
|
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
|
||||||
|
``,
|
||||||
|
`- Update the fetch layer to the v6 transport`,
|
||||||
|
`- Перенести таблицы соответствия идентификаторов`,
|
||||||
|
`- Verify citation links after the move`,
|
||||||
|
`- Проверить отображение длинных ответов в узкой панели`,
|
||||||
|
``,
|
||||||
|
`| Область | Страниц | Статус | Риск |`,
|
||||||
|
`| --- | --- | --- | --- |`,
|
||||||
|
`| API reference | ${n + 4} | migrated | low |`,
|
||||||
|
`| Onboarding | ${n + 2} | in progress | medium |`,
|
||||||
|
`| Release notes | ${n * 3} | pending | high |`,
|
||||||
|
``,
|
||||||
|
"```ts",
|
||||||
|
`export function migrateSection${n}(rows: Row[]): Row[] {`,
|
||||||
|
` return rows`,
|
||||||
|
` .filter((row) => row.section === ${n})`,
|
||||||
|
` .map((row) => ({ ...row, migrated: true }));`,
|
||||||
|
`}`,
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
|
||||||
|
function makeMarkdownAnswer(targetChars: number): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
let length = 0;
|
||||||
|
let n = 1;
|
||||||
|
while (length < targetChars) {
|
||||||
|
const section = markdownSection(n);
|
||||||
|
sections.push(section);
|
||||||
|
length += section.length + 2;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return sections.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
|
||||||
|
function makeFiller(bytes: number): string {
|
||||||
|
const unit = "Perf filler content for the synthetic getPage tool output. ";
|
||||||
|
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Turn script
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TurnToolCall {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: "getPage";
|
||||||
|
input: { pageId: string };
|
||||||
|
output: { id: string; title: string; content: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnStep {
|
||||||
|
reasoningText: string;
|
||||||
|
tool: TurnToolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnScript {
|
||||||
|
steps: TurnStep[];
|
||||||
|
answerText: string;
|
||||||
|
/** Approximate reasoning tokens for the whole turn (chars / 4). */
|
||||||
|
reasoningTokens: number;
|
||||||
|
/** Approximate context size after this turn, in tokens. */
|
||||||
|
contextTokens: number;
|
||||||
|
maxContextTokens: number;
|
||||||
|
/** Actual generated visible chars: reasoning + tool outputs + answer. */
|
||||||
|
totalChars: number;
|
||||||
|
/** totalChars / 4, rounded. */
|
||||||
|
approxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
|
||||||
|
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
|
||||||
|
*/
|
||||||
|
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
|
||||||
|
const steps: TurnStep[] = [];
|
||||||
|
let reasoningChars = 0;
|
||||||
|
let toolChars = 0;
|
||||||
|
for (let i = 0; i < config.steps; i++) {
|
||||||
|
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
|
||||||
|
const content = makeFiller(config.toolOutputBytes);
|
||||||
|
reasoningChars += reasoningText.length;
|
||||||
|
toolChars += content.length;
|
||||||
|
steps.push({
|
||||||
|
reasoningText,
|
||||||
|
tool: {
|
||||||
|
toolCallId: `${idPrefix}-call-${i + 1}`,
|
||||||
|
toolName: "getPage",
|
||||||
|
input: { pageId: "page-1" },
|
||||||
|
output: { id: "page-1", title: "Perf test page", content },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
|
||||||
|
const totalChars = reasoningChars + toolChars + answerText.length;
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
answerText,
|
||||||
|
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
|
||||||
|
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
maxContextTokens: 200_000,
|
||||||
|
totalChars,
|
||||||
|
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario A: persisted rows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Number of user+assistant pairs the preset is split across for history. */
|
||||||
|
const HISTORY_TURNS = 3;
|
||||||
|
|
||||||
|
const USER_PROMPTS = [
|
||||||
|
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
|
||||||
|
"Now cross-check the migration matrix against the summary and list every mismatch.",
|
||||||
|
"Собери финальный план миграции с оценкой рисков по каждой области.",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Persisted UIMessage parts for one finished assistant turn. */
|
||||||
|
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
for (const step of script.steps) {
|
||||||
|
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
|
||||||
|
parts.push({
|
||||||
|
type: `tool-${step.tool.toolName}`,
|
||||||
|
toolCallId: step.tool.toolCallId,
|
||||||
|
state: "output-available",
|
||||||
|
input: step.tool.input,
|
||||||
|
output: step.tool.output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parts.push({ type: "text", text: script.answerText, state: "done" });
|
||||||
|
return parts as UIMessage["parts"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedFixture {
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
totalChars: number;
|
||||||
|
approxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialize the preset as a finished 3-turn transcript: user row + assistant
|
||||||
|
* row per turn, with the preset's steps/answer split across the assistant turns.
|
||||||
|
* Approximate accounting — the actual totals are reported back for display.
|
||||||
|
*/
|
||||||
|
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
|
||||||
|
const rows: IAiChatMessageRow[] = [];
|
||||||
|
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
for (let t = 0; t < HISTORY_TURNS; t++) {
|
||||||
|
// Distribute steps as evenly as possible (earlier turns get the remainder).
|
||||||
|
const stepsForTurn =
|
||||||
|
Math.floor(config.steps / HISTORY_TURNS) +
|
||||||
|
(t < config.steps % HISTORY_TURNS ? 1 : 0);
|
||||||
|
const turnConfig: TurnConfig = {
|
||||||
|
steps: Math.max(1, stepsForTurn),
|
||||||
|
reasoningTokensPerStep: config.reasoningTokensPerStep,
|
||||||
|
toolOutputBytes: config.toolOutputBytes,
|
||||||
|
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
|
||||||
|
};
|
||||||
|
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
|
||||||
|
totalChars += script.totalChars;
|
||||||
|
|
||||||
|
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
|
||||||
|
rows.push({
|
||||||
|
id: `perf-row-u${t + 1}`,
|
||||||
|
role: "user",
|
||||||
|
content: userText,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(baseTime + t * 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
rows.push({
|
||||||
|
id: `perf-row-a${t + 1}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: script.answerText,
|
||||||
|
metadata: {
|
||||||
|
parts: scriptToPersistedParts(script),
|
||||||
|
usage: { reasoningTokens: script.reasoningTokens },
|
||||||
|
contextTokens: script.contextTokens,
|
||||||
|
maxContextTokens: script.maxContextTokens,
|
||||||
|
finishReason: "stop",
|
||||||
|
},
|
||||||
|
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalChars,
|
||||||
|
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario B: SSE stream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Streaming delta size in chars (reasoning/answer text is split into these). */
|
||||||
|
const DELTA_CHARS = 200;
|
||||||
|
|
||||||
|
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
|
||||||
|
const deltas: string[] = [];
|
||||||
|
for (let i = 0; i < text.length; i += size) {
|
||||||
|
deltas.push(text.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
|
||||||
|
interface SseFrame {
|
||||||
|
data: string;
|
||||||
|
chars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
|
||||||
|
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
|
||||||
|
* (excluding the final `data: [DONE]` terminator, appended by the pump).
|
||||||
|
*/
|
||||||
|
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
|
||||||
|
const frames: SseFrame[] = [];
|
||||||
|
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
|
||||||
|
|
||||||
|
script.steps.forEach((step, i) => {
|
||||||
|
frames.push(frame({ type: "start-step" }));
|
||||||
|
const reasoningId = `${messageId}-r${i + 1}`;
|
||||||
|
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
|
||||||
|
for (const delta of splitDeltas(step.reasoningText)) {
|
||||||
|
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
|
||||||
|
}
|
||||||
|
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
|
||||||
|
|
||||||
|
const { toolCallId, toolName, input, output } = step.tool;
|
||||||
|
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
|
||||||
|
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
|
||||||
|
// The tool result arrives as ONE chunk, like the real server sends it.
|
||||||
|
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
|
||||||
|
frames.push(frame({ type: "finish-step" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final step: the markdown answer.
|
||||||
|
frames.push(frame({ type: "start-step" }));
|
||||||
|
const textId = `${messageId}-answer`;
|
||||||
|
frames.push(frame({ type: "text-start", id: textId }));
|
||||||
|
for (const delta of splitDeltas(script.answerText)) {
|
||||||
|
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
|
||||||
|
}
|
||||||
|
frames.push(frame({ type: "text-end", id: textId }));
|
||||||
|
frames.push(frame({ type: "finish-step" }));
|
||||||
|
|
||||||
|
frames.push(
|
||||||
|
frame({
|
||||||
|
type: "finish",
|
||||||
|
messageMetadata: {
|
||||||
|
usage: { reasoningTokens: script.reasoningTokens },
|
||||||
|
contextTokens: script.contextTokens,
|
||||||
|
maxContextTokens: script.maxContextTokens,
|
||||||
|
finishReason: "stop",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveStreamSettings {
|
||||||
|
script: TurnScript;
|
||||||
|
/** Delay between SSE chunks (one chunk per tick). */
|
||||||
|
chunkIntervalMs: number;
|
||||||
|
/** Progress callback: cumulative emitted chunk count and visible chars. */
|
||||||
|
onProgress?: (chunks: number, chars: number) => void;
|
||||||
|
/** Fired once after the `[DONE]` terminator is enqueued. */
|
||||||
|
onDone?: () => void;
|
||||||
|
/** Fired if the client aborted the stream (Stop button). */
|
||||||
|
onAbort?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
|
||||||
|
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
|
||||||
|
*/
|
||||||
|
export function buildSseResponse(
|
||||||
|
settings: LiveStreamSettings,
|
||||||
|
signal?: AbortSignal | null,
|
||||||
|
): Response {
|
||||||
|
const messageId = `m-live-${Date.now()}`;
|
||||||
|
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let index = 0;
|
||||||
|
let emittedChars = 0;
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const stopPump = () => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pump = () => {
|
||||||
|
timer = undefined;
|
||||||
|
if (signal?.aborted) {
|
||||||
|
stopPump();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Already closed/cancelled — nothing to do.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index >= frames.length) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Cancelled mid-flight.
|
||||||
|
}
|
||||||
|
settings.onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = frames[index];
|
||||||
|
index += 1;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(next.data));
|
||||||
|
} catch {
|
||||||
|
stopPump();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emittedChars += next.chars;
|
||||||
|
settings.onProgress?.(index, emittedChars);
|
||||||
|
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||||
|
};
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
stopPump();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Reader already cancelled.
|
||||||
|
}
|
||||||
|
settings.onAbort?.();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"x-vercel-ai-ui-message-stream": "v1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// window.fetch patch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let currentLiveSettings: LiveStreamSettings | null = null;
|
||||||
|
|
||||||
|
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
|
||||||
|
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
|
||||||
|
currentLiveSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
|
||||||
|
* get the synthetic SSE Response; everything else passes through untouched.
|
||||||
|
*/
|
||||||
|
export function installAiChatStreamFetchPatch(): void {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
if (url.includes("/api/ai-chat/stream")) {
|
||||||
|
const settings = currentLiveSettings;
|
||||||
|
if (!settings) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response("perf harness: no live stream configured", { status: 500 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1274,6 +1274,10 @@
|
|||||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||||
|
"Dictation": "Dictation",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
|
||||||
|
"This page is read-only": "This page is read-only",
|
||||||
"Request format": "Request format",
|
"Request format": "Request format",
|
||||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||||
@@ -1369,9 +1373,50 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"No bundles available": "No bundles available",
|
||||||
|
"Content": "Content",
|
||||||
|
"Content language of the roles": "Content language of the roles",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||||
|
"Update all ({{count}})": "Update all ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||||
|
"{{count}} roles": "{{count}} roles",
|
||||||
|
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||||
|
"All installed · up to date": "All installed · up to date",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||||
|
"{{count}} new": "{{count}} new",
|
||||||
|
"{{count}} installed": "{{count}} installed",
|
||||||
|
"{{count}} updates": "{{count}} updates",
|
||||||
|
"Install bundle": "Install bundle",
|
||||||
|
"Install {{count}} selected": "Install {{count}} selected",
|
||||||
|
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||||
|
"Select all": "Select all",
|
||||||
|
"Deselect all": "Deselect all",
|
||||||
|
"Skipped": "Skipped",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "{{count}} roles installed",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||||
|
"{{count}} roles updated": "{{count}} roles updated",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||||
|
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||||
|
"Rename & install": "Rename & install",
|
||||||
|
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"The catalog is empty": "The catalog is empty",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||||
"Already up to date": "Already up to date",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
||||||
"Connecting… (read-only)": "Connecting… (read-only)"
|
"Connecting… (read-only)": "Connecting… (read-only)",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Applied": "Applied",
|
||||||
|
"Suggestion applied": "Suggestion applied",
|
||||||
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,6 +393,17 @@
|
|||||||
"No speech detected": "Речь не распознана",
|
"No speech detected": "Речь не распознана",
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
"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",
|
||||||
@@ -1224,10 +1235,51 @@
|
|||||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||||
"Please try again later.": "Попробуйте позже.",
|
"Please try again later.": "Попробуйте позже.",
|
||||||
"No bundles available": "Наборы недоступны",
|
"No bundles available": "Наборы недоступны",
|
||||||
|
"Content": "Язык контента",
|
||||||
|
"Content language of the roles": "Язык контента ролей",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||||
|
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||||
|
"{{count}} roles": "ролей: {{count}}",
|
||||||
|
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||||
|
"All installed · up to date": "Все установлены · актуальны",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||||
|
"{{count}} new": "новых: {{count}}",
|
||||||
|
"{{count}} installed": "установлено: {{count}}",
|
||||||
|
"{{count}} updates": "обновлений: {{count}}",
|
||||||
|
"Install bundle": "Установить набор",
|
||||||
|
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||||
|
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"Deselect all": "Снять выбор",
|
||||||
|
"Skipped": "Пропущено",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||||
|
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||||
|
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||||
|
"Rename & install": "Переименовать и установить",
|
||||||
|
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"The catalog is empty": "Каталог пуст",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
"Connecting… (read-only)": "Подключение… (только чтение)",
|
||||||
|
"Apply": "Применить",
|
||||||
|
"Applied": "Применено",
|
||||||
|
"Suggestion applied": "Предложение применено",
|
||||||
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
|
"Dismiss": "Не применять",
|
||||||
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { Provider, createStore } from "jotai";
|
import { Provider, createStore } from "jotai";
|
||||||
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack";
|
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||||
|
import { avatarStyle } from "@/lib/avatar-palette";
|
||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
@@ -13,6 +14,18 @@ import {
|
|||||||
|
|
||||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
||||||
|
|
||||||
|
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
|
||||||
|
// expected color through the same CSSOM path so the comparison stays exact and
|
||||||
|
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
|
||||||
|
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
|
||||||
|
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
|
||||||
|
// glyph carries an explicit solid `background-color` we assert on here.
|
||||||
|
function normalizeColor(value: string): string {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.style.backgroundColor = value;
|
||||||
|
return probe.style.backgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStack(props: Props) {
|
function renderStack(props: Props) {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||||
@@ -26,25 +39,8 @@ function renderStack(props: Props) {
|
|||||||
return { store, ...utils };
|
return { store, ...utils };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("agentGlyphBackground", () => {
|
|
||||||
it("is deterministic for a given agent name", () => {
|
|
||||||
expect(agentGlyphBackground("Researcher")).toBe(
|
|
||||||
agentGlyphBackground("Researcher"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("differs by name and stays a fixed dark shade (readable emoji)", () => {
|
|
||||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
|
||||||
agentGlyphBackground("Нарратор"),
|
|
||||||
);
|
|
||||||
// Only the hue varies; saturation/lightness are pinned low so the glyph is
|
|
||||||
// always a dark circle.
|
|
||||||
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, 45%, 24%\)$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AgentAvatarStack", () => {
|
describe("AgentAvatarStack", () => {
|
||||||
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
|
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
|
||||||
const { container } = renderStack({
|
const { container } = renderStack({
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
launcher: { name: "Alice", avatarUrl: null },
|
||||||
@@ -60,6 +56,57 @@ describe("AgentAvatarStack", () => {
|
|||||||
expect(screen.getByText("Alice")).toBeDefined();
|
expect(screen.getByText("Alice")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
|
||||||
|
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
|
||||||
|
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
|
||||||
|
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
|
||||||
|
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
|
||||||
|
const { container } = renderStack({
|
||||||
|
agent,
|
||||||
|
launcher: { name: "Alice", avatarUrl: null },
|
||||||
|
aiChatId: "chat-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const glyph = container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
expect(glyph).not.toBeNull();
|
||||||
|
const expected = normalizeColor(avatarStyle(agent.name).bg);
|
||||||
|
// Non-vacuous: the pre-fix Avatar set no inline background at all.
|
||||||
|
expect(expected).not.toBe("");
|
||||||
|
expect(glyph!.style.backgroundColor).toBe(expected);
|
||||||
|
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
|
||||||
|
// not round-trip linear-gradient — so its stops/angle are covered by the
|
||||||
|
// avatarStyle unit tests above, not asserted on the DOM here.)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
|
||||||
|
// "Researcher" and "Нарратор" hash to different palette entries, so their
|
||||||
|
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
|
||||||
|
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
|
||||||
|
|
||||||
|
const a = renderStack({
|
||||||
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
|
launcher: null,
|
||||||
|
aiChatId: null,
|
||||||
|
});
|
||||||
|
const b = renderStack({
|
||||||
|
agent: { name: "Нарратор", emoji: "📖", avatarUrl: null },
|
||||||
|
launcher: null,
|
||||||
|
aiChatId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const glyphA = a.container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
const glyphB = b.container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
expect(glyphA!.style.backgroundColor).not.toBe("");
|
||||||
|
// Different base colors reach the DOM (the serialized rgb values differ).
|
||||||
|
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
|
||||||
|
});
|
||||||
|
|
||||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
it("showName=false: renders only the avatars, no inline name label", () => {
|
||||||
renderStack({
|
renderStack({
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
@@ -91,7 +138,7 @@ describe("AgentAvatarStack", () => {
|
|||||||
expect(screen.getByText("Bob")).toBeDefined();
|
expect(screen.getByText("Bob")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("external MCP: agent avatar in front, NO launcher behind", () => {
|
it("external MCP: agent avatar only, NO human launcher badge", () => {
|
||||||
const { container } = renderStack({
|
const { container } = renderStack({
|
||||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||||
launcher: null,
|
launcher: null,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
|
import { Box, Group, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
|
||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
@@ -25,37 +26,15 @@ export interface LauncherInfo {
|
|||||||
|
|
||||||
const GLYPH_SIZE = 38;
|
const GLYPH_SIZE = 38;
|
||||||
const LAUNCHER_SIZE = 22;
|
const LAUNCHER_SIZE = 22;
|
||||||
// How far the launcher avatar sticks out past the agent's top-right corner, so
|
// How far the launcher avatar sticks out past the agent's top-right corner — it
|
||||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
// sits as a small badge over that corner (above the glyph) and stays fully visible.
|
||||||
const LAUNCHER_OVERHANG = 8;
|
const LAUNCHER_OVERHANG = 8;
|
||||||
|
|
||||||
// Small deterministic string hash (same algorithm as custom-avatar's initials
|
|
||||||
// hash) used to pick a stable per-agent glyph color.
|
|
||||||
function hashName(input: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
|
||||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic DARK background for an emoji/sparkles agent glyph. The hue is
|
|
||||||
* derived from the agent-name hash so distinct agents get distinct circles;
|
|
||||||
* saturation and lightness are pinned low ("shifted into darkness") so a bright
|
|
||||||
* emoji or the white sparkles icon stays legible on top (#300).
|
|
||||||
*/
|
|
||||||
export function agentGlyphBackground(name: string): string {
|
|
||||||
const hue = hashName(name) % 360;
|
|
||||||
return `hsl(${hue}, 45%, 24%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The front avatar. Image-source priority (#300):
|
* The front avatar. Image-source priority (#300):
|
||||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||||
* 2. agent.emoji -> the role emoji on a violet circle.
|
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
|
||||||
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
|
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
|
||||||
*/
|
*/
|
||||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||||
if (agent.avatarUrl) {
|
if (agent.avatarUrl) {
|
||||||
@@ -68,29 +47,42 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emoji/sparkles glyphs sit on a per-agent dark circle (hashed from the agent
|
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
|
||||||
// name) so different agents are visually distinct, while the dark background
|
// and split angle all hashed from the agent name via avatarStyle — see
|
||||||
// keeps the emoji / white sparkles icon readable.
|
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
|
||||||
const bg = agentGlyphBackground(agent.name);
|
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
|
||||||
const glyphStyles = {
|
// (every agent fell back to the theme's violet). The foreground (the sparkles
|
||||||
root: { background: bg },
|
// icon) uses the ring's WCAG-checked readable text color.
|
||||||
placeholder: { background: bg, color: "var(--mantine-color-white)" },
|
const style = avatarStyle(agent.name);
|
||||||
};
|
|
||||||
|
|
||||||
if (agent.emoji) {
|
|
||||||
return (
|
return (
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
<Box
|
||||||
|
data-testid="agent-glyph"
|
||||||
|
style={{
|
||||||
|
width: GLYPH_SIZE,
|
||||||
|
height: GLYPH_SIZE,
|
||||||
|
borderRadius: "50%",
|
||||||
|
// Solid base color is the fallback (and the testable value); the gradient
|
||||||
|
// paints over it in browsers that support it.
|
||||||
|
backgroundColor: style.bg,
|
||||||
|
backgroundImage: avatarBackgroundCss(style),
|
||||||
|
color:
|
||||||
|
style.text === "white"
|
||||||
|
? "var(--mantine-color-white)"
|
||||||
|
: "var(--mantine-color-black)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.emoji ? (
|
||||||
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
||||||
{agent.emoji}
|
{agent.emoji}
|
||||||
</span>
|
</span>
|
||||||
</Avatar>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
|
||||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
||||||
</Avatar>
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +102,10 @@ export interface AgentAvatarStackProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
|
* The "agent avatar stack" (#300): the AGENT glyph, and — for an internal AI
|
||||||
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
|
* chat — the HUMAN who launched it as a smaller avatar badge on top, overhanging
|
||||||
|
* the glyph's top-right corner in FRONT (zIndex 2 > the glyph's zIndex 1) so the
|
||||||
|
* launcher stays fully visible rather than being half-hidden behind the glyph.
|
||||||
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
||||||
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
||||||
* here); the click is contained (stopPropagation) so it does not also trigger an
|
* here); the click is contained (stopPropagation) so it does not also trigger an
|
||||||
@@ -185,7 +179,9 @@ export function AgentAvatarStack({
|
|||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
{launcher && (
|
{launcher && (
|
||||||
<Box pos="absolute" top={0} right={0} style={{ zIndex: 0 }}>
|
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
|
||||||
|
// it is fully visible, not half-hidden behind the agent circle.
|
||||||
|
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size={LAUNCHER_SIZE}
|
size={LAUNCHER_SIZE}
|
||||||
avatarUrl={launcher.avatarUrl}
|
avatarUrl={launcher.avatarUrl}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useLocation, useMatch } from "react-router-dom";
|
import { useLocation, useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -37,6 +37,14 @@ import {
|
|||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
readOnlyEditorAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import {
|
||||||
|
getEditorSelectionContext,
|
||||||
|
type EditorSelectionContext,
|
||||||
|
} from "@/features/editor/utils/get-editor-selection.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
@@ -45,9 +53,13 @@ import {
|
|||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import {
|
||||||
|
exportAiChat,
|
||||||
|
stopRun,
|
||||||
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -74,6 +86,12 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
|
// #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when
|
||||||
|
// a resume attempt could not attach to the live run and disarmed by the thread on
|
||||||
|
// settle / local stream; this cap is the ONLY backstop against an endless tick
|
||||||
|
// (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run).
|
||||||
|
const DEGRADED_POLL_MAX_MS = 10 * 60_000;
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
@@ -231,8 +249,61 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[roles],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
||||||
|
// ChatThread could not attach to a still-running run it arms this via
|
||||||
|
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
||||||
|
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
||||||
|
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||||
|
const armedAtRef = useRef(0);
|
||||||
|
const onResumeFallback = useCallback((active: boolean): void => {
|
||||||
|
if (active) armedAtRef.current = Date.now();
|
||||||
|
setDegradedPoll(active);
|
||||||
|
}, []);
|
||||||
|
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||||
|
// resume attempt of the previously-open chat (invariant 8).
|
||||||
|
useEffect(() => {
|
||||||
|
setDegradedPoll(false);
|
||||||
|
}, [activeChatId]);
|
||||||
|
|
||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(
|
||||||
|
activeChatId ?? undefined,
|
||||||
|
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
||||||
|
// and under the 10-min cap; otherwise off. NO error checks (TanStack v5
|
||||||
|
// resets fetchFailureCount each fetch, so consecutive errors are not
|
||||||
|
// expressible — and the poll must survive a server restart) and NO tail
|
||||||
|
// checks (the settled/local-stream semantics live in ChatThread, which
|
||||||
|
// disarms via onResumeFallback(false)). The time cap is the only backstop.
|
||||||
|
() =>
|
||||||
|
degradedPoll === true &&
|
||||||
|
Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS
|
||||||
|
? 2500
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
|
// this workspace. When the feature is off no runs are ever created, so the
|
||||||
|
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||||
|
// pointless attach round-trip.
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const autonomousRunsEnabled =
|
||||||
|
workspace?.settings?.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
|
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||||
|
// autonomous mode). Request the server stop — the ONLY thing that ends a
|
||||||
|
// detached run; a mere local SSE abort is a client disconnect the server
|
||||||
|
// ignores. On failure surface the error.
|
||||||
|
const handleServerStop = useCallback(
|
||||||
|
(chatId: string): void => {
|
||||||
|
void stopRun(chatId).catch(() => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to stop the run"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
@@ -251,6 +322,27 @@ export default function AiChatWindow() {
|
|||||||
? { id: openPageData.id, title: openPageData.title }
|
? { id: openPageData.id, title: openPageData.title }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Live editor handles for the selection snapshot (#388). Both are published by
|
||||||
|
// the page editor; the read-only editor is used in read mode. Reading the
|
||||||
|
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
|
||||||
|
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
|
||||||
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||||
|
|
||||||
|
// Snapshot the user's current editor selection at send time. Edit-mode editor
|
||||||
|
// wins; the read-only editor is the fallback (read mode). Null when neither
|
||||||
|
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
|
||||||
|
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
|
||||||
|
// snapshot and multi-turn works without recreating the transport.
|
||||||
|
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
|
||||||
|
for (const editor of [pageEditor, readOnlyEditor]) {
|
||||||
|
if (!editor || editor.isDestroyed) continue;
|
||||||
|
const sel = getEditorSelectionContext(editor.state);
|
||||||
|
if (sel) return sel;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [pageEditor, readOnlyEditor]);
|
||||||
|
|
||||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||||
@@ -873,6 +965,9 @@ export default function AiChatWindow() {
|
|||||||
chatId={activeChatId}
|
chatId={activeChatId}
|
||||||
initialRows={activeChatId ? messageRows : []}
|
initialRows={activeChatId ? messageRows : []}
|
||||||
openPage={openPage}
|
openPage={openPage}
|
||||||
|
// #388: live snapshotter for the user's editor selection, read at
|
||||||
|
// send time and nested inside openPage on the wire.
|
||||||
|
getEditorSelection={getEditorSelection}
|
||||||
// Honoured only for a new chat; null = universal assistant.
|
// Honoured only for a new chat; null = universal assistant.
|
||||||
roleId={activeChatId === null ? selectedRoleId : null}
|
roleId={activeChatId === null ? selectedRoleId : null}
|
||||||
// Role cards are the new-chat empty-state; offered only when this
|
// Role cards are the new-chat empty-state; offered only when this
|
||||||
@@ -882,6 +977,15 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
onServerChatId={onServerChatId}
|
||||||
|
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
|
||||||
|
// resume attempt could not attach to the live run; the thread
|
||||||
|
// disarms it on settle / local stream.
|
||||||
|
onResumeFallback={onResumeFallback}
|
||||||
|
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||||
|
// server stop (a local SSE abort is a client disconnect the server
|
||||||
|
// ignores).
|
||||||
|
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||||
|
onServerStop={handleServerStop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,8 +164,8 @@
|
|||||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||||
rendered markdown <div> it would turn the newlines between block tags
|
rendered markdown <div> it would turn the newlines between block tags
|
||||||
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
margins. The streaming plain-text path that needs pre-wrap sets it
|
||||||
inline itself (see reasoning-block.tsx). */
|
per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
|
||||||
}
|
}
|
||||||
|
|
||||||
.reasoningText p {
|
.reasoningText p {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||||
// above the imports) can expose the captured useChat callbacks / transport and
|
// above the imports) can expose the captured useChat callbacks / transport and
|
||||||
@@ -11,48 +18,62 @@ const h = vi.hoisted(() => ({
|
|||||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
setMessages: vi.fn(),
|
||||||
|
resumeStream: vi.fn(),
|
||||||
|
// The messages array useChat was seeded with (to assert strip/seed behavior).
|
||||||
|
seededMessages: null as null | unknown[],
|
||||||
transport: null as null | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest: (arg: {
|
prepareSendMessagesRequest?: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
}) => { body: Record<string, unknown> };
|
}) => { body: Record<string, unknown> };
|
||||||
|
prepareReconnectToStreamRequest?: () => { api?: string };
|
||||||
|
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
// Mock useChat: capture onFinish + seeded messages, return the spies and the
|
||||||
|
// controllable status.
|
||||||
vi.mock("@ai-sdk/react", () => ({
|
vi.mock("@ai-sdk/react", () => ({
|
||||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
useChat: (opts: {
|
||||||
|
messages?: unknown[];
|
||||||
|
onFinish?: (arg: Record<string, unknown>) => void;
|
||||||
|
}) => {
|
||||||
h.state.onFinish = opts.onFinish ?? null;
|
h.state.onFinish = opts.onFinish ?? null;
|
||||||
|
h.state.seededMessages = opts.messages ?? null;
|
||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
sendMessage: h.state.sendMessage,
|
sendMessage: h.state.sendMessage,
|
||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
setMessages: h.state.setMessages,
|
||||||
|
resumeStream: h.state.resumeStream,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
// can invoke prepareSendMessagesRequest / prepareReconnectToStreamRequest / fetch.
|
||||||
vi.mock("ai", () => {
|
vi.mock("ai", () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
return {
|
return {
|
||||||
generateId: () => `gid-${counter++}`,
|
generateId: () => `gid-${counter++}`,
|
||||||
DefaultChatTransport: class {
|
DefaultChatTransport: class {
|
||||||
constructor(opts: {
|
constructor(opts: Record<string, unknown>) {
|
||||||
prepareSendMessagesRequest: (arg: {
|
h.state.transport = opts as never;
|
||||||
messages: unknown[];
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
}) => { body: Record<string, unknown> };
|
|
||||||
}) {
|
|
||||||
h.state.transport = opts;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the ai-chat-query import light: ChatThread only needs the messages RQ key,
|
||||||
|
// so stub the module to avoid pulling axios / i18n transitively.
|
||||||
|
vi.mock("@/features/ai-chat/queries/ai-chat-query.ts", () => ({
|
||||||
|
AI_CHAT_MESSAGES_RQ_KEY: (chatId: string) => ["ai-chat-messages", chatId],
|
||||||
|
}));
|
||||||
|
|
||||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||||
// interaction this test needs to populate the queue while "streaming".
|
// interaction this test needs to populate the queue while "streaming".
|
||||||
@@ -60,49 +81,90 @@ vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
|||||||
default: () => <div data-testid="message-list" />,
|
default: () => <div data-testid="message-list" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
default: ({
|
||||||
|
onQueue,
|
||||||
|
onStop,
|
||||||
|
}: {
|
||||||
|
onQueue: (text: string) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||||
queue
|
queue
|
||||||
</button>
|
</button>
|
||||||
|
<button aria-label="Stop" onClick={() => onStop()}>
|
||||||
|
stop
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import ChatThread from "./chat-thread";
|
import ChatThread from "./chat-thread";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
function renderThread() {
|
function row(
|
||||||
|
id: string,
|
||||||
|
role: string,
|
||||||
|
status?: string,
|
||||||
|
text = "",
|
||||||
|
): IAiChatMessageRow {
|
||||||
|
return { id, role, content: text, status, createdAt: "2026-01-01T00:00:00Z" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThread(props?: {
|
||||||
|
chatId?: string | null;
|
||||||
|
initialRows?: IAiChatMessageRow[];
|
||||||
|
autonomousRunsEnabled?: boolean;
|
||||||
|
}) {
|
||||||
const onTurnFinished = vi.fn();
|
const onTurnFinished = vi.fn();
|
||||||
render(
|
const onResumeFallback = vi.fn();
|
||||||
|
const onServerStop = vi.fn();
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
const { unmount } = render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
<ChatThread
|
||||||
</MantineProvider>,
|
chatId={props?.chatId === undefined ? "c1" : props.chatId}
|
||||||
|
initialRows={props?.initialRows ?? []}
|
||||||
|
autonomousRunsEnabled={props?.autonomousRunsEnabled}
|
||||||
|
onTurnFinished={onTurnFinished}
|
||||||
|
onResumeFallback={onResumeFallback}
|
||||||
|
onServerStop={onServerStop}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { onTurnFinished };
|
return { onTurnFinished, onResumeFallback, onServerStop, invalidateSpy, unmount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.seededMessages = null;
|
||||||
|
h.state.transport = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
h.state.resumeStream.mockClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ChatThread — send now (#198)", () => {
|
describe("ChatThread — send now (#198)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(resetState);
|
||||||
h.state.status = "streaming";
|
|
||||||
h.state.onFinish = null;
|
|
||||||
h.state.sendMessage.mockClear();
|
|
||||||
h.state.stop.mockClear();
|
|
||||||
h.state.transport = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||||
renderThread();
|
renderThread();
|
||||||
|
|
||||||
// Queue a message while the turn is streaming.
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
const sendNowBtn = screen.getByLabelText("Send now");
|
const sendNowBtn = screen.getByLabelText("Send now");
|
||||||
expect(sendNowBtn).toBeTruthy();
|
expect(sendNowBtn).toBeTruthy();
|
||||||
|
|
||||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
|
||||||
// the resend happens once the abort lands in onFinish.
|
|
||||||
fireEvent.click(sendNowBtn);
|
fireEvent.click(sendNowBtn);
|
||||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
message: { id: "a", role: "assistant", parts: [] },
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
@@ -119,10 +181,8 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
fireEvent.click(screen.getByLabelText("Send now"));
|
||||||
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
// The send right after "send now" carries interrupted: true...
|
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||||
// ...and only that one (the flag is read-and-cleared).
|
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,10 +193,549 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
fireEvent.click(screen.getByLabelText("Send now"));
|
||||||
|
|
||||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
|
||||||
expect(h.state.stop).not.toHaveBeenCalled();
|
expect(h.state.stop).not.toHaveBeenCalled();
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #388: the editor selection is snapshotted at send time and nested inside
|
||||||
|
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||||
|
// fresh snapshot.
|
||||||
|
describe("ChatThread — editor selection wiring (#388)", () => {
|
||||||
|
beforeEach(resetState);
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderWithSelection(props: {
|
||||||
|
openPage?: { id: string; title: string } | null;
|
||||||
|
getEditorSelection?: () => unknown;
|
||||||
|
}) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MantineProvider>
|
||||||
|
<ChatThread
|
||||||
|
chatId="c1"
|
||||||
|
initialRows={[]}
|
||||||
|
openPage={props.openPage as never}
|
||||||
|
getEditorSelection={props.getEditorSelection as never}
|
||||||
|
onTurnFinished={vi.fn()}
|
||||||
|
onResumeFallback={vi.fn()}
|
||||||
|
onServerStop={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("nests the snapshot from the getter into openPage.selection at send time", () => {
|
||||||
|
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
|
||||||
|
renderWithSelection({
|
||||||
|
openPage: { id: "p1", title: "Doc" },
|
||||||
|
getEditorSelection: () => selection,
|
||||||
|
});
|
||||||
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
|
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends selection: null when the getter returns null", () => {
|
||||||
|
renderWithSelection({
|
||||||
|
openPage: { id: "p1", title: "Doc" },
|
||||||
|
getEditorSelection: () => null,
|
||||||
|
});
|
||||||
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
|
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send selection at all on a non-page route (openPage null)", () => {
|
||||||
|
const getter = vi.fn(() => ({ text: "sel" }));
|
||||||
|
renderWithSelection({ openPage: null, getEditorSelection: getter });
|
||||||
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
|
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
|
||||||
|
// The getter must not even be consulted when there is no page.
|
||||||
|
expect(getter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||||
|
beforeEach(resetState);
|
||||||
|
|
||||||
|
function finishWith(flags: {
|
||||||
|
isAbort?: boolean;
|
||||||
|
isDisconnect?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
cleanup();
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
const { onTurnFinished } = renderThread();
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
...flags,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { onTurnFinished };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
|
finishWith({});
|
||||||
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
|
finishWith({ isAbort: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||||
|
finishWith({ isDisconnect: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Connection lost — the answer was interrupted."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
|
finishWith({ isError: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
|
for (const flags of [
|
||||||
|
{},
|
||||||
|
{ isAbort: true },
|
||||||
|
{ isDisconnect: true },
|
||||||
|
{ isError: true },
|
||||||
|
]) {
|
||||||
|
const { onTurnFinished } = finishWith(flags);
|
||||||
|
expect(onTurnFinished).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #184 phase 1.5: the resumable-SSE client. A reopened tab resumes the live run
|
||||||
|
// via the SDK's reconnect transport (attach: replay + tail) instead of polling.
|
||||||
|
describe("ChatThread — resume (attach) machinery (#184)", () => {
|
||||||
|
const streamingTail = () => [
|
||||||
|
row("u1", "user", undefined, "hi"),
|
||||||
|
row("a1", "assistant", "streaming", "partial"),
|
||||||
|
];
|
||||||
|
const settledTail = () => [
|
||||||
|
row("u1", "user", undefined, "hi"),
|
||||||
|
row("a1", "assistant", "succeeded", "done"),
|
||||||
|
];
|
||||||
|
const userTail = () => [row("u1", "user", undefined, "hi")];
|
||||||
|
|
||||||
|
const visibleMsg = {
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: "streamed answer" }],
|
||||||
|
};
|
||||||
|
const emptyMsg = { id: "a1", role: "assistant", parts: [] };
|
||||||
|
|
||||||
|
beforeEach(resetState);
|
||||||
|
// NOTE: do NOT vi.unstubAllGlobals() here — vitest.setup.ts installs
|
||||||
|
// matchMedia/localStorage via vi.stubGlobal and unstubbing wipes them for the
|
||||||
|
// rest of the file. Fetch is re-stubbed per test that needs it.
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("resumes on mount only when the flag is on, chatId is set, and the tail is not a settled assistant", () => {
|
||||||
|
// streaming tail -> resume
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// user tail -> resume (the assistant row may not be seeded yet)
|
||||||
|
cleanup();
|
||||||
|
h.state.resumeStream.mockClear();
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// settled assistant tail -> NO resume
|
||||||
|
cleanup();
|
||||||
|
h.state.resumeStream.mockClear();
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||||
|
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// flag off -> NO resume
|
||||||
|
cleanup();
|
||||||
|
h.state.resumeStream.mockClear();
|
||||||
|
renderThread({ autonomousRunsEnabled: false, initialRows: streamingTail() });
|
||||||
|
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// no chatId -> NO resume
|
||||||
|
cleanup();
|
||||||
|
h.state.resumeStream.mockClear();
|
||||||
|
renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
chatId: null,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips the streaming tail from the seed, but keeps a user tail whole", () => {
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
// 2 rows in, streaming tail stripped -> 1 seeded message.
|
||||||
|
expect(h.state.seededMessages).toHaveLength(1);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||||
|
// user tail is not stripped.
|
||||||
|
expect(h.state.seededMessages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the attach URL with expect=live&anchor only when the streaming tail was stripped", () => {
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetch204() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("204 on a user tail: no crash, no restore, reconcile+invalidate, onResumeFallback(true)", async () => {
|
||||||
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: userTail(),
|
||||||
|
});
|
||||||
|
await fetch204();
|
||||||
|
// No stripped row -> no restore merge.
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
||||||
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
await fetch204();
|
||||||
|
// Stripped row is restored to the store.
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("F7 restart-survival: a 500 attach failure restores the stripped row AND arms the poll (not lost)", async () => {
|
||||||
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // stripped row restored
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true); // degraded poll armed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("F7 restart-survival: a network throw restores the stripped row AND arms the poll", async () => {
|
||||||
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockRejectedValue(new Error("network down")),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await h.state
|
||||||
|
.transport!.fetch!("http://x", { method: "GET" })
|
||||||
|
.catch(() => undefined); // the wrapper rethrows; swallow here
|
||||||
|
});
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmount during a pending attach aborts the controller and gates late callbacks", async () => {
|
||||||
|
const { onResumeFallback, invalidateSpy, unmount } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
let abortSeen = false;
|
||||||
|
let resolveFetch!: (v: unknown) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||||
|
init.signal?.addEventListener("abort", () => {
|
||||||
|
abortSeen = true;
|
||||||
|
});
|
||||||
|
return new Promise((res) => {
|
||||||
|
resolveFetch = res;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Kick a reconnect GET (stays pending).
|
||||||
|
let pending!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
pending = h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
// Unmount: the cleanup aborts the in-flight attach.
|
||||||
|
unmount();
|
||||||
|
expect(abortSeen).toBe(true);
|
||||||
|
// A late 204 landing after unmount must NOT arm a poll / invalidate the (now
|
||||||
|
// different) chat.
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
invalidateSpy.mockClear();
|
||||||
|
await act(async () => {
|
||||||
|
resolveFetch({ status: 204, ok: false });
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||||
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a resume fetch error clears resumedTurn so the next local turn flushes the queue", async () => {
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
h.state.status = "ready";
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
// Queue then clean-finish: suppression was cleared, so the queue flushes.
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: visibleMsg,
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a resumed turn's onFinish does NOT flush the queue", () => {
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: visibleMsg,
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a healthy resumed finish (visible content) arms nothing and keeps the store", () => {
|
||||||
|
h.state.status = "ready";
|
||||||
|
const { onResumeFallback } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: visibleMsg,
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// No restore (would clobber the fuller streamed message), no poll arm.
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDisconnect WITH visible content arms the poll but does NOT restore", () => {
|
||||||
|
h.state.status = "ready";
|
||||||
|
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
invalidateSpy.mockClear();
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: visibleMsg,
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: true,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["ai-chat-messages", "c1"],
|
||||||
|
});
|
||||||
|
// Restore forbidden: the on-screen partial must not roll back.
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty resumed message (starved replay) restores the stripped row AND arms the poll", () => {
|
||||||
|
h.state.status = "ready";
|
||||||
|
const { onResumeFallback } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: emptyMsg,
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degraded-merge: merges the tail per initialRows update, and settles disarm the poll", async () => {
|
||||||
|
h.state.status = "ready";
|
||||||
|
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||||
|
// Arm reconcile via a 204.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
|
||||||
|
// A streaming-tail update: merge, poll stays armed.
|
||||||
|
rerender([
|
||||||
|
row("u1", "user", undefined, "hi"),
|
||||||
|
row("a1", "assistant", "streaming", "step 1\nstep 2"),
|
||||||
|
]);
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onResumeFallback).not.toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
// A settled-tail update: merge + disarm.
|
||||||
|
h.state.setMessages.mockClear();
|
||||||
|
rerender([
|
||||||
|
row("u1", "user", undefined, "hi"),
|
||||||
|
row("a1", "assistant", "succeeded", "final"),
|
||||||
|
]);
|
||||||
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a local stream disarms both the merge and the poll", () => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||||
|
onResumeFallback.mockClear();
|
||||||
|
// A re-render while streaming: the reconciliation effect disarms.
|
||||||
|
rerender(streamingTail());
|
||||||
|
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Send now is hidden on a resumed turn but visible on a local stream", () => {
|
||||||
|
// Resumed turn: hidden.
|
||||||
|
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
expect(screen.queryByLabelText("Send now")).toBeNull();
|
||||||
|
|
||||||
|
// Local streaming turn (no resume): visible.
|
||||||
|
cleanup();
|
||||||
|
resetState();
|
||||||
|
renderThread({ initialRows: [] });
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
expect(screen.getByLabelText("Send now")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleStop aborts the attach controller and calls onServerStop", async () => {
|
||||||
|
const { onServerStop } = renderThread({
|
||||||
|
autonomousRunsEnabled: true,
|
||||||
|
initialRows: streamingTail(),
|
||||||
|
});
|
||||||
|
// Establish an attach controller via a (pending) reconnect GET.
|
||||||
|
let abortSeen = false;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||||
|
init.signal?.addEventListener("abort", () => {
|
||||||
|
abortSeen = true;
|
||||||
|
});
|
||||||
|
return new Promise(() => undefined); // never resolves
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
void h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Stop"));
|
||||||
|
expect(abortSeen).toBe(true);
|
||||||
|
expect(onServerStop).toHaveBeenCalledWith("c1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: render a resumable thread and expose a rerender that only swaps
|
||||||
|
// initialRows (the degraded-merge effect depends on it).
|
||||||
|
function renderResumable(initialRows: IAiChatMessageRow[]) {
|
||||||
|
const onResumeFallback = vi.fn();
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
const Wrapper = ({ rows }: { rows: IAiChatMessageRow[] }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MantineProvider>
|
||||||
|
<ChatThread
|
||||||
|
chatId="c1"
|
||||||
|
initialRows={rows}
|
||||||
|
autonomousRunsEnabled
|
||||||
|
onTurnFinished={vi.fn()}
|
||||||
|
onResumeFallback={onResumeFallback}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const view = render(<Wrapper rows={initialRows} />);
|
||||||
|
const rerender = (rows: IAiChatMessageRow[]) =>
|
||||||
|
act(() => view.rerender(<Wrapper rows={rows} />));
|
||||||
|
return { rerender, onResumeFallback };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { generateId } from "ai";
|
import { generateId } from "ai";
|
||||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,15 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
|
import {
|
||||||
|
isStreamingTail,
|
||||||
|
isSettledAssistantTail,
|
||||||
|
seedRows,
|
||||||
|
mergeById,
|
||||||
|
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||||
|
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -60,6 +70,10 @@ interface ChatThreadProps {
|
|||||||
/** The page currently open in the workspace, or null on a non-page route.
|
/** The page currently open in the workspace, or null on a non-page route.
|
||||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||||
openPage?: OpenPageContext | null;
|
openPage?: OpenPageContext | null;
|
||||||
|
/** #388: snapshot the user's current editor selection at SEND time. Invoked
|
||||||
|
* inside prepareSendMessagesRequest and nested into openPage on the wire, so a
|
||||||
|
* fresh snapshot ships each turn. Null/absent => nothing selected. */
|
||||||
|
getEditorSelection?: () => EditorSelectionContext | null;
|
||||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||||
* in the request body so the server persists it on chat creation; ignored by
|
* in the request body so the server persists it on chat creation; ignored by
|
||||||
* the server for existing chats (the role is read from the chat row). */
|
* the server for existing chats (the role is read from the chat row). */
|
||||||
@@ -86,6 +100,23 @@ interface ChatThreadProps {
|
|||||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
onServerChatId?: (serverChatId?: string) => void;
|
||||||
|
/** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
|
||||||
|
* chat's window. Called `true` when a resume attempt could not attach to the
|
||||||
|
* live run (attach 204 / starved-or-torn resumed finish), so the window starts
|
||||||
|
* a dumb timed poll of the message history to follow the detached run to settle;
|
||||||
|
* called `false` the moment a local stream starts or the terminal settled row is
|
||||||
|
* merged (invariant 8). The window owns the timer + its 10-min cap. */
|
||||||
|
onResumeFallback?: (active: boolean) => void;
|
||||||
|
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
||||||
|
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
||||||
|
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
||||||
|
* which the server deliberately ignores, so the detached run would keep going. */
|
||||||
|
autonomousRunsEnabled?: boolean;
|
||||||
|
/** #184: request the server-side stop of this chat's active run (the parent owns
|
||||||
|
* the endpoint call + the "stopping" latch that keeps observer-polling from
|
||||||
|
* immediately re-streaming the stopping run's output). Called with the resolved
|
||||||
|
* chat id when the user presses Stop in autonomous mode. */
|
||||||
|
onServerStop?: (chatId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,17 +156,65 @@ export default function ChatThread({
|
|||||||
threadKey,
|
threadKey,
|
||||||
initialRows,
|
initialRows,
|
||||||
openPage,
|
openPage,
|
||||||
|
getEditorSelection,
|
||||||
roleId,
|
roleId,
|
||||||
roles,
|
roles,
|
||||||
onRolePicked,
|
onRolePicked,
|
||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
|
onResumeFallback,
|
||||||
|
autonomousRunsEnabled,
|
||||||
|
onServerStop,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// resume machinery refs (#184 phase 1.5)
|
||||||
|
const attachAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const reconcileTailRef = useRef(false);
|
||||||
|
const noStreamHandledRef = useRef(false);
|
||||||
|
const onNoActiveStreamRef = useRef<(() => void) | null>(null);
|
||||||
|
// Live mount flag. The attach GET and the resumed `onFinish` are async and can
|
||||||
|
// land AFTER this thread unmounts (the parent remounts per chat via `key`); with
|
||||||
|
// chatIdRef then pointing at the NEW chat, an ungated late callback would arm a
|
||||||
|
// spurious poll + foreign invalidation on the newly-opened chat. Every parent-
|
||||||
|
// facing resume side-effect is gated on this.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const [resumedTurn, setResumedTurn] = useState(false);
|
||||||
|
const resumedTurnRef = useRef(false);
|
||||||
|
// Identity-stable pair setter (bare useState setter + ref write): it is closed
|
||||||
|
// over by the transport useMemo([]), so it MUST NOT capture state.
|
||||||
|
const setResumedTurnPair = useCallback((v: boolean) => {
|
||||||
|
resumedTurnRef.current = v;
|
||||||
|
setResumedTurn(v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mount-time resume gating (in refs — computed once for this mount; the parent
|
||||||
|
// remounts per chat via `key`).
|
||||||
|
//
|
||||||
|
// Attempt resume for any non-settled tail: a streaming tail (strip + expect
|
||||||
|
// live replay) or a user tail (the run may exist but its assistant row is not
|
||||||
|
// seeded yet — attach to the pre-opened registry entry and wait for frames).
|
||||||
|
// A settled assistant tail must NEVER resume: replaying a finished run into a
|
||||||
|
// store that already contains its message duplicates parts (SDK text-start
|
||||||
|
// always pushes a new part).
|
||||||
|
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
||||||
|
const attemptResumeRef = useRef(
|
||||||
|
autonomousRunsEnabled === true &&
|
||||||
|
chatId !== null &&
|
||||||
|
!isSettledAssistantTail(initialRows ?? []),
|
||||||
|
);
|
||||||
|
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
||||||
|
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
||||||
|
);
|
||||||
|
|
||||||
const initialMessages = useMemo<UIMessage[]>(
|
const initialMessages = useMemo<UIMessage[]>(
|
||||||
() => (initialRows ?? []).map(rowToUiMessage),
|
() =>
|
||||||
|
seedRows(
|
||||||
|
initialRows ?? [],
|
||||||
|
attemptResumeRef.current && stripRef.current,
|
||||||
|
).map(rowToUiMessage),
|
||||||
[initialRows],
|
[initialRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,6 +232,14 @@ export default function ChatThread({
|
|||||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||||
openPageRef.current = openPage ?? null;
|
openPageRef.current = openPage ?? null;
|
||||||
|
|
||||||
|
// Keep the selection snapshotter in a ref, same rationale as openPageRef: the
|
||||||
|
// transport useMemo([]) closes it over, so prop-identity churn must not matter.
|
||||||
|
// Called at send time inside prepareSendMessagesRequest (#388).
|
||||||
|
const getEditorSelectionRef = useRef<
|
||||||
|
(() => EditorSelectionContext | null) | undefined
|
||||||
|
>(getEditorSelection);
|
||||||
|
getEditorSelectionRef.current = getEditorSelection;
|
||||||
|
|
||||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||||
// ignores it for existing chats), but sending it on every send is harmless.
|
// ignores it for existing chats), but sending it on every send is harmless.
|
||||||
@@ -216,6 +303,16 @@ export default function ChatThread({
|
|||||||
const flushOnAbortRef = useRef(false);
|
const flushOnAbortRef = useRef(false);
|
||||||
const interruptNextSendRef = useRef(false);
|
const interruptNextSendRef = useRef(false);
|
||||||
|
|
||||||
|
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
|
||||||
|
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
|
||||||
|
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
|
||||||
|
// autonomous run — it keeps burning tokens and WRITING TO PAGES — so we cannot
|
||||||
|
// just no-op. We latch the stop as PENDING and fire the authoritative server
|
||||||
|
// stop the moment onServerChatId adopts the id (below). Read-and-cleared there;
|
||||||
|
// also defused on every new turn start so it can never fire against a later,
|
||||||
|
// unrelated turn's run.
|
||||||
|
const stopPendingRef = useRef(false);
|
||||||
|
|
||||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||||
// Returns whether a message was actually sent, so callers can tell an empty
|
// Returns whether a message was actually sent, so callers can tell an empty
|
||||||
// dequeue (nothing to flush) from a real send.
|
// dequeue (nothing to flush) from a real send.
|
||||||
@@ -223,9 +320,12 @@ export default function ChatThread({
|
|||||||
const { head, rest } = dequeue(queuedRef.current);
|
const { head, rest } = dequeue(queuedRef.current);
|
||||||
if (!head) return false;
|
if (!head) return false;
|
||||||
setQueue(rest);
|
setQueue(rest);
|
||||||
|
// Local send: clear any resume-suppression flag so this genuine local turn's
|
||||||
|
// onFinish flushes normally (invariant 8).
|
||||||
|
setResumedTurnPair(false);
|
||||||
sendMessageRef.current?.({ text: head.text });
|
sendMessageRef.current?.({ text: head.text });
|
||||||
return true;
|
return true;
|
||||||
}, [setQueue]);
|
}, [setQueue, setResumedTurnPair]);
|
||||||
|
|
||||||
const enqueue = useCallback(
|
const enqueue = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -245,6 +345,47 @@ 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: () => ({
|
||||||
|
// SDK default URL uses the useChat STORE id — always build from the real chat id.
|
||||||
|
// ?expect=live&anchor=<row id> ONLY when we stripped a streaming tail: expect=live
|
||||||
|
// is the only case where a finished-retained replay is safe (the row is stripped,
|
||||||
|
// replay rebuilds it), and the anchor pins the replay to OUR run — a mismatching
|
||||||
|
// (newer) run must 204 into the restore+poll path instead of replaying a foreign
|
||||||
|
// transcript into this store.
|
||||||
|
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||||
|
stripRef.current
|
||||||
|
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
}),
|
||||||
|
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
|
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
|
||||||
|
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
|
||||||
|
// for observer Stop / unmount abort.
|
||||||
|
const controller = new AbortController();
|
||||||
|
attachAbortRef.current = controller;
|
||||||
|
try {
|
||||||
|
const response = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
// No onFinish will come for a 204 (silent no-op) OR any non-2xx
|
||||||
|
// (5xx/502 — a server restart mid-attach). Both run the same
|
||||||
|
// no-active-stream recovery: restore the stripped row, invalidate, and
|
||||||
|
// arm the degraded poll (idempotent via noStreamHandledRef; its part-d
|
||||||
|
// also clears the resumedTurn flag). This is the restart-survival path
|
||||||
|
// the removed F7 latch used to guard — a transient attach failure must
|
||||||
|
// NOT drop the in-progress row or stop tracking the durable run.
|
||||||
|
if (response.status === 204 || !response.ok)
|
||||||
|
onNoActiveStreamRef.current?.();
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
// Network throw: same no-onFinish recovery, then rethrow so the SDK
|
||||||
|
// still surfaces the error to its own machinery.
|
||||||
|
onNoActiveStreamRef.current?.();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
// Inject the chat id and the currently-open page alongside the useChat
|
// Inject the chat id and the currently-open page alongside the useChat
|
||||||
// messages so the server can resolve an existing chat (or create one
|
// messages so the server can resolve an existing chat (or create one
|
||||||
// when null) and tell the agent which page "this page" refers to. Both
|
// when null) and tell the agent which page "this page" refers to. Both
|
||||||
@@ -261,7 +402,16 @@ export default function ChatThread({
|
|||||||
body: {
|
body: {
|
||||||
...body,
|
...body,
|
||||||
chatId: chatIdRef.current,
|
chatId: chatIdRef.current,
|
||||||
openPage: openPageRef.current,
|
// Attach the live editor selection to the open-page context at send
|
||||||
|
// time — "this"/"here" in the user's message means THIS selection.
|
||||||
|
// Nested inside openPage so it dies with the page when the server
|
||||||
|
// rejects the page id (#388). Null when nothing is selected.
|
||||||
|
openPage: openPageRef.current
|
||||||
|
? {
|
||||||
|
...openPageRef.current,
|
||||||
|
selection: getEditorSelectionRef.current?.() ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
// Honoured by the server only when creating a new chat; null =>
|
// Honoured by the server only when creating a new chat; null =>
|
||||||
// universal assistant.
|
// universal assistant.
|
||||||
roleId: roleIdRef.current,
|
roleId: roleIdRef.current,
|
||||||
@@ -274,7 +424,15 @@ export default function ChatThread({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { messages, sendMessage, status, stop, error } = useChat({
|
const {
|
||||||
|
messages,
|
||||||
|
sendMessage,
|
||||||
|
status,
|
||||||
|
stop,
|
||||||
|
error,
|
||||||
|
setMessages,
|
||||||
|
resumeStream,
|
||||||
|
} = useChat({
|
||||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// every render mid-stream (see `chatStoreId` above).
|
||||||
@@ -292,6 +450,38 @@ export default function ChatThread({
|
|||||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||||
// the user to decide.
|
// the user to decide.
|
||||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||||
|
// (1) Capture whether THIS finish belongs to a resumed (attach) turn and
|
||||||
|
// immediately clear the flag so it can never suppress a LATER local turn.
|
||||||
|
const wasResumed = resumedTurnRef.current;
|
||||||
|
setResumedTurnPair(false);
|
||||||
|
// (2) Recovery after a starved/torn resumed finish (invariant 9). The arm
|
||||||
|
// and the stripped-row restore are gated DIFFERENTLY. Skip entirely once
|
||||||
|
// unmounted (an abort-triggered onFinish landing after a chat switch must
|
||||||
|
// not arm a poll / invalidate on the new chat).
|
||||||
|
if (wasResumed && mountedRef.current) {
|
||||||
|
const hasVisibleContent = assistantMessageHasVisibleContent(message);
|
||||||
|
// ARM the reconcile + degraded poll when the resumed message carries no
|
||||||
|
// visible content (starved replay) OR the connection dropped mid-run — in
|
||||||
|
// both cases the poll must drive the row to its real terminal state.
|
||||||
|
if (isDisconnect || !hasVisibleContent) {
|
||||||
|
reconcileTailRef.current = true;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||||
|
});
|
||||||
|
onResumeFallback?.(true);
|
||||||
|
}
|
||||||
|
// RESTORE the stripped streaming row ONLY when the resumed message has no
|
||||||
|
// visible content. On isDisconnect WITH visible content restore is
|
||||||
|
// FORBIDDEN: the live stream may have advanced far past the mount-time
|
||||||
|
// snapshot, so restoring would clobber on-screen content (invariant 9) —
|
||||||
|
// the arm above suffices, the poll reaches the true terminal.
|
||||||
|
if (!hasVisibleContent && strippedRowRef.current) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (3) Standard branches.
|
||||||
// Forward the authoritative server chatId (streamed on the assistant
|
// Forward the authoritative server chatId (streamed on the assistant
|
||||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||||
@@ -304,6 +494,10 @@ export default function ChatThread({
|
|||||||
else if (isAbort) setStopNotice("manual");
|
else if (isAbort) setStopNotice("manual");
|
||||||
else if (isDisconnect) setStopNotice("disconnect");
|
else if (isDisconnect) setStopNotice("disconnect");
|
||||||
else setStopNotice(null);
|
else setStopNotice(null);
|
||||||
|
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
|
||||||
|
// flush-on-abort branch and the plain flush. The local streamer is the only
|
||||||
|
// tab that owns the queue.
|
||||||
|
if (wasResumed) return;
|
||||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||||
// immediately send the promoted head. Flush it even though the turn was
|
// immediately send the promoted head. Flush it even though the turn was
|
||||||
// aborted (the normal abort path below keeps the queue intact). The
|
// aborted (the normal abort path below keeps the queue intact). The
|
||||||
@@ -365,7 +559,14 @@ export default function ChatThread({
|
|||||||
return;
|
return;
|
||||||
lastForwardedChatIdRef.current = serverChatId;
|
lastForwardedChatIdRef.current = serverChatId;
|
||||||
onServerChatId(serverChatId);
|
onServerChatId(serverChatId);
|
||||||
}, [messages, onServerChatId]);
|
// #234 F5: if Stop was pressed before the id was known, the authoritative
|
||||||
|
// server stop was deferred to this adoption point — fire it now with the
|
||||||
|
// just-adopted id. One-shot (read-and-clear) so it can't fire twice.
|
||||||
|
if (stopPendingRef.current) {
|
||||||
|
stopPendingRef.current = false;
|
||||||
|
onServerStop?.(serverChatId);
|
||||||
|
}
|
||||||
|
}, [messages, onServerChatId, onServerStop]);
|
||||||
|
|
||||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||||
@@ -378,6 +579,99 @@ export default function ChatThread({
|
|||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
// 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to
|
||||||
|
// resume (overflow / begin-failure / after retention / anchor-mismatch). One-
|
||||||
|
// shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four
|
||||||
|
// parts. Kept in a ref (read by the transport's fetch closure) and refreshed
|
||||||
|
// each render below.
|
||||||
|
const onNoActiveStream = useCallback(() => {
|
||||||
|
// A late attach outcome after unmount must not arm a poll / invalidate on the
|
||||||
|
// now-different chat this thread's refs were reused for.
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (noStreamHandledRef.current) return;
|
||||||
|
noStreamHandledRef.current = true;
|
||||||
|
// (a) Restore the stripped streaming row to the store — ONLY when we actually
|
||||||
|
// stripped one (a user-tail 204 does NOT reach here with a stripped row, so do
|
||||||
|
// not dereference null).
|
||||||
|
if (strippedRowRef.current) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// (b) Reconcile the tail from the message history + invalidate it so the
|
||||||
|
// degraded poll starts from a fresh fetch.
|
||||||
|
reconcileTailRef.current = true;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||||
|
});
|
||||||
|
// (c) Arm the degraded poll (a dumb timer with a 10-min cap in the window);
|
||||||
|
// the thread disarms it via onResumeFallback(false) on settle / local stream.
|
||||||
|
onResumeFallback?.(true);
|
||||||
|
// (d) 204 means onFinish will NOT fire — clear the suppression flag so it
|
||||||
|
// cannot swallow the NEXT local turn's queue flush.
|
||||||
|
setResumedTurnPair(false);
|
||||||
|
}, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]);
|
||||||
|
onNoActiveStreamRef.current = onNoActiveStream;
|
||||||
|
|
||||||
|
// Mount effect: kick off the resume attempt for a non-settled tail. Marking the
|
||||||
|
// turn as resumed BEFORE resumeStream so onFinish (invariant 7/8) sees it.
|
||||||
|
useEffect(() => {
|
||||||
|
// Re-arm on (re)mount — StrictMode dev-mounts twice, and the cleanup below
|
||||||
|
// flips this false between the two.
|
||||||
|
mountedRef.current = true;
|
||||||
|
if (attemptResumeRef.current) {
|
||||||
|
setResumedTurnPair(true);
|
||||||
|
void resumeStream();
|
||||||
|
}
|
||||||
|
// Unmount: mark unmounted (gates late attach/onFinish side-effects) and abort
|
||||||
|
// the in-flight attach GET so its callbacks don't fire against the next chat.
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
attachAbortRef.current?.abort();
|
||||||
|
};
|
||||||
|
// Mount-only by design; the parent remounts per chat via `key`.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reconciliation + degraded-merge (invariant 8). Deps are EXACTLY
|
||||||
|
// [initialRows, isStreaming, setMessages].
|
||||||
|
useEffect(() => {
|
||||||
|
// A local stream owns the view: disarm BOTH the merge and the window poll.
|
||||||
|
if (isStreaming) {
|
||||||
|
reconcileTailRef.current = false;
|
||||||
|
onResumeFallback?.(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reconcileTailRef.current) return;
|
||||||
|
const rows = initialRows ?? [];
|
||||||
|
const tail = rows[rows.length - 1];
|
||||||
|
if (!tail || tail.role !== "assistant") return;
|
||||||
|
// Merge the polled assistant tail on EVERY initialRows update — while the
|
||||||
|
// degraded poll is active this IS the live per-step progress.
|
||||||
|
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
||||||
|
// Anchor-mismatch coherence: when we restored a stripped streaming row A but a
|
||||||
|
// DIFFERENT run's row B is now the tail (A finished, B replaced the registry
|
||||||
|
// entry, so the attach 204'd), A would otherwise linger forever as an orphan
|
||||||
|
// jumping-dots row over the real run. Settle it from fresh history (where A is
|
||||||
|
// now persisted) so no phantom row survives. No-op in the common case where A
|
||||||
|
// IS the tail (id match).
|
||||||
|
const stripped = strippedRowRef.current;
|
||||||
|
if (stripped && stripped.id !== tail.id) {
|
||||||
|
const historical = rows.find((r) => r.id === stripped.id);
|
||||||
|
if (historical)
|
||||||
|
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
||||||
|
}
|
||||||
|
// Settled: the terminal merge is done — disarm the flag AND the window poll
|
||||||
|
// explicitly (the window only has a time cap, it will not disarm itself).
|
||||||
|
if (tail.status !== "streaming") {
|
||||||
|
reconcileTailRef.current = false;
|
||||||
|
onResumeFallback?.(false);
|
||||||
|
}
|
||||||
|
// onResumeFallback intentionally omitted (parent-stable callback); deps are
|
||||||
|
// fixed by the resume design.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialRows, isStreaming, setMessages]);
|
||||||
|
|
||||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
// stay queued and flush normally after the new turn. Reuses the existing
|
// stay queued and flush normally after the new turn. Reuses the existing
|
||||||
@@ -403,12 +697,51 @@ export default function ChatThread({
|
|||||||
const msg = queuedRef.current.find((m) => m.id === id);
|
const msg = queuedRef.current.find((m) => m.id === id);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
setQueue(removeQueuedById(queuedRef.current, id));
|
setQueue(removeQueuedById(queuedRef.current, id));
|
||||||
|
// Local send: clear any resume-suppression flag (invariant 8).
|
||||||
|
setResumedTurnPair(false);
|
||||||
sendMessageRef.current?.({ text: msg.text });
|
sendMessageRef.current?.({ text: msg.text });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setQueue, stop],
|
[setQueue, stop, setResumedTurnPair],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||||
|
// returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run:
|
||||||
|
// aborting the local SSE is only a client disconnect, which the server ignores,
|
||||||
|
// so the run would keep executing — we ADDITIONALLY request the authoritative
|
||||||
|
// server-side stop (the parent owns that call + the "stopping" latch that keeps
|
||||||
|
// observer-polling from re-streaming the stopping run's output). The chat id is
|
||||||
|
// read live from chatIdRef (adopted early at the stream's `start` chunk); if it
|
||||||
|
// is not known yet — a brand-new chat in the first moment of its first turn —
|
||||||
|
// only the local abort happens (there is no server-side run handle to stop yet).
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
// Abort the resume/attach GET first: the SDK does not pass it a signal, so an
|
||||||
|
// observer's Stop would otherwise leave the attach fetch running.
|
||||||
|
attachAbortRef.current?.abort();
|
||||||
|
stop();
|
||||||
|
if (!autonomousRunsEnabled) return;
|
||||||
|
if (chatIdRef.current) {
|
||||||
|
onServerStop?.(chatIdRef.current);
|
||||||
|
} else {
|
||||||
|
// #234 F5: no chat id yet (brand-new chat in the first moment of its first
|
||||||
|
// turn, before the `start` chunk adopted the id). Latch the stop as pending;
|
||||||
|
// the onServerChatId adoption effect fires the deferred server stop as soon
|
||||||
|
// as the id appears, so the detached run is still authoritatively stopped
|
||||||
|
// instead of left running by a silent local-only abort.
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (#234 F5 review): `stop()` above has already aborted the
|
||||||
|
// local SSE reader. In the rare sub-window where Stop is pressed while still
|
||||||
|
// `submitted` (request sent, not one chunk read yet), that abort can cancel
|
||||||
|
// the reader BEFORE the `start` chunk is applied to `messages`, so the
|
||||||
|
// adoption effect never runs and this pending stop never fires. The detached
|
||||||
|
// run then keeps going for that turn. This is not a regression (the pre-fix
|
||||||
|
// behavior sent no server stop at all); closing it fully would require
|
||||||
|
// deferring the local abort until adoption, which is riskier and out of scope
|
||||||
|
// for this fix. Documented so a future change can address the abort-ordering.
|
||||||
|
stopPendingRef.current = true;
|
||||||
|
}
|
||||||
|
}, [stop, autonomousRunsEnabled, onServerStop]);
|
||||||
|
|
||||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||||
@@ -420,6 +753,11 @@ export default function ChatThread({
|
|||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
flushOnAbortRef.current = false;
|
flushOnAbortRef.current = false;
|
||||||
interruptNextSendRef.current = false;
|
interruptNextSendRef.current = false;
|
||||||
|
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
|
||||||
|
// previous turn that never adopted an id, so it can never fire against this
|
||||||
|
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
|
||||||
|
// set AFTER this effect (on the Stop click), so this does not clobber it.
|
||||||
|
stopPendingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
@@ -512,6 +850,11 @@ export default function ChatThread({
|
|||||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||||
{m.text}
|
{m.text}
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
|
||||||
|
stop() does not abort the resumed attach fetch, so the click
|
||||||
|
would be swallowed while flushOnAbortRef would fire minutes
|
||||||
|
later on the natural finish. Only the remove affordance stays. */}
|
||||||
|
{!resumedTurn && (
|
||||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -523,6 +866,7 @@ export default function ChatThread({
|
|||||||
<IconPlayerPlayFilled size={12} />
|
<IconPlayerPlayFilled size={12} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -537,9 +881,13 @@ export default function ChatThread({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => sendMessage({ text })}
|
onSend={(text) => {
|
||||||
|
// Local send: clear any resume-suppression flag (invariant 8).
|
||||||
|
setResumedTurnPair(false);
|
||||||
|
sendMessage({ text });
|
||||||
|
}}
|
||||||
onQueue={enqueue}
|
onQueue={enqueue}
|
||||||
onStop={stop}
|
onStop={handleStop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
|
|||||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (stranded reasoning part): a reasoning part is left at
|
||||||
|
// `state:"streaming"` forever when the turn ends without `reasoning-end`
|
||||||
|
// (manual Stop during thinking). The signature is EQUAL across that turn-end
|
||||||
|
// flip (nothing in the message changed), so the comparator must ALSO compare
|
||||||
|
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
|
||||||
|
// never switches from chunked plain text to its one-time markdown parse.
|
||||||
|
it("returns false when turnStreaming differs despite an equal signature", () => {
|
||||||
|
const m = msg([
|
||||||
|
{ type: "reasoning", text: "thinking", state: "streaming" },
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
arePropsEqual(
|
||||||
|
props(m, { turnStreaming: true }),
|
||||||
|
props(m, { turnStreaming: false }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns true for the same content in a different message object", () => {
|
it("returns true for the same content in a different message object", () => {
|
||||||
const a = msg([{ type: "text", text: "answer" }]);
|
const a = msg([{ type: "text", text: "answer" }]);
|
||||||
const b = msg([{ type: "text", text: "answer" }]);
|
const b = msg([{ type: "text", text: "answer" }]);
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ interface MessageItemProps {
|
|||||||
* absent; the public share passes the configured identity (agent role) name.
|
* absent; the public share passes the configured identity (agent role) name.
|
||||||
*/
|
*/
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
/**
|
||||||
|
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
|
||||||
|
* A reasoning part may be left `state: "streaming"` forever when the turn
|
||||||
|
* ends without a `reasoning-end` chunk (manual Stop during the thinking
|
||||||
|
* phase, or a provider that never emits it) — the AI SDK finalizes reasoning
|
||||||
|
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
|
||||||
|
* state alone cannot prove liveness; the reasoning part is treated as live
|
||||||
|
* only while the whole turn is still streaming. Defaults to false.
|
||||||
|
*
|
||||||
|
* The parent passes it as "turn is live AND this is the tail row", so a
|
||||||
|
* stranded part in an EARLIER row never re-activates when a later turn
|
||||||
|
* streams.
|
||||||
|
*/
|
||||||
|
turnStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +119,7 @@ function MessageItem({
|
|||||||
showCitations = true,
|
showCitations = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
|
turnStreaming = false,
|
||||||
}: MessageItemProps) {
|
}: MessageItemProps) {
|
||||||
// `signature` is intentionally not read in the body — it exists solely as the
|
// `signature` is intentionally not read in the body — it exists solely as the
|
||||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||||
@@ -155,8 +170,23 @@ function MessageItem({
|
|||||||
const text = (part as { text?: string }).text ?? "";
|
const text = (part as { text?: string }).text ?? "";
|
||||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
return null;
|
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 (
|
return (
|
||||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
<ReasoningBlock
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
tokens={reasoningTokens}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +275,11 @@ export function arePropsEqual(
|
|||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName
|
prev.assistantName === next.assistantName &&
|
||||||
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
// that is what converts a stranded `state:"streaming"` reasoning part to
|
||||||
|
// its one-time markdown parse (see the `turnStreaming` prop doc).
|
||||||
|
prev.turnStreaming === next.turnStreaming
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
@@ -50,8 +50,9 @@ vi.stubGlobal(
|
|||||||
|
|
||||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||||
// regression test to model how the AI SDK hands back the SAME message object.
|
// regression test to model how the AI SDK hands back the SAME message object.
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
// Pass an explicit `id` when a test renders several rows at once.
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
|
||||||
|
({ id, role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
describe("MessageList", () => {
|
describe("MessageList", () => {
|
||||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||||
@@ -116,4 +117,102 @@ describe("MessageList", () => {
|
|||||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
|
||||||
|
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
|
||||||
|
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
|
||||||
|
// that never emits `reasoning-end`) therefore leaves the part at
|
||||||
|
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
|
||||||
|
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
|
||||||
|
// forwarded as `turnStreaming`): while the turn streams the expanded block
|
||||||
|
// shows chunked plain text (no parse); once the turn ends — even though the
|
||||||
|
// part is still `state:"streaming"` — the block finalizes and does its
|
||||||
|
// one-time markdown parse. Note the message signature does NOT change across
|
||||||
|
// that flip, so this also exercises the `turnStreaming` memo comparison in
|
||||||
|
// arePropsEqual (without it the row would never re-render).
|
||||||
|
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const reasoningText = "**bold** thinking";
|
||||||
|
// Reasoning part stranded mid-stream + a non-empty answer part (a
|
||||||
|
// reasoning-only message renders nothing — see message-content.ts).
|
||||||
|
const message = msg([
|
||||||
|
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||||
|
{ type: "text", text: "partial answer" },
|
||||||
|
]);
|
||||||
|
const parsesOfReasoning = () =>
|
||||||
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
const { rerender, getByRole, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[message]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Expand the reasoning block (its toggle is the only button in the list).
|
||||||
|
fireEvent.click(getByRole("button"));
|
||||||
|
// Turn live + part streaming -> ReasoningBlock received streaming=true:
|
||||||
|
// the body is chunked plain text (raw markdown syntax), NOT parsed.
|
||||||
|
expect(queryByText(/bold/)).not.toBeNull();
|
||||||
|
expect(parsesOfReasoning()).toBe(0);
|
||||||
|
|
||||||
|
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
|
||||||
|
// (still state:"streaming"), only the turn-level flag flips.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[message]} isStreaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// ReasoningBlock now received streaming=false and did its one-time parse.
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
|
||||||
|
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
|
||||||
|
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
|
||||||
|
// above) as live again whenever a LATER turn streams — an expanded stranded
|
||||||
|
// block would flip markdown -> raw plain text -> markdown across turn
|
||||||
|
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
|
||||||
|
// to the TAIL row only.
|
||||||
|
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const reasoningText = "**bold** thinking";
|
||||||
|
// First (earlier) assistant message: its turn was stopped during the
|
||||||
|
// thinking phase, leaving the reasoning part at state:"streaming".
|
||||||
|
const first = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||||
|
{ type: "text", text: "first answer" },
|
||||||
|
],
|
||||||
|
"m1",
|
||||||
|
);
|
||||||
|
// Second assistant message: the LATER turn, currently streaming.
|
||||||
|
const second = msg([{ type: "text", text: "second answer" }], "m2");
|
||||||
|
const parsesOfReasoning = () =>
|
||||||
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
const { rerender, getByRole, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[first, second]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Expand the first row's reasoning block (the only toggle in the list —
|
||||||
|
// the second message has no reasoning or tool parts).
|
||||||
|
fireEvent.click(getByRole("button"));
|
||||||
|
// The turn is live but the first row is NOT the tail: its ReasoningBlock
|
||||||
|
// received streaming=false, so the stranded part stays finalized and does
|
||||||
|
// its one-time markdown parse instead of dropping to chunked plain text.
|
||||||
|
expect(queryByText(/bold/)).not.toBeNull();
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
|
||||||
|
// A later-turn delta re-renders the list; the earlier block must neither
|
||||||
|
// flip back to streaming nor re-parse.
|
||||||
|
(second.parts[0] as { text: string }).text = "second answer grows";
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[first, second]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export default function MessageList({
|
|||||||
return (
|
return (
|
||||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||||
<Stack gap={0} pr="xs">
|
<Stack gap={0} pr="xs">
|
||||||
{messages.map((message) => (
|
{messages.map((message, index) => (
|
||||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||||
// string and handed to MessageItem as its memo key. It must NOT be
|
// string and handed to MessageItem as its memo key. It must NOT be
|
||||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||||
@@ -210,6 +210,13 @@ export default function MessageList({
|
|||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
// can belong to the in-flight turn, so a reasoning part stranded at
|
||||||
|
// `state:"streaming"` in an EARLIER message (its turn ended without
|
||||||
|
// `reasoning-end`) stays finalized and doesn't flip back to plain
|
||||||
|
// text (and re-parse) whenever a later turn streams — see
|
||||||
|
// message-item.tsx.
|
||||||
|
turnStreaming={isStreaming && index === messages.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
{typing && (
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// Spy on the markdown renderer so we can assert it is NOT called while the block
|
||||||
|
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
|
||||||
|
// tests don't depend on real markdown, so a light stub is safe.
|
||||||
|
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
|
||||||
|
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
|
||||||
|
}));
|
||||||
|
|
||||||
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||||
// keeps the assertions on the component's OWN count logic (authoritative vs
|
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||||
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||||
@@ -17,10 +24,15 @@ vi.mock("react-i18next", () => ({
|
|||||||
|
|
||||||
import ReasoningBlock from "./reasoning-block";
|
import ReasoningBlock from "./reasoning-block";
|
||||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
function renderBlock(props: { text: string; tokens?: number }) {
|
function renderBlock(props: {
|
||||||
|
text: string;
|
||||||
|
tokens?: number;
|
||||||
|
streaming?: boolean;
|
||||||
|
}) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ReasoningBlock {...props} />
|
<ReasoningBlock {...props} />
|
||||||
@@ -62,4 +74,68 @@ describe("ReasoningBlock", () => {
|
|||||||
// either way the text is present in the document.
|
// either way the text is present in the document.
|
||||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||||
|
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
|
||||||
|
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
|
||||||
|
// delta is exactly what froze the chat (#302). The collapsed body shows the
|
||||||
|
// cheap raw-text fallback instead.
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
// Expanding parses the current text exactly once (a user-initiated click).
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
renderBlock({
|
||||||
|
text: "первый абзац размышлений\n\nвторой абзац растёт",
|
||||||
|
tokens: 5,
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
|
||||||
|
// delta — the body is chunked plain text (only the tail chunk updates).
|
||||||
|
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
// Both paragraph chunks' raw text is present in the body.
|
||||||
|
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
|
||||||
|
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses exactly once when streaming flips to done while expanded", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
const { rerender } = renderBlock({
|
||||||
|
text: "**bold** reasoning",
|
||||||
|
tokens: 5,
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Finalization: the part's state flips streaming→done, the parent
|
||||||
|
// re-renders the row (the flip changes the message signature), and the
|
||||||
|
// block does its ONE markdown parse of the now-stable text.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
|
||||||
|
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||||
|
|
||||||
|
// Further re-renders with unchanged props do not re-parse.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface ReasoningBlockProps {
|
interface ReasoningBlockProps {
|
||||||
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
|
|||||||
* step/turn has finished. When absent (or 0) the count is estimated from the
|
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||||
* text length so it ticks live as the reasoning streams in. */
|
* text length so it ticks live as the reasoning streams in. */
|
||||||
tokens?: number;
|
tokens?: number;
|
||||||
|
/** True while the reasoning part is still streaming (part `state ===
|
||||||
|
* "streaming"`). False means finalized: persisted history or `state ===
|
||||||
|
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,22 +32,30 @@ interface ReasoningBlockProps {
|
|||||||
* Providers that don't stream reasoning TEXT still render this block from the
|
* Providers that don't stream reasoning TEXT still render this block from the
|
||||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||||
*/
|
*/
|
||||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
// Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
|
||||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
// 1. Collapsed -> never parse (#302): the html is only shown inside
|
||||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
// <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
|
||||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
// marked + DOMPurify storm.
|
||||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
// 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
|
||||||
// paragraphs) — ONLY here, not in the normal answer.
|
// renders as chunked plain text (StreamingPlainText) with a memoized
|
||||||
|
// stable prefix, so each delta updates only the tail chunk's text node.
|
||||||
|
// This closes the O(n²) hole #302 left open ("expanded while streaming")
|
||||||
|
// that froze the whole tab in Safari when watching the thinking stream.
|
||||||
|
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
|
||||||
|
// are stable after the part is done, so this memo runs once per expand.
|
||||||
const html = useMemo(
|
const html = useMemo(
|
||||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
() =>
|
||||||
[trimmed],
|
open && trimmed && !streaming
|
||||||
|
? renderChatMarkdown(collapseBlankLines(trimmed), {})
|
||||||
|
: "",
|
||||||
|
[open, trimmed, streaming],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text
|
// Still streaming (or markdown yielded nothing): chunked plain text.
|
||||||
className={classes.reasoningText}
|
// The wrapper carries the reasoningText styling; each chunk sets its
|
||||||
style={{ whiteSpace: "pre-wrap" }}
|
// own pre-wrap inline (NOT on this div — see ai-chat.module.css).
|
||||||
>
|
<div className={classes.reasoningText}>
|
||||||
{trimmed}
|
<StreamingPlainText text={trimmed} />
|
||||||
</Text>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapse>
|
</Collapse>
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
// Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
|
||||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
// props, default shallow compare), so a parent re-render during streaming of OTHER
|
||||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
// content does not re-run the markdown parse for an already-finalized reasoning block.
|
||||||
export default memo(ReasoningBlock);
|
export default memo(ReasoningBlock);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
splitPlainChunks,
|
||||||
|
StreamingPlainText,
|
||||||
|
} from "./streaming-plain-text";
|
||||||
|
|
||||||
|
describe("splitPlainChunks", () => {
|
||||||
|
// THE load-bearing property (see the invariant comment in the module): under
|
||||||
|
// append-only growth, every chunk except the LAST must be byte-identical
|
||||||
|
// between successive calls, so the memoized chunk components never re-render
|
||||||
|
// for the stable prefix and each stream delta touches only the tail chunk.
|
||||||
|
it("keeps all non-last chunks byte-identical across append-only growth", () => {
|
||||||
|
// A simulated reasoning stream covering: appends inside the last paragraph,
|
||||||
|
// appends that ADD new blank lines, growth of a trailing newline run, and a
|
||||||
|
// trailing separator later followed by text.
|
||||||
|
const steps = [
|
||||||
|
"Пер",
|
||||||
|
"Первый абзац",
|
||||||
|
"Первый абзац\n",
|
||||||
|
"Первый абзац\n\n",
|
||||||
|
"Первый абзац\n\n\n",
|
||||||
|
"Первый абзац\n\n\nВторой",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
|
||||||
|
];
|
||||||
|
let prev: string[] = [];
|
||||||
|
for (const text of steps) {
|
||||||
|
const next = splitPlainChunks(text);
|
||||||
|
// Lossless: chunks always reassemble into the exact input.
|
||||||
|
expect(next.join("")).toBe(text);
|
||||||
|
// Chunk count never shrinks (boundaries never disappear).
|
||||||
|
expect(next.length).toBeGreaterThanOrEqual(prev.length);
|
||||||
|
// Every previously-FINAL chunk (all but prev's last) is unchanged.
|
||||||
|
for (let i = 0; i < prev.length - 1; i++) {
|
||||||
|
expect(next[i]).toBe(prev[i]);
|
||||||
|
}
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
// Guard against a vacuous pass: the final split must be multi-chunk.
|
||||||
|
expect(prev.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the blank-line separator run to the preceding chunk", () => {
|
||||||
|
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
|
||||||
|
// A longer run is ONE separator, not several boundaries.
|
||||||
|
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
|
||||||
|
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single newlines are not boundaries", () => {
|
||||||
|
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
|
||||||
|
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
|
||||||
|
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
|
||||||
|
// merge with it into a new separator unit and retroactively create a boundary
|
||||||
|
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
|
||||||
|
// in one (still lossless) chunk — only granularity is coarser; LLM output is
|
||||||
|
// `\n` in practice. See the doc comment on splitPlainChunks.
|
||||||
|
it("keeps CRLF blank lines inside one chunk", () => {
|
||||||
|
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
|
||||||
|
// Mixed input: only pure-`\n` runs split.
|
||||||
|
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
|
||||||
|
expect(splitPlainChunks("")).toEqual([]);
|
||||||
|
// A trailing newline run stays inside the last chunk (it may still grow).
|
||||||
|
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
|
||||||
|
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
|
||||||
|
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
|
||||||
|
// Degenerate all-newlines input is a single deterministic chunk.
|
||||||
|
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
|
||||||
|
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
|
||||||
|
for (const chunk of splitPlainChunks(text)) {
|
||||||
|
expect(chunk.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StreamingPlainText", () => {
|
||||||
|
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
|
||||||
|
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
const blocks = Array.from(container.querySelectorAll("div"));
|
||||||
|
// One block element per chunk.
|
||||||
|
expect(blocks.length).toBe(splitPlainChunks(text).length);
|
||||||
|
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
|
||||||
|
// separator newlines — rendering them inside a pre-wrap block would add an
|
||||||
|
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
|
||||||
|
// keep their separators (losslessness is asserted on splitPlainChunks
|
||||||
|
// above); multi-blank-line runs collapse to one uniform gap, consistent
|
||||||
|
// with collapseBlankLines on the finalized markdown path.
|
||||||
|
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||||
|
"первый абзац",
|
||||||
|
"второй абзац",
|
||||||
|
"третий",
|
||||||
|
]);
|
||||||
|
// The uniform paragraph gap comes from the block margin instead (matches
|
||||||
|
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
|
||||||
|
for (const block of blocks) {
|
||||||
|
expect((block as HTMLElement).style.marginBottom).toBe("4px");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps interior newlines intact — only the trailing run is stripped", () => {
|
||||||
|
const text = "строка один\nстрока два\n\nхвост";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
const blocks = Array.from(container.querySelectorAll("div"));
|
||||||
|
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||||
|
"строка один\nстрока два",
|
||||||
|
"хвост",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
|
||||||
|
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
|
||||||
|
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
|
||||||
|
// node, which escapes it, so HTML in the model output is inert. This test
|
||||||
|
// pins that the path is a TEXT sink, not an HTML sink: a future change to
|
||||||
|
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
|
||||||
|
//
|
||||||
|
// The existing tests assert via textContent, which strips tags and so cannot
|
||||||
|
// distinguish an escaped literal from injected DOM. This one asserts on the
|
||||||
|
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
|
||||||
|
// would become real elements and querySelector would find them.
|
||||||
|
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
|
||||||
|
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
// No DOM elements were created from the payload — it was NOT parsed as HTML.
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
expect(container.querySelector("b")).toBeNull();
|
||||||
|
// The raw markup survived verbatim as text (proving it is escaped, not
|
||||||
|
// interpreted). textContent alone can't prove this, but combined with the
|
||||||
|
// querySelector assertions above it does: the literals are present AND no
|
||||||
|
// elements exist.
|
||||||
|
expect(container.textContent).toContain("<b>hi</b>");
|
||||||
|
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
|
||||||
|
* each separator run attached to the END of the preceding chunk, so the chunks
|
||||||
|
* always reassemble byte-for-byte into the input.
|
||||||
|
*
|
||||||
|
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
|
||||||
|
* one more character. A newline run that is a SUFFIX of the text is NOT a
|
||||||
|
* boundary yet: under append-only growth it may still gain more newlines, and
|
||||||
|
* cutting there would move the boundary on the next call.
|
||||||
|
*
|
||||||
|
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
|
||||||
|
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
|
||||||
|
* between successive calls — previously-emitted boundaries never move. Proof
|
||||||
|
* sketch: appending never modifies existing characters, so (a) an existing
|
||||||
|
* boundary's newline run and its following character are untouched and the
|
||||||
|
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
|
||||||
|
* inside the old text, because a `\n{2,}` run followed by a character entirely
|
||||||
|
* within the old text would already have been a boundary. New boundaries can
|
||||||
|
* only materialize at or after the old text's end, i.e. inside the last chunk.
|
||||||
|
*
|
||||||
|
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
|
||||||
|
* the invariant above — a lone trailing `\r` is not a boundary, but a later-
|
||||||
|
* appended `\n` would merge with it into a new separator unit and retroactively
|
||||||
|
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
|
||||||
|
* With `\n`-only runs, appended characters can never extend a run that is
|
||||||
|
* already followed by a non-`\n` character, so old boundaries are immutable.
|
||||||
|
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
|
||||||
|
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
|
||||||
|
* output is `\n` in practice).
|
||||||
|
*/
|
||||||
|
export function splitPlainChunks(text: string): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (const match of text.matchAll(/\n{2,}/g)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
// Suffix run: not a stable boundary yet (see the invariant above).
|
||||||
|
if (end >= text.length) break;
|
||||||
|
chunks.push(text.slice(start, end));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
if (start < text.length) chunks.push(text.slice(start));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One immutable chunk. Memoized on its string prop: during streaming only the
|
||||||
|
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
|
||||||
|
* skips every stable chunk and the per-delta DOM work is a single text-node
|
||||||
|
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
|
||||||
|
* on the surrounding markdown-styled container — see the note in
|
||||||
|
* ai-chat.module.css. Font/size/color are inherited from that container.
|
||||||
|
*
|
||||||
|
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
|
||||||
|
* separator run attached (the splitPlainChunks invariant, load-bearing for the
|
||||||
|
* memo), but rendering those newlines inside a pre-wrap block would add an
|
||||||
|
* empty line ON TOP of the block break — a doubled gap. So the RENDERED string
|
||||||
|
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
|
||||||
|
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
|
||||||
|
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
|
||||||
|
* consistent with `collapseBlankLines` on the markdown path. The last chunk
|
||||||
|
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
|
||||||
|
*/
|
||||||
|
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
|
||||||
|
{text.replace(/\n+$/, "")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders still-streaming plain text as a list of paragraph chunks where only
|
||||||
|
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML —
|
||||||
|
* this is the cheap streaming-time stand-in for the one-time markdown parse
|
||||||
|
* that happens after the part is finalized (see reasoning-block.tsx).
|
||||||
|
*/
|
||||||
|
export function StreamingPlainText({ text }: { text: string }) {
|
||||||
|
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
// Index keys are stable here: chunks are append-only (the invariant),
|
||||||
|
// so an index never gets a different chunk's content mid-stream.
|
||||||
|
<PlainChunk key={index} text={chunk} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||||
// see :pageSlug — match the full path against the authenticated page route.
|
// see :pageSlug — match the full path against the authenticated page route.
|
||||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
|
||||||
|
// real page uuid (PageRepo.findById accepts slugId or uuid).
|
||||||
|
const slugId = extractPageSlugId(match?.params?.pageSlug);
|
||||||
|
|
||||||
return useCallback(async () => {
|
return useCallback(async () => {
|
||||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||||
@@ -40,9 +42,9 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
// connection the first click reads as a hung control until the POST returns.
|
// connection the first click reads as a hung control until the POST returns.
|
||||||
setWindowOpen(true);
|
setWindowOpen(true);
|
||||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||||
if (pageId) {
|
if (slugId) {
|
||||||
try {
|
try {
|
||||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
resolved = await getBoundChat(slugId); // null => fresh chat
|
||||||
} catch {
|
} catch {
|
||||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
}, [
|
}, [
|
||||||
windowOpen,
|
windowOpen,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
pageId,
|
slugId,
|
||||||
setWindowOpen,
|
setWindowOpen,
|
||||||
setActiveChatId,
|
setActiveChatId,
|
||||||
setDraft,
|
setDraft,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// react-i18next / notifications are pulled in transitively by ai-chat-query.ts
|
||||||
|
// (the mutation hooks use them); stub so the module imports cleanly.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the service module; only getAiChatMessages is exercised, but the other
|
||||||
|
// named exports must exist so ai-chat-query.ts imports resolve.
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAiChatMessages } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useAiChatMessagesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
const emptyPage = { items: [], meta: { hasNextPage: false, nextCursor: null } };
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The degraded-poll fallback (#184 phase 1.5) is threaded into this query as a
|
||||||
|
// `refetchInterval`; AiChatWindow supplies the deliberately-dumb callback. These
|
||||||
|
// pin the plumbing the window depends on: the interval polls the message history,
|
||||||
|
// and — critically — fetch ERRORS do NOT stop the tick (TanStack v5 resets the
|
||||||
|
// failure count each fetch, so the poll must survive a server restart).
|
||||||
|
describe("useAiChatMessagesQuery — degraded refetchInterval", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-polls at the interval while the callback returns a duration", async () => {
|
||||||
|
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||||
|
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-poll when the callback returns false", async () => {
|
||||||
|
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||||
|
renderHook(() => useAiChatMessagesQuery("c1", () => false), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1),
|
||||||
|
);
|
||||||
|
// Give any errant interval a chance to fire, then assert it did not.
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ticking through fetch errors (errors do not gate the poll)", async () => {
|
||||||
|
vi.mocked(getAiChatMessages).mockRejectedValue(new Error("server down"));
|
||||||
|
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -56,11 +57,12 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHATS_RQ_KEY,
|
queryKey: AI_CHATS_RQ_KEY,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||||
@@ -83,15 +85,26 @@ export function useAiChatsQuery() {
|
|||||||
* Load all persisted messages of a chat (oldest first), flattening the
|
* Load all persisted messages of a chat (oldest first), flattening the
|
||||||
* paginated server response. Used to seed `useChat` initial messages.
|
* paginated server response. Used to seed `useChat` initial messages.
|
||||||
*/
|
*/
|
||||||
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
export function useAiChatMessagesQuery(
|
||||||
|
chatId: string | undefined,
|
||||||
|
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
||||||
|
// still-running run (the attach returned 204 / the resumed stream ended with no
|
||||||
|
// terminal row), the window arms a dumb timed poll of the message history to
|
||||||
|
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
||||||
|
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
||||||
|
refetchInterval?: number | false | (() => number | false),
|
||||||
|
) {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
enabled: !!chatId,
|
enabled: !!chatId,
|
||||||
|
refetchInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||||
@@ -272,6 +285,29 @@ export function useAiRoleCatalogBundleQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||||
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||||
|
* which needs every role's install state up front — so contents can no longer be
|
||||||
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||||
|
* cached read per bundle, sharing the same cache keys as
|
||||||
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||||
|
* resolved language) so nothing fetches while the modal is closed.
|
||||||
|
*/
|
||||||
|
export function useAiRoleCatalogBundlesQueries(
|
||||||
|
bundleIds: string[],
|
||||||
|
language: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQueries({
|
||||||
|
queries: bundleIds.map((bundleId) => ({
|
||||||
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||||
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||||
|
enabled: enabled && !!language,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportAiRolesFromCatalogMutation() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -280,11 +316,14 @@ export function useImportAiRolesFromCatalogMutation() {
|
|||||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
message: t(
|
||||||
|
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||||
|
{
|
||||||
created: result.created,
|
created: result.created,
|
||||||
renamed: result.renamed,
|
renamed: result.renamed,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
await runMutation({
|
||||||
|
created: 3,
|
||||||
|
renamed: 1,
|
||||||
|
skipped: 2,
|
||||||
|
errors: [],
|
||||||
|
createdRoles: [],
|
||||||
|
skippedRoles: [],
|
||||||
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -42,13 +42,30 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||||
|
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||||
|
* is deliberately ignored server-side, so the client must call this to actually
|
||||||
|
* stop an autonomous run. Targeted by `chatId` (the server resolves whatever run
|
||||||
|
* is active on it); owner-gated server-side. Returns `{ stopped }` — false when
|
||||||
|
* there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
export async function stopRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<{ stopped: boolean }> {
|
||||||
|
const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
*/
|
*/
|
||||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
export async function getBoundChat(slugId: string): Promise<string | null> {
|
||||||
|
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
|
||||||
|
// it to the real page uuid (the wire key stays `pageId` for the DTO).
|
||||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||||
pageId,
|
pageId: slugId,
|
||||||
});
|
});
|
||||||
return req.data.chatId;
|
return req.data.chatId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
|||||||
conflict: "skip" | "rename";
|
conflict: "skip" | "rename";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
/**
|
||||||
|
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||||
|
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||||
|
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||||
|
* result plaque — which roles were installed (and any rename) and which were
|
||||||
|
* skipped and why (so the plaque can name the conflicting role and offer
|
||||||
|
* "Rename & install").
|
||||||
|
*/
|
||||||
export interface IAiRoleImportResult {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: "name-conflict" | "already-installed";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,6 +210,11 @@ export interface IAiChatMessageRow {
|
|||||||
// renders a "stopped" marker on interrupted turns.
|
// renders a "stopped" marker on interrupted turns.
|
||||||
finishReason?: string;
|
finishReason?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
// Persisted lifecycle status of the row's turn, carried on the wire by
|
||||||
|
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
|
||||||
|
// the resume machinery to decide whether a tail is a live stream to attach to
|
||||||
|
// or a settled row that must not be replayed).
|
||||||
|
status?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleCounts,
|
||||||
|
bundlePhase,
|
||||||
|
installedLangForRole,
|
||||||
|
mapBundleRolesToView,
|
||||||
|
mapCatalogRoleToView,
|
||||||
|
nameConflictSlugs,
|
||||||
|
partialOffersRename,
|
||||||
|
type CatalogViewRole,
|
||||||
|
} from "./catalog-bundle-model.ts";
|
||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogRole(
|
||||||
|
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||||
|
): IAiRoleCatalogRole {
|
||||||
|
return {
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
instructions: "be a writer",
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
version: 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal view role for bundlePhase tests.
|
||||||
|
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||||
|
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bundlePhase", () => {
|
||||||
|
it("empty bundle -> empty", () => {
|
||||||
|
expect(bundlePhase([])).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all importable, none installed -> allNew", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||||
|
"allNew",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing to import or update -> allInstalled", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||||
|
"allInstalled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates present, nothing to import -> updates", () => {
|
||||||
|
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||||
|
"updates",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + installed (no updates) -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + update -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||||
|
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||||
|
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||||
|
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||||
|
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleCounts", () => {
|
||||||
|
it("tallies each status once", () => {
|
||||||
|
expect(
|
||||||
|
bundleCounts([
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("installed"),
|
||||||
|
viewRole("update"),
|
||||||
|
viewRole("skipped"),
|
||||||
|
]),
|
||||||
|
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||||
|
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installedLangForRole", () => {
|
||||||
|
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no install of the slug exists", () => {
|
||||||
|
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source)", () => {
|
||||||
|
const roles = [
|
||||||
|
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||||
|
source: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCatalogRoleToView", () => {
|
||||||
|
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
status: "import",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
expect(view.installedRoleId).toBeUndefined();
|
||||||
|
expect(view.installedLang).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import with the slug installed in another language -> installedLang set", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||||
|
expect(view.status).toBe("import");
|
||||||
|
expect(view.installedLang).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "installed",
|
||||||
|
version: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "update",
|
||||||
|
version: 1,
|
||||||
|
newVersion: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||||
|
const view = mapCatalogRoleToView(
|
||||||
|
catalogRole({ emoji: null, description: null }),
|
||||||
|
[],
|
||||||
|
"en",
|
||||||
|
);
|
||||||
|
expect(view.emoji).toBeUndefined();
|
||||||
|
expect(view.description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapBundleRolesToView", () => {
|
||||||
|
it("maps a bundle's roles preserving order", () => {
|
||||||
|
const roles = [
|
||||||
|
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||||
|
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||||
|
];
|
||||||
|
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||||
|
const view = mapBundleRolesToView(roles, installed, "en");
|
||||||
|
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||||
|
expect(view[0].status).toBe("installed");
|
||||||
|
expect(view[1].status).toBe("import");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||||
|
* (readable without expanding) and a single primary action. The per-role and
|
||||||
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||||
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||||
|
* computation are unit-testable without mounting the component (mirrors the
|
||||||
|
* `catalogRoleInstallState` precedent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A role's status in the catalog view model.
|
||||||
|
* - `import` — not installed in the current content language.
|
||||||
|
* - `installed` — installed and up to date.
|
||||||
|
* - `update` — installed, but the catalog ships a newer version.
|
||||||
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||||
|
* (a name collision under `conflict:'skip'`); never from the
|
||||||
|
* backend.
|
||||||
|
*/
|
||||||
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||||
|
|
||||||
|
/** A catalog role mapped into the modal's view model. */
|
||||||
|
export interface CatalogViewRole {
|
||||||
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||||
|
// `slugs[]` payload for import.
|
||||||
|
slug: string;
|
||||||
|
// Optional in the catalog — the row reserves space and renders nothing when
|
||||||
|
// absent.
|
||||||
|
emoji?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||||
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||||
|
version: number;
|
||||||
|
newVersion?: number;
|
||||||
|
status: RoleStatus;
|
||||||
|
// The language a same-slug role is installed under, when it differs from the
|
||||||
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||||
|
installedLang?: string;
|
||||||
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||||
|
// update-from-catalog mutation.
|
||||||
|
installedRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||||
|
* the collapsed-header summary and the bundle's single primary action.
|
||||||
|
* - `empty` — the bundle has no roles.
|
||||||
|
* - `allNew` — everything is importable, nothing installed.
|
||||||
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||||
|
* - `updates` — updates available and nothing left to import.
|
||||||
|
* - `mixed` — any other combination.
|
||||||
|
*/
|
||||||
|
export type BundlePhase =
|
||||||
|
| "empty"
|
||||||
|
| "allNew"
|
||||||
|
| "allInstalled"
|
||||||
|
| "updates"
|
||||||
|
| "mixed";
|
||||||
|
|
||||||
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||||
|
export interface BundleCounts {
|
||||||
|
importable: number;
|
||||||
|
installed: number;
|
||||||
|
update: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||||
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||||
|
*/
|
||||||
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||||
|
const counts: BundleCounts = {
|
||||||
|
importable: 0,
|
||||||
|
installed: 0,
|
||||||
|
update: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r.status === "import") counts.importable += 1;
|
||||||
|
else if (r.status === "installed") counts.installed += 1;
|
||||||
|
else if (r.status === "update") counts.update += 1;
|
||||||
|
else if (r.status === "skipped") counts.skipped += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||||
|
if (roles.length === 0) return "empty";
|
||||||
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||||
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||||
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||||
|
// would make the collapsed green "up to date" header contradict the open
|
||||||
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||||
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||||
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||||
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||||
|
return "allNew";
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||||
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||||
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||||
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||||
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||||
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||||
|
*/
|
||||||
|
export function nameConflictSlugs(
|
||||||
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): string[] {
|
||||||
|
return skipped
|
||||||
|
.filter((s) => s.reason === "name-conflict")
|
||||||
|
.map((s) => s.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partial-import result should offer the "Rename & install" action:
|
||||||
|
* only when at least one skip is a name conflict (renameable). An
|
||||||
|
* `already-installed`-only partial is informational.
|
||||||
|
*/
|
||||||
|
export function partialOffersRename(
|
||||||
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): boolean {
|
||||||
|
return skipped.some((s) => s.reason === "name-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a role NOT installed in the current `language`, find a workspace role with
|
||||||
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||||
|
* language. Drives the "installed in another language" hint (Р5): a different
|
||||||
|
* language of the same slug is a separate install and appears as `import`.
|
||||||
|
*/
|
||||||
|
export function installedLangForRole(
|
||||||
|
slug: string,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): string | undefined {
|
||||||
|
const other = workspaceRoles.find(
|
||||||
|
(r) =>
|
||||||
|
r.source?.slug === slug &&
|
||||||
|
!!r.source?.language &&
|
||||||
|
r.source.language !== language,
|
||||||
|
);
|
||||||
|
return other?.source?.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one catalog role to the view model, computing its install status against
|
||||||
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||||
|
* the other-language hint.
|
||||||
|
*/
|
||||||
|
export function mapCatalogRoleToView(
|
||||||
|
role: IAiRoleCatalogRole,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole {
|
||||||
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||||
|
const base = {
|
||||||
|
slug: role.slug,
|
||||||
|
emoji: role.emoji ?? undefined,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
};
|
||||||
|
if (state.state === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "update",
|
||||||
|
version: state.fromVersion,
|
||||||
|
newVersion: state.toVersion,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.state === "installed") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "installed",
|
||||||
|
version: role.version,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "import",
|
||||||
|
version: role.version,
|
||||||
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||||
|
*/
|
||||||
|
export function mapBundleRolesToView(
|
||||||
|
roles: IAiRoleCatalogRole[],
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole[] {
|
||||||
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
isStreamingTail,
|
||||||
|
isSettledAssistantTail,
|
||||||
|
seedRows,
|
||||||
|
mergeById,
|
||||||
|
} from "./resume-helpers.ts";
|
||||||
|
|
||||||
|
function row(
|
||||||
|
id: string,
|
||||||
|
role: string,
|
||||||
|
status?: string,
|
||||||
|
): IAiChatMessageRow {
|
||||||
|
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
} as UIMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isStreamingTail", () => {
|
||||||
|
it("is true when the last row is a streaming assistant row", () => {
|
||||||
|
expect(
|
||||||
|
isStreamingTail([row("u1", "user"), row("a1", "assistant", "streaming")]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a settled assistant tail", () => {
|
||||||
|
expect(isStreamingTail([row("a1", "assistant", "succeeded")])).toBe(false);
|
||||||
|
expect(isStreamingTail([row("a1", "assistant")])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the tail is a user row or the list is empty", () => {
|
||||||
|
expect(isStreamingTail([row("u1", "user")])).toBe(false);
|
||||||
|
expect(isStreamingTail([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSettledAssistantTail", () => {
|
||||||
|
it("is true for an assistant tail whose status is not streaming", () => {
|
||||||
|
expect(isSettledAssistantTail([row("a1", "assistant", "succeeded")])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isSettledAssistantTail([row("a1", "assistant")])).toBe(true);
|
||||||
|
expect(isSettledAssistantTail([row("a1", "assistant", "aborted")])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a streaming assistant tail", () => {
|
||||||
|
expect(isSettledAssistantTail([row("a1", "assistant", "streaming")])).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the tail is a user row or the list is empty", () => {
|
||||||
|
expect(isSettledAssistantTail([row("u1", "user")])).toBe(false);
|
||||||
|
expect(isSettledAssistantTail([])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seedRows", () => {
|
||||||
|
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||||
|
|
||||||
|
it("returns the rows unchanged when not stripping", () => {
|
||||||
|
expect(seedRows(rows, false)).toBe(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the last row when stripping", () => {
|
||||||
|
const seeded = seedRows(rows, true);
|
||||||
|
expect(seeded).toHaveLength(1);
|
||||||
|
expect(seeded[0].id).toBe("u1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when stripping a single-row list", () => {
|
||||||
|
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeById", () => {
|
||||||
|
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const incoming = makeMsg("a1", "step 1\nstep 2");
|
||||||
|
const next = mergeById(prev, incoming);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(incoming);
|
||||||
|
expect(next[0]).toBe(prev[0]); // untouched
|
||||||
|
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends when the incoming message is not yet present", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
const incoming = makeMsg("a1", "first token");
|
||||||
|
const next = mergeById(prev, incoming);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(incoming);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
expect(mergeById(prev, null)).toBe(prev);
|
||||||
|
expect(mergeById(prev, undefined)).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decisions for the resumable-SSE resume machinery (#184 phase 1.5). A tab
|
||||||
|
* that reopens a chat whose agent run is still going attaches to the server's
|
||||||
|
* run-stream registry (replay + live tail) instead of polling snapshots; these
|
||||||
|
* small predicates decide WHICH tail is safe to resume and how to seed the store,
|
||||||
|
* extracted so they can be unit-tested in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||||
|
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||||
|
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||||
|
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||||
|
*/
|
||||||
|
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||||
|
const tail = rows[rows.length - 1];
|
||||||
|
return !!tail && tail.role === "assistant" && tail.status === "streaming";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SETTLED assistant tail: the last row is an assistant row whose status is
|
||||||
|
* anything OTHER than 'streaming'. A settled assistant tail must NEVER resume —
|
||||||
|
* replaying a finished run into a store that already holds its message duplicates
|
||||||
|
* parts (`text-start` always pushes a new part).
|
||||||
|
*/
|
||||||
|
export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
||||||
|
const tail = rows[rows.length - 1];
|
||||||
|
return !!tail && tail.role === "assistant" && tail.status !== "streaming";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||||
|
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||||
|
* without duplicating parts).
|
||||||
|
*/
|
||||||
|
export function seedRows(
|
||||||
|
rows: IAiChatMessageRow[],
|
||||||
|
strip: boolean,
|
||||||
|
): IAiChatMessageRow[] {
|
||||||
|
return strip ? rows.slice(0, -1) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an assistant message into the rendered list by id: replace the message
|
||||||
|
* with the same id in place (the in-progress assistant row is already seeded from
|
||||||
|
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||||
|
* new array; the input is never mutated.
|
||||||
|
*/
|
||||||
|
export function mergeById(
|
||||||
|
messages: UIMessage[],
|
||||||
|
incoming: UIMessage | null | undefined,
|
||||||
|
): UIMessage[] {
|
||||||
|
if (!incoming) return messages;
|
||||||
|
const idx = messages.findIndex((m) => m.id === incoming.id);
|
||||||
|
if (idx === -1) return [...messages, incoming];
|
||||||
|
const next = messages.slice();
|
||||||
|
next[idx] = incoming;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||||
|
// safety valve fired without pulling in the editor stack.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||||
|
// case renders in isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CommentContentView } from "./comment-content-view";
|
||||||
|
|
||||||
|
function renderView(content: string | object) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentContentView content={content} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||||
|
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||||
|
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||||
|
|
||||||
|
describe("CommentContentView", () => {
|
||||||
|
it("renders paragraphs as <p> with text", () => {
|
||||||
|
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||||
|
expect(screen.getByText("Hello world")).toBeDefined();
|
||||||
|
expect(container.querySelector("p")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||||
|
const { container } = renderView(doc([para([text("x")])]));
|
||||||
|
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||||
|
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||||
|
expect(globalPm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bold mark as <strong>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||||
|
);
|
||||||
|
const el = container.querySelector("strong");
|
||||||
|
expect(el?.textContent).toBe("bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the italic mark as <em>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("it", [{ type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the strike mark as <s>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("st", [{ type: "strike" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("un", [{ type: "underline" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||||
|
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the code mark as <code>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("co", [{ type: "code" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "https://example.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||||
|
expect(a?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
// No navigable javascript: href — attribute is absent (or empty).
|
||||||
|
expect(a?.getAttribute("href")).toBeFalsy();
|
||||||
|
// The link text is still rendered.
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a data: link href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("mail", [
|
||||||
|
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"mailto:a@b.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"/some/path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests multiple marks on one text node", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||||
|
expect(container.querySelector("strong")).not.toBeNull();
|
||||||
|
expect(container.querySelector("em")).not.toBeNull();
|
||||||
|
expect(screen.getByText("x")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hardBreak as <br/>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("br")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user mention as a styled span", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("@Alice")).toBeDefined();
|
||||||
|
// No fallback to the editor.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page mention as a link", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
label: "Some Page",
|
||||||
|
entityType: "page",
|
||||||
|
slugId: "pg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Some Page")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||||
|
renderView("just a legacy string");
|
||||||
|
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for an unknown node type", () => {
|
||||||
|
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for malformed JSON", () => {
|
||||||
|
renderView('{"type":"doc","content":[');
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from "react";
|
||||||
|
import classes from "./comment.module.css";
|
||||||
|
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
|
||||||
|
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||||
|
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||||
|
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||||
|
//
|
||||||
|
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||||
|
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||||
|
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||||
|
// so we never show a half-rendered comment.
|
||||||
|
|
||||||
|
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||||
|
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||||
|
class UnknownNodeError extends Error {}
|
||||||
|
|
||||||
|
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||||
|
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||||
|
// renderer must apply the SAME sanitization because the backend stores comment
|
||||||
|
// content verbatim and React does not neutralize javascript: in an href.
|
||||||
|
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||||
|
|
||||||
|
function safeHref(href: unknown): string | undefined {
|
||||||
|
if (typeof href !== "string") return undefined;
|
||||||
|
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||||
|
// (e.g. "java\tscript:").
|
||||||
|
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||||
|
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||||
|
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: PMNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: PMMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||||
|
function renderMarks(
|
||||||
|
text: React.ReactNode,
|
||||||
|
marks: PMMark[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!marks || marks.length === 0) return text;
|
||||||
|
|
||||||
|
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||||
|
const key = `${keyPrefix}-m${i}`;
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return <strong key={key}>{acc}</strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={key}>{acc}</em>;
|
||||||
|
case "strike":
|
||||||
|
return <s key={key}>{acc}</s>;
|
||||||
|
case "underline":
|
||||||
|
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||||
|
// CommentEditor does not disable it, so real comments can carry this
|
||||||
|
// mark. Render it here rather than degrading the whole comment.
|
||||||
|
return <u key={key}>{acc}</u>;
|
||||||
|
case "code":
|
||||||
|
return <code key={key}>{acc}</code>;
|
||||||
|
case "link": {
|
||||||
|
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||||
|
// safe rel semantics the editor produces. Sanitize the href against the
|
||||||
|
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||||
|
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||||
|
// its text, matching how extension-link blanks a bad href.
|
||||||
|
const href = safeHref(mark.attrs?.href);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{acc}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||||
|
switch (node.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{renderMarks(node.text ?? "", node.marks, key)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case "hardBreak":
|
||||||
|
return <br key={key} />;
|
||||||
|
case "mention":
|
||||||
|
return (
|
||||||
|
<span key={key} style={{ display: "inline" }}>
|
||||||
|
<MentionContent attrs={node.attrs as any} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(
|
||||||
|
content: PMNode[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!content) return null;
|
||||||
|
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||||
|
// scoped CSS in comment.module.css (which targets
|
||||||
|
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||||
|
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={classes.commentEditor}>
|
||||||
|
<div className={classes.ProseMirror}>
|
||||||
|
<div className="ProseMirror">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentContentViewProps {
|
||||||
|
content: string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||||
|
// Degrade this single comment to the old editor-based render (safety valve).
|
||||||
|
const fallback = () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
"CommentContentView: unsupported comment content, falling back to editor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <CommentEditor defaultContent={content} editable={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{content}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||||
|
if (typeof doc === "string") {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{doc}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pmDoc = doc as PMNode;
|
||||||
|
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||||
|
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||||
|
}
|
||||||
|
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnknownNodeError) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentContentView;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -7,18 +7,75 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
|
|
||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
|
useApplySuggestionMutation: () => ({
|
||||||
|
mutateAsync: applyMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||||
|
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||||
|
const EDITED_DOC = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// so the edit->save/cancel flow can be driven without a live editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: ({ onUpdate, onSave }: any) => (
|
||||||
|
<div data-testid="comment-editor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-update"
|
||||||
|
onClick={() => onUpdate?.(doc)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-save"
|
||||||
|
onClick={() => onSave?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CommentContentView (used for the read-only body) imports the mention view,
|
||||||
|
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||||
|
// renders in isolation without the app entry side-effect.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -32,10 +89,21 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment) {
|
function renderItem(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
<CommentListItem
|
||||||
|
comment={comment}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,3 +155,306 @@ describe("CommentListItem — agent avatar stack", () => {
|
|||||||
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
||||||
// only guards the insertion gate (agent → stack, user → no stack).
|
// only guards the insertion gate (agent → stack, user → no stack).
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — suggested edit (#315)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
|
const { container } = renderItem(suggestion(), true);
|
||||||
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
|
// Apply button is present.
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
// No Applied badge yet.
|
||||||
|
expect(screen.queryByText("Applied")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
|
const { container } = renderItem(suggestion(), false);
|
||||||
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
|
// ...but no Apply button.
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Apply button once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the apply mutation when the Apply button is clicked", () => {
|
||||||
|
applyMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||||
|
expect(applyMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the diff block for a reply (child) comment", () => {
|
||||||
|
renderItem(
|
||||||
|
suggestion({ parentCommentId: "c-0" }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("new wording here")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||||
|
// regardless of who authored the comment; the tests below use it as the lever
|
||||||
|
// since the currentUser atom is unseeded (null) in this harness.
|
||||||
|
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderItem(suggestion(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderItem(suggestion(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||||
|
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||||
|
// currentUser atom is null while the comment is authored by user-1), so the
|
||||||
|
// server would 403 a dismiss — the button must not be shown at all.
|
||||||
|
renderItem(suggestion(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canShowApply predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowApply(c(), true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without edit permission", () => {
|
||||||
|
expect(canShowApply(c(), false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canShowDismiss predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without comment permission", () => {
|
||||||
|
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when not owner and not admin (#338 F5)", () => {
|
||||||
|
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||||
|
const body = (t: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit menu item is gated on the viewer owning the comment
|
||||||
|
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||||
|
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMutateAsync.mockClear();
|
||||||
|
localStorage.setItem(
|
||||||
|
"currentUser",
|
||||||
|
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||||
|
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||||
|
fireEvent.click(await screen.findByText("Edit comment"));
|
||||||
|
// Edit form (mocked editor + actions) is now mounted.
|
||||||
|
await screen.findByTestId("comment-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||||
|
const { rerender } = renderItem(
|
||||||
|
baseComment({ content: body("original body") }),
|
||||||
|
);
|
||||||
|
// Static body first.
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
// mutateAsync is called with the stringified edited doc.
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(EDITED_DOC),
|
||||||
|
});
|
||||||
|
|
||||||
|
// On success the form closes (isEditing -> false); the static body renders
|
||||||
|
// from the comment.content prop again.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the cache invalidation swapping in a new comment object with the
|
||||||
|
// updated content — the static body reflects it.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem
|
||||||
|
comment={baseComment({ content: body("updated body after save") })}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||||
|
expect(screen.queryByText("original body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Type something (editContentRef set), then cancel.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
// Editor unmounts, static body restored, no save happened.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
|
||||||
|
// Cancel path clears editContentRef...
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(body("original body")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — read-only body renders statically", () => {
|
||||||
|
it("renders the comment body as static text without a TipTap editor", () => {
|
||||||
|
renderItem(
|
||||||
|
baseComment({
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello static world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Body text is present...
|
||||||
|
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||||
|
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
import ResolveComment from "@/features/comment/components/resolve-comment";
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,6 +32,10 @@ interface CommentListItemProps {
|
|||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
|
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
|
||||||
|
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
|
||||||
|
// allowed to comment cannot apply edits).
|
||||||
|
canEdit?: boolean;
|
||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +43,7 @@ function CommentListItem({
|
|||||||
comment,
|
comment,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: CommentListItemProps) {
|
}: CommentListItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -38,30 +51,43 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
useEffect(() => {
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
setContent(comment.content);
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
}, [comment]);
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||||
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||||
|
// render an action the server will 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
editContentRef.current = null;
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -95,6 +121,31 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleApplySuggestion() {
|
||||||
|
try {
|
||||||
|
await applySuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Errors surface via the mutation's onError notification (incl. 409).
|
||||||
|
console.error("Failed to apply suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDismissSuggestion() {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent races are reconciled to success in the mutation's onError;
|
||||||
|
// anything else surfaces there as a notification.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -170,7 +221,7 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{isOwnerOrAdmin && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
@@ -211,12 +262,93 @@ function CommentListItem({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
||||||
|
carrying a suggestion. Old text struck-through/red, new text green. */}
|
||||||
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
|
<Box className={classes.suggestionBlock}>
|
||||||
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{comment.suggestionAppliedAt ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
mt={6}
|
||||||
|
aria-label={t("Applied")}
|
||||||
|
>
|
||||||
|
{t("Applied")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
(canShowApply(comment, canEdit) ||
|
||||||
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||||
|
<Group gap="xs" mt={6}>
|
||||||
|
{canShowApply(comment, canEdit) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={handleApplySuggestion}
|
||||||
|
loading={applySuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||||
|
without changing the page text. Gated on canComment. */}
|
||||||
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -236,4 +368,6 @@ function CommentListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentListItem;
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||||
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||||
|
export default React.memo(CommentListItem);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||||
|
// the lazy reply editor's mount transition can be observed without the editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||||
|
// pulled in transitively so importing the module is side-effect free.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChildrenByParent,
|
||||||
|
CommentEditorWithActions,
|
||||||
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
|
expect(buildChildrenByParent([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||||
|
const map = buildChildrenByParent([c("p1", null)]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(map.has("p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||||
|
const p1 = c("p1", null);
|
||||||
|
const r1 = c("r1", "p1");
|
||||||
|
const r2 = c("r2", "r1"); // a reply to a reply
|
||||||
|
const map = buildChildrenByParent([p1, r1, r2]);
|
||||||
|
expect(map.get("p1")).toEqual([r1]);
|
||||||
|
expect(map.get("r1")).toEqual([r2]);
|
||||||
|
// The top-level comment itself is never a key.
|
||||||
|
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still groups a reply whose parent is not present in items", () => {
|
||||||
|
const orphan = c("o1", "missing-parent");
|
||||||
|
const map = buildChildrenByParent([orphan]);
|
||||||
|
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order among sibling replies", () => {
|
||||||
|
const map = buildChildrenByParent([
|
||||||
|
c("a", "p1"),
|
||||||
|
c("b", "p1"),
|
||||||
|
c("d", "p1"),
|
||||||
|
]);
|
||||||
|
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyEditor() {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||||
|
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
expect(screen.getByRole("button")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
// The stub button is replaced by the editor subtree.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor when the stub receives focus", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.focus(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor on Enter keydown of the stub", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||||
|
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||||
|
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function buildChildrenByParent(
|
||||||
|
items: IComment[] | undefined,
|
||||||
|
): Map<string, IComment[]> {
|
||||||
|
const m = new Map<string, IComment[]>();
|
||||||
|
for (const c of items ?? []) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = m.get(c.parentCommentId);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else m.set(c.parentCommentId, [c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,11 +63,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||||
|
// mutation object) so the reply/comment callbacks stay stable.
|
||||||
|
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
|
|
||||||
const canComment =
|
const canComment =
|
||||||
(page?.permissions?.canEdit ?? false) ||
|
canEdit ||
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
(space?.settings?.comments?.allowViewerComments === true);
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
@@ -73,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
|
const childrenByParent = useMemo(
|
||||||
|
() => buildChildrenByParent(comments?.items),
|
||||||
|
[comments?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -98,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
|
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||||
|
// does not churn renderComments and re-render the whole list.
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -137,13 +165,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
comment={comment}
|
comment={comment}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,13 +184,19 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
[
|
||||||
|
childrenByParent,
|
||||||
|
handleAddReply,
|
||||||
|
page?.id,
|
||||||
|
space?.membership?.role,
|
||||||
|
canComment,
|
||||||
|
canEdit,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
@@ -192,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||||
|
// unmounted while Open is shown). The Open panel overrides this with its
|
||||||
|
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||||
|
// Open -> Resolved -> Open switch.
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -250,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||||
|
active, so a lazily-mounted reply editor's draft (and an
|
||||||
|
in-progress edit) is not discarded on tab switch. */}
|
||||||
|
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -296,42 +340,40 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
placeholder = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||||
|
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||||
|
// mounted so the draft is preserved.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
setContent("");
|
setContent("");
|
||||||
commentEditorRef.current?.clearContent();
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={activate}
|
||||||
|
onFocus={activate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--mantine-color-placeholder)",
|
||||||
|
cursor: "text",
|
||||||
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder || t("Reply...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -366,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,53 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suggested-edit (#315) "было → стало" diff block. */
|
||||||
|
.suggestionBlock {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionOld {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--mantine-color-red-7);
|
||||||
|
background: var(--mantine-color-red-light);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionNew {
|
||||||
|
color: var(--mantine-color-green-9);
|
||||||
|
background: var(--mantine-color-green-light);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||||
|
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||||
|
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||||
|
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
applySuggestion,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
suggestedText: "new",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
suggestionAppliedAt: resolvedAt,
|
||||||
|
suggestionAppliedById: "u-1",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||||
|
// silently drop the comment.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||||
|
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||||
|
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||||
|
// and keep the comment in the cache.
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 400 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Comment NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// A real (red) error, never the success message.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ color: "red" }),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||||
|
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||||
|
// error (restores the #315 apply idempotency).
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||||
|
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||||
|
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||||
|
// real error surfacing the server message, and must NOT drop the live thread.
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// The live thread is NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// Surfaces the server's specific message as a red error, never a success.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
InfiniteData,
|
InfiniteData,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
dismissSuggestion,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@@ -50,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: query.isLoading || query.hasNextPage,
|
// Paint the first page as soon as it arrives instead of blocking until every
|
||||||
|
// page has loaded; the background effect above keeps streaming the rest
|
||||||
|
// (tab counts grow as pages arrive).
|
||||||
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -176,6 +182,196 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCommentFromCache(
|
||||||
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
|
commentId: string,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||||
|
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||||
|
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||||
|
// state through (which relocates it to the resolved tab).
|
||||||
|
function applySuggestionOutcomeToCache(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
pageId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: ISuggestionOutcome,
|
||||||
|
) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
if (data.outcome === "deleted") {
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||||
|
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(pageId),
|
||||||
|
updateCommentInCache(cache, commentId, (comment) => ({
|
||||||
|
...comment,
|
||||||
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApplySuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
|
// so we only mutate the cache once the server confirms.
|
||||||
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||||
|
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||||
|
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||||
|
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||||
|
// and report success, the user's intent is already satisfied (restores the
|
||||||
|
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||||
|
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||||
|
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||||
|
// applied" is a false success that also drops a live thread from the cache.
|
||||||
|
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||||
|
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||||
|
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||||
|
// the server's specific message when it carries one.
|
||||||
|
if (status === 400) {
|
||||||
|
const serverMsg = err?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
typeof serverMsg === "string" && serverMsg.length > 0
|
||||||
|
? serverMsg
|
||||||
|
: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
|
// a specific message (with the current text) rather than a generic error.
|
||||||
|
const currentText = err?.response?.data?.currentText;
|
||||||
|
if (status === 409 && typeof currentText === "string") {
|
||||||
|
const shortText =
|
||||||
|
currentText.length > 80
|
||||||
|
? `${currentText.slice(0, 80)}…`
|
||||||
|
: currentText;
|
||||||
|
notifications.show({
|
||||||
|
title: t(
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
),
|
||||||
|
message: shortText,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDismissSuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||||
|
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||||
|
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||||
|
// report success, the user's intent (make it disappear) is satisfied.
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||||
|
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||||
|
// a live thread from the cache. Show a real error and keep the comment.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to dismiss suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
@@ -18,6 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applySuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||||
|
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||||
|
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export interface IComment {
|
|||||||
createdSource?: string;
|
createdSource?: string;
|
||||||
aiChatId?: string | null;
|
aiChatId?: string | null;
|
||||||
resolvedSource?: string | null;
|
resolvedSource?: string | null;
|
||||||
|
// Suggested-edit (#315): when an agent proposes a replacement for the
|
||||||
|
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
|
||||||
|
// applies it server-side the backend stamps `suggestionAppliedAt` /
|
||||||
|
// `suggestionAppliedById` and auto-resolves the thread.
|
||||||
|
suggestedText?: string | null;
|
||||||
|
suggestionAppliedAt?: Date | string | null;
|
||||||
|
suggestionAppliedById?: string | null;
|
||||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
||||||
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
||||||
// human behind it (null for an external MCP agent).
|
// human behind it (null for an external MCP agent).
|
||||||
@@ -53,6 +60,15 @@ export interface IResolveComment {
|
|||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||||
|
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||||
|
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||||
|
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||||
|
// take (drop the comment vs. move it to the resolved tab).
|
||||||
|
export type ISuggestionOutcome = IComment & {
|
||||||
|
outcome?: "deleted" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ICommentParams extends QueryParams {
|
export interface ICommentParams extends QueryParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
|
// comment: it must carry a suggestion, not already be applied or resolved, be a
|
||||||
|
// top-level comment, and the viewer must be able to edit the page.
|
||||||
|
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canEdit &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||||
|
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||||
|
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||||
|
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||||
|
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||||
|
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||||
|
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||||
|
export function canShowDismiss(
|
||||||
|
comment: IComment,
|
||||||
|
canComment?: boolean,
|
||||||
|
isOwnerOrAdmin?: boolean,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canComment &&
|
||||||
|
isOwnerOrAdmin &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// A disabled mic must explain WHY it is unavailable rather than silently saying
|
||||||
|
// "Start dictation". This renders MicButton in its idle+disabled state with a
|
||||||
|
// forwarded reason and asserts the accessible label resolves to that reason's
|
||||||
|
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// Pass i18n keys through verbatim so we assert the exact resolved string.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (s: string) => s }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep both controllers inert and idle so MicButton renders the idle branch.
|
||||||
|
const idleCtl = {
|
||||||
|
status: "idle" as const,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
audioLevel: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
|
||||||
|
useDictation: () => idleCtl,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
|
||||||
|
useStreamingDictation: () => idleCtl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MicButton } from "./mic-button";
|
||||||
|
|
||||||
|
function renderButton(props: React.ComponentProps<typeof MicButton>) {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MicButton {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MicButton — disabled reason label", () => {
|
||||||
|
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
|
||||||
|
// report "unsupported" and mask the forwarded reason. Stub both so the button
|
||||||
|
// is considered supported and the availability reason is what surfaces.
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
|
||||||
|
class {};
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia: vi.fn() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
|
||||||
|
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
|
||||||
|
const expected =
|
||||||
|
"No connection to the collaboration server — dictation unavailable";
|
||||||
|
// The reason surfaces as the accessible label (and the tooltip text).
|
||||||
|
const button = screen.getByRole("button", { name: expected });
|
||||||
|
expect(button).toBeDefined();
|
||||||
|
// It is marked disabled the Mantine way (data-disabled), NOT the native
|
||||||
|
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
|
||||||
|
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(false);
|
||||||
|
// And it no longer silently reads "Start dictation".
|
||||||
|
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'Start dictation' when enabled with no reason", () => {
|
||||||
|
renderButton({ onText: () => {} });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Start dictation" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not advertise 'Start dictation' when disabled with no reason", () => {
|
||||||
|
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
|
||||||
|
// unavailableReason must not get a hoverable mic whose tooltip invites
|
||||||
|
// "Start dictation" on a click that is rejected.
|
||||||
|
renderButton({ onText: () => {}, disabled: true });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Start dictation" }),
|
||||||
|
).toBeNull();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||||
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||||
|
import {
|
||||||
|
isDictationSupported,
|
||||||
|
resolveUnavailableLabel,
|
||||||
|
type DictationUnavailableReason,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
import classes from "./mic-button.module.css";
|
import classes from "./mic-button.module.css";
|
||||||
|
|
||||||
interface MicButtonProps {
|
interface MicButtonProps {
|
||||||
@@ -21,6 +26,9 @@ interface MicButtonProps {
|
|||||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||||
// text progressively as the user pauses; otherwise use the batch controller.
|
// text progressively as the user pauses; otherwise use the batch controller.
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
// When the mic is disabled for an availability reason, this is the cause the
|
||||||
|
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
|
||||||
|
unavailableReason?: DictationUnavailableReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +45,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
color,
|
color,
|
||||||
iconSize,
|
iconSize,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
|
unavailableReason,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||||
@@ -46,7 +55,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
const batchCtl = useDictation({ onText, onStart });
|
const batchCtl = useDictation({ onText, onStart });
|
||||||
const streamingCtl = useStreamingDictation({ onText, onStart });
|
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||||
const ctl = streaming ? streamingCtl : batchCtl;
|
const ctl = streaming ? streamingCtl : batchCtl;
|
||||||
const { status, start, stop, audioLevel } = ctl;
|
const { status, start, stop, audioLevel, errorMessage } = ctl;
|
||||||
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||||
|
|
||||||
if (status === "recording") {
|
if (status === "recording") {
|
||||||
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
) {
|
) {
|
||||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||||
// a confusing second click can't fire while the model loads.
|
// a confusing second click can't fire while the model loads. The error case
|
||||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
// explains the failure via the hook's resolved errorMessage instead of the
|
||||||
|
// transient "Transcribing…" label.
|
||||||
|
const label =
|
||||||
|
status === "error"
|
||||||
|
? (errorMessage ?? t("Transcription failed"))
|
||||||
|
: status === "loading"
|
||||||
|
? t("Preparing…")
|
||||||
|
: t("Transcribing…");
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} withArrow>
|
<Tooltip label={label} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={size}
|
size={size}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={color}
|
color={color}
|
||||||
disabled
|
// Mark disabled the Mantine way (data-disabled/aria-disabled) rather
|
||||||
|
// than the native `disabled` attribute: native `disabled` sets
|
||||||
|
// `pointer-events:none`, which suppresses hover so the Tooltip never
|
||||||
|
// fires. This is a status display with no click action to guard, so
|
||||||
|
// keeping it hoverable simply lets the error reason be read on hover.
|
||||||
|
data-disabled
|
||||||
|
aria-disabled
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
@@ -99,18 +121,56 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
|
||||||
<Tooltip label={t("Start dictation")} withArrow>
|
// unsupported browser/context is detected here; otherwise the parent forwards
|
||||||
|
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
|
||||||
|
// renders `<button disabled>` with `pointer-events:none`, which suppresses
|
||||||
|
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
|
||||||
|
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
|
||||||
|
// and guard the click ourselves.
|
||||||
|
const unsupported = !isDictationSupported();
|
||||||
|
const isDisabled = disabled || unsupported;
|
||||||
|
const reason: DictationUnavailableReason | undefined = unsupported
|
||||||
|
? "unsupported"
|
||||||
|
: unavailableReason;
|
||||||
|
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
|
||||||
|
// A disabled mic with a known reason surfaces it on hover; an enabled mic
|
||||||
|
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
|
||||||
|
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
|
||||||
|
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
|
||||||
|
// tooltip on a control that rejects the click. In that case we render the icon
|
||||||
|
// without a Tooltip and give it a neutral accessible label instead.
|
||||||
|
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
|
||||||
|
const icon = (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={size}
|
size={size}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={color}
|
color={color}
|
||||||
onClick={() => void start()}
|
onClick={(e) => {
|
||||||
disabled={disabled}
|
if (isDisabled) {
|
||||||
aria-label={t("Start dictation")}
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void start();
|
||||||
|
}}
|
||||||
|
data-disabled={isDisabled || undefined}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<IconMicrophone size={resolvedIconSize} />
|
<IconMicrophone size={resolvedIconSize} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
|
||||||
|
// a grey, unclickable mic should not advertise "Start dictation".
|
||||||
|
if (isDisabled && !reasonLabel) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={reasonLabel ?? t("Start dictation")}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
resolveUnavailableLabel,
|
||||||
|
isDictationSupported,
|
||||||
|
} from "./dictation-status";
|
||||||
|
|
||||||
|
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
|
||||||
|
// Both dictation hooks and the mic button form their user-facing strings here,
|
||||||
|
// so a regression in the classification or message mapping would silently swap
|
||||||
|
// what a user reads when the mic is grey or a recording fails. A fake `t`
|
||||||
|
// returns its key verbatim so we assert the exact i18n key each branch selects.
|
||||||
|
const t = (k: string) => k;
|
||||||
|
|
||||||
|
describe("classifyGetUserMediaError", () => {
|
||||||
|
it("maps NotAllowedError / SecurityError to mic-denied", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
|
||||||
|
"mic-denied",
|
||||||
|
);
|
||||||
|
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
|
||||||
|
"mic-denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
|
||||||
|
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
|
||||||
|
"no-mic",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps NotReadableError / AbortError to mic-in-use", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
|
||||||
|
"mic-in-use",
|
||||||
|
);
|
||||||
|
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
|
||||||
|
"mic-in-use",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps anything else / undefined to unknown", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
|
||||||
|
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
|
||||||
|
expect(classifyGetUserMediaError({})).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyTranscriptionError", () => {
|
||||||
|
it("returns the verbatim server message when present", () => {
|
||||||
|
const err = { response: { status: 500, data: { message: "provider 404" } } };
|
||||||
|
expect(classifyTranscriptionError(err)).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
serverMessage: "provider 404",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
|
||||||
|
code: "stt-not-configured",
|
||||||
|
});
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
|
||||||
|
code: "stt-not-configured",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to transcription-failed with no server message otherwise", () => {
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
});
|
||||||
|
expect(classifyTranscriptionError(new Error("network"))).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
});
|
||||||
|
// Blank server message is ignored (does not win as verbatim text).
|
||||||
|
expect(
|
||||||
|
classifyTranscriptionError({ response: { data: { message: " " } } }),
|
||||||
|
).toEqual({ code: "transcription-failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dictationErrorMessage", () => {
|
||||||
|
it("maps each code to the expected i18n key", () => {
|
||||||
|
expect(dictationErrorMessage("mic-denied", t)).toBe(
|
||||||
|
"Microphone access denied",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
|
||||||
|
expect(dictationErrorMessage("mic-in-use", t)).toBe(
|
||||||
|
"Microphone is unavailable or already in use",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("no-media-devices", t)).toBe(
|
||||||
|
"Audio recording is not available in this browser/context",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
|
||||||
|
"Voice dictation is not configured",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("transcription-failed", t)).toBe(
|
||||||
|
"Transcription failed",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("recorder-failed", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("unknown", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("transcription-failed", t, {
|
||||||
|
serverMessage: "quota exceeded",
|
||||||
|
}),
|
||||||
|
).toBe("quota exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the detail to recorder-failed / unknown", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
|
||||||
|
).toBe("Could not start recording: boom");
|
||||||
|
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
|
||||||
|
"Could not start recording: nope",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the detail to transcription-failed when there is no server message", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
|
||||||
|
).toBe("Transcription failed: timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveUnavailableLabel", () => {
|
||||||
|
it("maps each reason to its expected i18n key", () => {
|
||||||
|
expect(resolveUnavailableLabel("connecting", t)).toBe(
|
||||||
|
"Dictation becomes available once the page finishes connecting",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("offline", t)).toBe(
|
||||||
|
"No connection to the collaboration server — dictation unavailable",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("read-only", t)).toBe(
|
||||||
|
"This page is read-only",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("unsupported", t)).toBe(
|
||||||
|
"Audio recording is not available in this browser/context",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDictationSupported", () => {
|
||||||
|
it("returns a boolean", () => {
|
||||||
|
expect(typeof isDictationSupported()).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Single source of truth for "why dictation is unavailable" and "why it failed".
|
||||||
|
// Both dictation hooks and the mic button pull their user-facing strings from
|
||||||
|
// the resolvers here so the wording lives in exactly one place.
|
||||||
|
|
||||||
|
export type DictationUnavailableReason =
|
||||||
|
| "connecting"
|
||||||
|
| "offline"
|
||||||
|
| "read-only"
|
||||||
|
| "unsupported";
|
||||||
|
|
||||||
|
export type DictationErrorCode =
|
||||||
|
| "no-media-devices"
|
||||||
|
| "mic-denied"
|
||||||
|
| "no-mic"
|
||||||
|
| "mic-in-use"
|
||||||
|
| "recorder-failed"
|
||||||
|
| "vad-init-failed"
|
||||||
|
| "stt-not-configured"
|
||||||
|
| "transcription-failed"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
// True if this browser/context can record audio.
|
||||||
|
export function isDictationSupported(): boolean {
|
||||||
|
return (
|
||||||
|
typeof MediaRecorder !== "undefined" &&
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
!!navigator.mediaDevices?.getUserMedia
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
|
||||||
|
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
|
||||||
|
const name = (err as { name?: string })?.name;
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError")
|
||||||
|
return "mic-denied";
|
||||||
|
if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||||
|
return "no-mic";
|
||||||
|
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcription HTTP failure -> code (+ verbatim server message when present).
|
||||||
|
export function classifyTranscriptionError(err: unknown): {
|
||||||
|
code: DictationErrorCode;
|
||||||
|
serverMessage?: string;
|
||||||
|
} {
|
||||||
|
const resp = (
|
||||||
|
err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
)?.response;
|
||||||
|
const serverMessage = resp?.data?.message;
|
||||||
|
if (serverMessage && serverMessage.trim().length > 0)
|
||||||
|
return { code: "transcription-failed", serverMessage };
|
||||||
|
if (resp?.status === 503 || resp?.status === 403)
|
||||||
|
return { code: "stt-not-configured" };
|
||||||
|
return { code: "transcription-failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFn = (key: string) => string;
|
||||||
|
|
||||||
|
// Code -> user text. The ONE place runtime error strings are formed.
|
||||||
|
// serverMessage (verbatim) wins for transcription-failed; detail is appended
|
||||||
|
// to the generic "could not start"/"transcription failed" strings.
|
||||||
|
export function dictationErrorMessage(
|
||||||
|
code: DictationErrorCode,
|
||||||
|
t: TFn,
|
||||||
|
extra?: { serverMessage?: string; detail?: string },
|
||||||
|
): string {
|
||||||
|
const detail = extra?.detail;
|
||||||
|
switch (code) {
|
||||||
|
case "mic-denied":
|
||||||
|
return t("Microphone access denied");
|
||||||
|
case "no-mic":
|
||||||
|
return t("No microphone found");
|
||||||
|
case "mic-in-use":
|
||||||
|
return t("Microphone is unavailable or already in use");
|
||||||
|
case "no-media-devices":
|
||||||
|
return t("Audio recording is not available in this browser/context");
|
||||||
|
case "stt-not-configured":
|
||||||
|
return t("Voice dictation is not configured");
|
||||||
|
case "transcription-failed":
|
||||||
|
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
|
||||||
|
return extra.serverMessage;
|
||||||
|
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
|
||||||
|
case "recorder-failed":
|
||||||
|
case "vad-init-failed":
|
||||||
|
case "unknown":
|
||||||
|
default:
|
||||||
|
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
|
||||||
|
export function resolveUnavailableLabel(
|
||||||
|
r: DictationUnavailableReason,
|
||||||
|
t: TFn,
|
||||||
|
): string {
|
||||||
|
switch (r) {
|
||||||
|
case "connecting":
|
||||||
|
return t("Dictation becomes available once the page finishes connecting");
|
||||||
|
case "offline":
|
||||||
|
return t(
|
||||||
|
"No connection to the collaboration server — dictation unavailable",
|
||||||
|
);
|
||||||
|
case "read-only":
|
||||||
|
return t("This page is read-only");
|
||||||
|
case "unsupported":
|
||||||
|
default:
|
||||||
|
return t("Audio recording is not available in this browser/context");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||||
@@ -26,6 +31,8 @@ interface UseDictationResult {
|
|||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||||
audioLevel: number;
|
audioLevel: number;
|
||||||
|
// The last error shown to the user (null until one occurs / on a new start).
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate container/codec combinations in preference order. The first one the
|
// Candidate container/codec combinations in preference order. The first one the
|
||||||
@@ -67,6 +74,8 @@ export function useDictation(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||||
// calls the current handlers without re-creating the recorder.
|
// calls the current handlers without re-creating the recorder.
|
||||||
@@ -194,15 +203,16 @@ export function useDictation(
|
|||||||
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
||||||
if (status !== "idle") return;
|
if (status !== "idle") return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
|
// Clear any stale error from a previous attempt.
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
const reason =
|
const reason =
|
||||||
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
||||||
console.error("[dictation] " + reason);
|
console.error("[dictation] " + reason);
|
||||||
notifications.show({
|
const message = dictationErrorMessage("no-media-devices", t);
|
||||||
color: "red",
|
notifications.show({ color: "red", message });
|
||||||
message: t("Audio recording is not available in this browser/context"),
|
setErrorMessage(message);
|
||||||
});
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -215,19 +225,16 @@ export function useDictation(
|
|||||||
// Always log the full error for diagnosis (name, message, stack).
|
// Always log the full error for diagnosis (name, message, stack).
|
||||||
console.error("[dictation] getUserMedia failed", err);
|
console.error("[dictation] getUserMedia failed", err);
|
||||||
const name = (err as { name?: string })?.name;
|
const name = (err as { name?: string })?.name;
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const rawDetail = (err as { message?: string })?.message ?? String(err);
|
||||||
let message: string;
|
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
|
||||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
// resolver branch reproduces this hook's original "Could not start
|
||||||
message = t("Microphone access denied");
|
// recording: <name>: <detail>" text. Each caller owns its own detail; the
|
||||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
// streaming hook intentionally does not add the name.
|
||||||
message = t("No microphone found");
|
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
|
||||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
const code = classifyGetUserMediaError(err);
|
||||||
message = t("Microphone is unavailable or already in use");
|
const message = dictationErrorMessage(code, t, { detail });
|
||||||
} else {
|
|
||||||
// Unknown failure: show the real reason instead of a generic string.
|
|
||||||
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -249,10 +256,10 @@ export function useDictation(
|
|||||||
// The stream was acquired but the recorder failed to construct; stop the
|
// The stream was acquired but the recorder failed to construct; stop the
|
||||||
// tracks so the MediaStream does not leak before bailing out.
|
// tracks so the MediaStream does not leak before bailing out.
|
||||||
stopTracks();
|
stopTracks();
|
||||||
notifications.show({
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
color: "red",
|
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
notifications.show({ color: "red", message });
|
||||||
});
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -293,21 +300,14 @@ export function useDictation(
|
|||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// Log the full error for diagnosis (status + body + stack).
|
// Log the full error for diagnosis (status + body + stack).
|
||||||
console.error("[dictation] transcription failed", err);
|
console.error("[dictation] transcription failed", err);
|
||||||
const resp = (
|
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||||
err as { response?: { status?: number; data?: { message?: string } } }
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
)?.response;
|
const message = dictationErrorMessage(code, t, {
|
||||||
const serverMsg = resp?.data?.message;
|
serverMessage,
|
||||||
let message: string;
|
detail,
|
||||||
if (serverMsg && serverMsg.trim().length > 0) {
|
});
|
||||||
// The server already explains the cause (e.g. provider 404, bad
|
|
||||||
// format, STT not configured) — show it verbatim.
|
|
||||||
message = serverMsg;
|
|
||||||
} else if (resp?.status === 503 || resp?.status === 403) {
|
|
||||||
message = t("Voice dictation is not configured");
|
|
||||||
} else {
|
|
||||||
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
if (errorTimerRef.current !== null) {
|
if (errorTimerRef.current !== null) {
|
||||||
clearTimeout(errorTimerRef.current);
|
clearTimeout(errorTimerRef.current);
|
||||||
@@ -332,10 +332,10 @@ export function useDictation(
|
|||||||
stopTracks();
|
stopTracks();
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
notifications.show({
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
color: "red",
|
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
notifications.show({ color: "red", message });
|
||||||
});
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -405,5 +405,5 @@ export function useDictation(
|
|||||||
};
|
};
|
||||||
}, [clearTimer, stopTracks, stopMeter]);
|
}, [clearTimer, stopTracks, stopMeter]);
|
||||||
|
|
||||||
return { status, start, stop, cancel, audioLevel };
|
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||||
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||||
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
|
|||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||||
audioLevel: number;
|
audioLevel: number;
|
||||||
|
// The last error shown to the user (null until one occurs / on a new start).
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||||
@@ -60,6 +67,8 @@ export function useStreamingDictation(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||||
// current handlers without re-creating the VAD.
|
// current handlers without re-creating the VAD.
|
||||||
@@ -158,26 +167,6 @@ export function useStreamingDictation(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
|
||||||
const transcriptionErrorMessage = useCallback(
|
|
||||||
(err: unknown): string => {
|
|
||||||
const resp = (
|
|
||||||
err as { response?: { status?: number; data?: { message?: string } } }
|
|
||||||
)?.response;
|
|
||||||
const serverMsg = resp?.data?.message;
|
|
||||||
if (serverMsg && serverMsg.trim().length > 0) {
|
|
||||||
// The server already explains the cause (e.g. provider 404, bad format,
|
|
||||||
// STT not configured) — show it verbatim.
|
|
||||||
return serverMsg;
|
|
||||||
}
|
|
||||||
if (resp?.status === 503 || resp?.status === 403) {
|
|
||||||
return t("Voice dictation is not configured");
|
|
||||||
}
|
|
||||||
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||||
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||||
// the session: log + one notification, then advance past that seq so later
|
// the session: log + one notification, then advance past that seq so later
|
||||||
@@ -204,10 +193,14 @@ export function useStreamingDictation(
|
|||||||
if (epoch !== epochRef.current) return;
|
if (epoch !== epochRef.current) return;
|
||||||
// Log the full error for diagnosis (status + body + stack).
|
// Log the full error for diagnosis (status + body + stack).
|
||||||
console.error("[dictation] segment transcription failed", err);
|
console.error("[dictation] segment transcription failed", err);
|
||||||
notifications.show({
|
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||||
color: "red",
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
message: transcriptionErrorMessage(err),
|
const message = dictationErrorMessage(code, t, {
|
||||||
|
serverMessage,
|
||||||
|
detail,
|
||||||
});
|
});
|
||||||
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
// Skip this seq so later segments can still flush in order.
|
// Skip this seq so later segments can still flush in order.
|
||||||
if (nextEmitSeqRef.current === seq) {
|
if (nextEmitSeqRef.current === seq) {
|
||||||
nextEmitSeqRef.current += 1;
|
nextEmitSeqRef.current += 1;
|
||||||
@@ -226,7 +219,7 @@ export function useStreamingDictation(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[drainResults, transcriptionErrorMessage],
|
[drainResults, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async (): Promise<void> => {
|
const start = useCallback(async (): Promise<void> => {
|
||||||
@@ -236,6 +229,8 @@ export function useStreamingDictation(
|
|||||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||||
if (status !== "idle") return;
|
if (status !== "idle") return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
|
// Clear any stale error from a previous attempt.
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
// Notify the caller right when dictation begins (before any async work) so the
|
// Notify the caller right when dictation begins (before any async work) so the
|
||||||
// editor can snapshot the caret position.
|
// editor can snapshot the caret position.
|
||||||
@@ -354,10 +349,9 @@ export function useStreamingDictation(
|
|||||||
// actually runs.)
|
// actually runs.)
|
||||||
console.error("[dictation] VAD init failed", err);
|
console.error("[dictation] VAD init failed", err);
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
notifications.show({
|
const message = dictationErrorMessage("vad-init-failed", t, { detail });
|
||||||
color: "red",
|
notifications.show({ color: "red", message });
|
||||||
message: `${t("Could not start recording")}: ${detail}`,
|
setErrorMessage(message);
|
||||||
});
|
|
||||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||||
// don't leak it.
|
// don't leak it.
|
||||||
destroyVad();
|
destroyVad();
|
||||||
@@ -379,19 +373,11 @@ export function useStreamingDictation(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Always log the full error for diagnosis (name, message, stack).
|
// Always log the full error for diagnosis (name, message, stack).
|
||||||
console.error("[dictation] VAD.start failed", err);
|
console.error("[dictation] VAD.start failed", err);
|
||||||
const name = (err as { name?: string })?.name;
|
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
let message: string;
|
const code = classifyGetUserMediaError(err);
|
||||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
const message = dictationErrorMessage(code, t, { detail });
|
||||||
message = t("Microphone access denied");
|
|
||||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
|
||||||
message = t("No microphone found");
|
|
||||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
|
||||||
message = t("Microphone is unavailable or already in use");
|
|
||||||
} else {
|
|
||||||
message = `${t("Could not start recording")}: ${detail}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
activeRef.current = false;
|
activeRef.current = false;
|
||||||
destroyVad();
|
destroyVad();
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
@@ -470,5 +456,5 @@ export function useStreamingDictation(
|
|||||||
};
|
};
|
||||||
}, [clearTimer, destroyVad]);
|
}, [clearTimer, destroyVad]);
|
||||||
|
|
||||||
return { status, start, stop, cancel, audioLevel };
|
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
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";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
@@ -15,3 +16,15 @@ export const showLinkMenuAtom = atom(false);
|
|||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
// first load, can be toggled locally without persisting to the server.
|
// first load, can be toggled locally without persisting to the server.
|
||||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
||||||
|
|
||||||
|
// Whether the dictation mic can start, and (when it can't) the cause-specific
|
||||||
|
// reason the mic button surfaces as a tooltip. Published by the page editor,
|
||||||
|
// consumed by DictationGroup -> MicButton.
|
||||||
|
export type DictationAvailability = {
|
||||||
|
isEditable: boolean;
|
||||||
|
reason: DictationUnavailableReason | null;
|
||||||
|
};
|
||||||
|
export const dictationAvailabilityAtom = atom<DictationAvailability>({
|
||||||
|
isEditable: false,
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
|
||||||
|
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
|
||||||
|
// page the user can edit, the mic must un-grey once the body becomes editable.
|
||||||
|
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
|
||||||
|
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
|
||||||
|
// publishes (carrying both the editable gate AND the unavailable reason). The mic
|
||||||
|
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
|
||||||
|
// re-render the group and flip the disabled state (jotai drives the subscription).
|
||||||
|
|
||||||
|
// Detectable stand-in that surfaces the `disabled` prop the component computes.
|
||||||
|
vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||||
|
MicButton: ({ disabled }: any) => (
|
||||||
|
<button data-testid="mic" disabled={disabled} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DictationGroup } from "./dictation-group";
|
||||||
|
|
||||||
|
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
|
||||||
|
// handleText). The disabled gate no longer reads this — it reads the atom.
|
||||||
|
function makeFakeEditor() {
|
||||||
|
return {
|
||||||
|
isEditable: false,
|
||||||
|
isDestroyed: false,
|
||||||
|
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
|
||||||
|
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
|
||||||
|
const editor = makeFakeEditor();
|
||||||
|
const store = createStore();
|
||||||
|
// Pre-sync: page editor publishes not-editable (with a reason).
|
||||||
|
store.set(dictationAvailabilityAtom, {
|
||||||
|
isEditable: false,
|
||||||
|
reason: "connecting",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<DictationGroup editor={editor} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
|
||||||
|
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
|
||||||
|
|
||||||
|
// Collab sync -> page editor republishes editable; the atom change must
|
||||||
|
// re-render the group and enable the mic.
|
||||||
|
act(() => {
|
||||||
|
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FC, useRef } from "react";
|
import { FC, useRef } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,6 +17,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
|||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const streamingDictation =
|
const streamingDictation =
|
||||||
workspace?.settings?.ai?.dictationStreaming === true;
|
workspace?.settings?.ai?.dictationStreaming === true;
|
||||||
|
// Cause-specific reason the mic is unavailable (published by the page editor).
|
||||||
|
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
|
||||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||||
// Running insertion point: after each inserted segment we remember the caret
|
// Running insertion point: after each inserted segment we remember the caret
|
||||||
@@ -80,7 +83,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
|||||||
streaming={streamingDictation}
|
streaming={streamingDictation}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
onText={handleText}
|
onText={handleText}
|
||||||
disabled={!editor.isEditable}
|
disabled={!dictationAvailability.isEditable}
|
||||||
|
unavailableReason={dictationAvailability.reason ?? undefined}
|
||||||
color={color}
|
color={color}
|
||||||
iconSize={iconSize}
|
iconSize={iconSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
slugId?: string;
|
||||||
|
anchorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||||
|
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||||
|
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||||
|
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||||
|
const { label, entityType, slugId, anchorId } = attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
import {
|
||||||
|
isCollabSynced,
|
||||||
|
isBodyEditable,
|
||||||
|
computeDictationAvailability,
|
||||||
|
} from "./editor-sync-state";
|
||||||
|
|
||||||
describe("isCollabSynced", () => {
|
describe("isCollabSynced", () => {
|
||||||
it("is true only when Connected and synced", () => {
|
it("is true only when Connected and synced", () => {
|
||||||
@@ -30,3 +34,77 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
|||||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
|
||||||
|
const base = {
|
||||||
|
editable: true,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: false,
|
||||||
|
isDisconnected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is available with no reason once synced (showStatic false)", () => {
|
||||||
|
expect(computeDictationAvailability(base)).toEqual({
|
||||||
|
isEditable: true,
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'offline' during pre-sync while disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
...base,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "offline" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'connecting' during pre-sync while still connecting", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
...base,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "connecting" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'read-only' without edit permission", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({ ...base, editable: false }),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'read-only' when not in edit mode", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({ ...base, inEditMode: false }),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lack of edit permission takes precedence over the pre-sync reason: a
|
||||||
|
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
|
||||||
|
// still read "read-only", never "offline"/"connecting". This pins the
|
||||||
|
// `opts.editable &&` guard on the pre-sync branch.
|
||||||
|
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable: false,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable: false,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collab document is usable only once the provider is Connected AND has
|
* The collab document is usable only once the provider is Connected AND has
|
||||||
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether dictation can start and, when it can't, the cause-specific reason the
|
||||||
|
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
|
||||||
|
* tested gate) so the published `isEditable` can never diverge from the actual
|
||||||
|
* body-editable state and make the tooltip lie (#309).
|
||||||
|
*
|
||||||
|
* `isDisconnected` is the caller's own boolean (collab connection is in the
|
||||||
|
* Disconnected state), passed in so this module stays free of the collab enum.
|
||||||
|
*/
|
||||||
|
export function computeDictationAvailability(opts: {
|
||||||
|
editable: boolean;
|
||||||
|
inEditMode: boolean;
|
||||||
|
showStatic: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
|
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
|
||||||
|
const isEditable = isBodyEditable({
|
||||||
|
editable: opts.editable,
|
||||||
|
inEditMode: opts.inEditMode,
|
||||||
|
showStatic: opts.showStatic,
|
||||||
|
});
|
||||||
|
if (isEditable) return { isEditable, reason: null };
|
||||||
|
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
|
||||||
|
if (opts.editable && opts.inEditMode && opts.showStatic) {
|
||||||
|
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
|
||||||
|
}
|
||||||
|
// No edit permission or not in edit mode.
|
||||||
|
return { isEditable, reason: "read-only" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||||
|
// below the just-inserted audio node. The audio insert already
|
||||||
|
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||||
|
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||||
|
// non-string transcript is a no-op inside the helper (audio only).
|
||||||
|
try {
|
||||||
|
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gitmost] transcript insert failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, pageId: page.id };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
|
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||||
|
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||||
|
* regression would be caught), asserting the resulting document rather than
|
||||||
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
|
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||||
|
* paragraphs; absent/empty/non-string -> no-op.
|
||||||
|
*/
|
||||||
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
|
const makeEditor = () =>
|
||||||
|
new Editor({
|
||||||
|
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||||
|
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||||
|
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||||
|
// text assertions below would fail.
|
||||||
|
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||||
|
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||||
|
// helper appends at the end of the doc, i.e. below existing content.
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||||
|
// A level-2 "Transcript" heading is present.
|
||||||
|
const heading = nodes.find((n) => n.type === "heading");
|
||||||
|
expect(heading?.attrs?.level).toBe(2);
|
||||||
|
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||||
|
|
||||||
|
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||||
|
// the blank line between them is dropped.
|
||||||
|
const texts = nodes
|
||||||
|
.filter((n) => n.type === "paragraph")
|
||||||
|
.map((n) => n.content?.[0]?.text)
|
||||||
|
.filter((t) => typeof t === "string");
|
||||||
|
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const line =
|
||||||
|
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const paras = (editor.getJSON().content ?? []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
) as any[];
|
||||||
|
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||||
|
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||||
|
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||||
|
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||||
|
const content = paras[paras.length - 1].content;
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[0].marks ?? []).toEqual([]);
|
||||||
|
expect(content[0].text).toBe(line);
|
||||||
|
// And no bold/italic/link mark exists anywhere in the document.
|
||||||
|
const html = editor.getHTML();
|
||||||
|
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||||
|
// The angle brackets survived as escaped entities (literal text), not a live
|
||||||
|
// <script>/<b> element.
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
[
|
||||||
|
"- dash",
|
||||||
|
" > quote", // leading indent must be trimmed then neutralized
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"You: normal line",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const texts = (editor.getJSON().content ?? [])
|
||||||
|
.filter((n: any) => n.type === "paragraph")
|
||||||
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
|
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||||
|
// trimmed first); the normal `You:` line is left byte-exact.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
ZWSP + "- dash",
|
||||||
|
ZWSP + "> quote",
|
||||||
|
ZWSP + "# hash",
|
||||||
|
ZWSP + "1. one",
|
||||||
|
ZWSP + "> [!info] note",
|
||||||
|
ZWSP + "```js",
|
||||||
|
ZWSP + "---",
|
||||||
|
ZWSP + "***",
|
||||||
|
ZWSP + "___",
|
||||||
|
"You: normal line",
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||||
|
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const before = JSON.stringify(editor.getJSON());
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||||
|
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
// Document is untouched (audio-only behavior preserved).
|
||||||
|
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
|||||||
base64: string;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||||
|
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||||
|
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||||
|
// audio only.
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitmostCreatePageResult {
|
export interface GitmostCreatePageResult {
|
||||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||||
|
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||||
|
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||||
|
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||||
|
const GITMOST_ZWSP = "";
|
||||||
|
|
||||||
|
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||||
|
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||||
|
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||||
|
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||||
|
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||||
|
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||||
|
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||||
|
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||||
|
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||||
|
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||||
|
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||||
|
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||||
|
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||||
|
// and never matches, so prefixed lines are left byte-exact.
|
||||||
|
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
|
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||||
|
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||||
|
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||||
|
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||||
|
// serializer's missing block-escape). This is best-effort and meant to run
|
||||||
|
// AFTER the audio has already been inserted; the caller must guard against a
|
||||||
|
// throw so a transcript failure never fails the (already successful) recording.
|
||||||
|
// Returns true when a block was inserted, false when there was nothing to
|
||||||
|
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||||
|
// no-op, not an error.
|
||||||
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor: Editor,
|
||||||
|
transcript: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (typeof transcript !== "string") return false;
|
||||||
|
const lines = transcript
|
||||||
|
.split("\n")
|
||||||
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||||
|
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||||
|
// `Speaker N:`) never match and stay byte-exact.
|
||||||
|
.map((line) =>
|
||||||
|
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||||
|
);
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Transcript" }],
|
||||||
|
},
|
||||||
|
...lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: line }],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append at the end of the document. On a freshly-created recording page the
|
||||||
|
// audio node is the last block, so the end position places the transcript
|
||||||
|
// directly below it.
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useSwapHeightReservation } from "./use-swap-height-reservation";
|
||||||
|
|
||||||
|
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
|
||||||
|
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
|
||||||
|
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
|
||||||
|
// the NEXT tick), letting each test advance the release loop frame by frame.
|
||||||
|
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
|
||||||
|
let nextRafId = 1;
|
||||||
|
let realRaf: typeof globalThis.requestAnimationFrame;
|
||||||
|
let realCancel: typeof globalThis.cancelAnimationFrame;
|
||||||
|
|
||||||
|
function tickRaf(): void {
|
||||||
|
const current = rafQueue;
|
||||||
|
rafQueue = [];
|
||||||
|
for (const { cb } of current) cb(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mutable stand-in for the live-content container. The hook only reads
|
||||||
|
// `scrollHeight`, so tests drive the release condition by mutating this.
|
||||||
|
function makeMenuRef(): {
|
||||||
|
ref: RefObject<HTMLElement | null>;
|
||||||
|
setScrollHeight: (h: number) => void;
|
||||||
|
} {
|
||||||
|
const el = { scrollHeight: 0 };
|
||||||
|
return {
|
||||||
|
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
|
||||||
|
setScrollHeight: (h: number) => {
|
||||||
|
el.scrollHeight = h;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const H = 1000;
|
||||||
|
|
||||||
|
describe("useSwapHeightReservation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rafQueue = [];
|
||||||
|
nextRafId = 1;
|
||||||
|
realRaf = globalThis.requestAnimationFrame;
|
||||||
|
realCancel = globalThis.cancelAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||||
|
const id = nextRafId++;
|
||||||
|
rafQueue.push({ id, cb });
|
||||||
|
return id;
|
||||||
|
}) as typeof globalThis.requestAnimationFrame;
|
||||||
|
globalThis.cancelAnimationFrame = ((id: number) => {
|
||||||
|
rafQueue = rafQueue.filter((e) => e.id !== id);
|
||||||
|
}) as typeof globalThis.cancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.requestAnimationFrame = realRaf;
|
||||||
|
globalThis.cancelAnimationFrame = realCancel;
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
|
||||||
|
// that drives the swap wrapper's minHeight. Captured while static is still up,
|
||||||
|
// then the swap flips showStatic; before any release frame runs the reservation
|
||||||
|
// is held at exactly H.
|
||||||
|
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture happens synchronously at the swap point (static still shown).
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
// The swap flips to the live branch.
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBe(H);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (b) release when the live content is tall enough. Guard is `>=`: with
|
||||||
|
// liveHeight === H the reservation releases. This FAILS if the guard direction
|
||||||
|
// were `<` (liveHeight === H is not `< H`, so it would never release).
|
||||||
|
it("(b) releases once live content reaches the reserved height", () => {
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(0);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
|
||||||
|
|
||||||
|
// Live editor finishes laying out to the reserved height.
|
||||||
|
setScrollHeight(H);
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (c) cap escape: the live content never reaches the reserved height, so the
|
||||||
|
// height match never fires; the reservation must still release at the 4000ms
|
||||||
|
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
|
||||||
|
// loop would poll forever while scrollHeight stays below H.
|
||||||
|
it("(c) releases at the 4000ms cap when live content stays too short", () => {
|
||||||
|
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
|
||||||
|
// manual rAF queue in place (default fake timers would replace it).
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
|
||||||
|
// A few frames pass but time has not reached the cap: still reserved.
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
expect(result.current.reservedHeight).toBe(H);
|
||||||
|
|
||||||
|
// Advance past the cap; the next frame releases even though the live content
|
||||||
|
// is still shorter than the reservation.
|
||||||
|
vi.setSystemTime(4001);
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (d) non-swap: without a capture (and while static is shown) there is no
|
||||||
|
// reservation and the release loop never arms, so no rAF is scheduled.
|
||||||
|
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
|
||||||
|
const { ref } = makeMenuRef();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSwapHeightReservation(true, ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
expect(rafQueue.length).toBe(0); // release loop never armed
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// Last-resort release deadline. The primary release is the live-content height
|
||||||
|
// match below; this cap only exists so a slow/short live doc can never pin the
|
||||||
|
// reservation forever. It is generous (well past when the live content normally
|
||||||
|
// reaches the reserved height — it renders the SAME content as the static copy)
|
||||||
|
// so a slow load doesn't release mid-render and reintroduce the collapse.
|
||||||
|
const RELEASE_CAP_MS = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves the document height across the static -> live editor swap.
|
||||||
|
*
|
||||||
|
* The live editor lays out its content over a few frames, so replacing the
|
||||||
|
* (full-height) static copy with it momentarily shrinks the document; the
|
||||||
|
* browser then clamps window scroll to the top, which yanked the reader off
|
||||||
|
* their restored reading position (and threw their scroll to 0 if they were
|
||||||
|
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
|
||||||
|
* document tall through the swap so the scroll position simply survives (#266).
|
||||||
|
* `reservedHeight === null` means no reservation is active.
|
||||||
|
*
|
||||||
|
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
|
||||||
|
* this hook derives by watching `showStatic`. The height MUST be read
|
||||||
|
* synchronously while the static content is still mounted (full natural height),
|
||||||
|
* right before the flip to the live branch. By the time any post-transition
|
||||||
|
* effect here could run, `showStatic` is already false and the wrapper shows the
|
||||||
|
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
|
||||||
|
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
|
||||||
|
* before `setShowStatic(false)`, preserving that exact timing.
|
||||||
|
*
|
||||||
|
* @param showStatic whether the static (cached) content is still shown.
|
||||||
|
* @param menuContainerRef the live-branch content container. It is a descendant
|
||||||
|
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
|
||||||
|
* content height (not inflated by the ancestor min-height reservation).
|
||||||
|
*/
|
||||||
|
export function useSwapHeightReservation(
|
||||||
|
showStatic: boolean,
|
||||||
|
menuContainerRef: RefObject<HTMLElement | null>,
|
||||||
|
): {
|
||||||
|
reservedHeight: number | null;
|
||||||
|
captureReservation: (height: number | null) => void;
|
||||||
|
} {
|
||||||
|
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Capture the current (static, full-height) content height BEFORE the swap so
|
||||||
|
// the wrapper can reserve it while the live editor lays out — otherwise the
|
||||||
|
// transient shrink clamps window scroll to the top. The caller reads
|
||||||
|
// `offsetHeight` synchronously at the swap point and hands it here.
|
||||||
|
const captureReservation = useCallback(
|
||||||
|
(height: number | null) => setReservedHeight(height),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release the reserved height once the live editor's content has laid out to
|
||||||
|
// at least the reserved height (so removing the reservation cannot collapse
|
||||||
|
// the document). The primary release is that height match; the cap is only a
|
||||||
|
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
|
||||||
|
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
|
||||||
|
// space until then.
|
||||||
|
useEffect(() => {
|
||||||
|
if (showStatic || reservedHeight == null) return;
|
||||||
|
let raf = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const check = () => {
|
||||||
|
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
|
||||||
|
if (
|
||||||
|
liveHeight >= reservedHeight ||
|
||||||
|
Date.now() - startedAt > RELEASE_CAP_MS
|
||||||
|
) {
|
||||||
|
setReservedHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(check);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [showStatic, reservedHeight, menuContainerRef]);
|
||||||
|
|
||||||
|
return { reservedHeight, captureReservation };
|
||||||
|
}
|
||||||
@@ -27,11 +27,12 @@ import {
|
|||||||
collabExtensions,
|
collabExtensions,
|
||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
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 {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
@@ -79,6 +80,7 @@ import { jwtDecode } from "jwt-decode";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||||
|
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
||||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
@@ -87,9 +89,15 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb
|
|||||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
computeDictationAvailability,
|
||||||
isBodyEditable,
|
isBodyEditable,
|
||||||
isCollabSynced,
|
isCollabSynced,
|
||||||
} from "@/features/editor/editor-sync-state";
|
} from "@/features/editor/editor-sync-state";
|
||||||
|
import {
|
||||||
|
isVitalsActive,
|
||||||
|
measurePageOpen,
|
||||||
|
reportEditorTx,
|
||||||
|
} from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -138,6 +146,7 @@ export default function PageEditor({
|
|||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
|
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
|
||||||
const canScroll = useCallback(
|
const canScroll = useCallback(
|
||||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
@@ -347,6 +356,40 @@ export default function PageEditor({
|
|||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
// #355 — perf instrumentation. Skip ALL of it when telemetry is
|
||||||
|
// disabled (F1 flag off) or this session isn't sampled: no page-open
|
||||||
|
// measure, and crucially NO dispatch wrapping, so a non-collecting
|
||||||
|
// session pays zero per-transaction cost.
|
||||||
|
if (isVitalsActive()) {
|
||||||
|
// page_open_ms: this is the first editor-content render, so measure
|
||||||
|
// against any page-open mark set on the tree-row/link click.
|
||||||
|
measurePageOpen();
|
||||||
|
|
||||||
|
// editor_tx_ms: time the SYNCHRONOUS part of applying each
|
||||||
|
// transaction (state.apply + updateState) by wrapping the view's
|
||||||
|
// dispatch. Only slow syncs (>8ms) are reported (see reportEditorTx),
|
||||||
|
// so the common path adds just one performance.now() pair. Passive:
|
||||||
|
// the original dispatch still runs unchanged.
|
||||||
|
try {
|
||||||
|
const view = editor.view as unknown as {
|
||||||
|
dispatch: (tr: unknown) => void;
|
||||||
|
};
|
||||||
|
const originalDispatch = view.dispatch.bind(view);
|
||||||
|
view.dispatch = (tr: unknown) => {
|
||||||
|
const started = performance.now();
|
||||||
|
originalDispatch(tr);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
try {
|
||||||
|
reportEditorTx(elapsed, editor.state.doc.content.size);
|
||||||
|
} catch {
|
||||||
|
// never let telemetry break editing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// if the view shape changes, skip editor_tx instrumentation
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -449,6 +492,22 @@ export default function PageEditor({
|
|||||||
const hasConnectedOnceRef = useRef(false);
|
const hasConnectedOnceRef = useRef(false);
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
const [showStatic, setShowStatic] = useState(true);
|
||||||
|
|
||||||
|
// Reserved height held across the static -> live editor swap. The live editor
|
||||||
|
// lays out its content over a few frames, so replacing the (full-height) static
|
||||||
|
// copy with it momentarily shrinks the document; the browser then clamps window
|
||||||
|
// scroll to the top, which yanked the reader off their restored reading position
|
||||||
|
// (and threw their scroll to 0 if they were scrolling at that moment). Pinning a
|
||||||
|
// min-height on the swap wrapper keeps the document tall through the swap so the
|
||||||
|
// scroll position simply survives. `null` = no reservation active.
|
||||||
|
const swapWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Reserve/release wiring lives in the hook so its capture trigger and release
|
||||||
|
// guard/cap are directly unit-testable. Capture stays synchronous at the swap
|
||||||
|
// point (see the collab-sync effect below); the hook only owns the release.
|
||||||
|
const { reservedHeight, captureReservation } = useSwapHeightReservation(
|
||||||
|
showStatic,
|
||||||
|
menuContainerRef,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||||
@@ -471,12 +530,36 @@ export default function PageEditor({
|
|||||||
);
|
);
|
||||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||||
|
|
||||||
|
// Publish whether dictation can start and, if not, the cause-specific reason
|
||||||
|
// the mic button surfaces. Recomputed on the same signals that drive body
|
||||||
|
// editability so the tooltip never lies about the current state.
|
||||||
|
useEffect(() => {
|
||||||
|
setDictationAvailability(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable,
|
||||||
|
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||||
|
showStatic,
|
||||||
|
isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
editable,
|
||||||
|
currentPageEditMode,
|
||||||
|
showStatic,
|
||||||
|
yjsConnectionStatus,
|
||||||
|
setDictationAvailability,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
|
// Capture the current (static, full-height) content height BEFORE the swap
|
||||||
|
// so the wrapper can reserve it while the live editor lays out — otherwise
|
||||||
|
// the transient shrink clamps window scroll to the top.
|
||||||
|
captureReservation(swapWrapperRef.current?.offsetHeight ?? null);
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
@@ -490,6 +573,12 @@ export default function PageEditor({
|
|||||||
<TransclusionLookupProvider>
|
<TransclusionLookupProvider>
|
||||||
<PageEmbedLookupProvider>
|
<PageEmbedLookupProvider>
|
||||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||||
|
<div
|
||||||
|
ref={swapWrapperRef}
|
||||||
|
style={
|
||||||
|
reservedHeight != null ? { minHeight: reservedHeight } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{showStatic ? (
|
{showStatic ? (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
{/* Surface the pre-sync read-only window so edits typed before the
|
{/* Surface the pre-sync read-only window so edits typed before the
|
||||||
@@ -577,6 +666,7 @@ export default function PageEditor({
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</PageEmbedAncestryProvider>
|
</PageEmbedAncestryProvider>
|
||||||
</PageEmbedLookupProvider>
|
</PageEmbedLookupProvider>
|
||||||
</TransclusionLookupProvider>
|
</TransclusionLookupProvider>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { UniqueID } from "@docmost/editor-ext";
|
||||||
|
import { getEditorSelectionContext } from "./get-editor-selection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for getEditorSelectionContext (#388). Built on a headless
|
||||||
|
* ProseMirror schema (Document + Paragraph + Text + the block-id UniqueID
|
||||||
|
* extension), mirroring the editor-ext test style. We assemble docs with
|
||||||
|
* explicit block ids so the covered-blockIds assertions are deterministic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A schema that carries the `id` block attribute (UniqueID) on paragraphs, just
|
||||||
|
// like the real editor.
|
||||||
|
const { schema } = new Editor({
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
UniqueID.configure({ types: ["paragraph"] }),
|
||||||
|
],
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function docOf(blocks: { id: string; text: string }[]): PMNode {
|
||||||
|
return schema.node(
|
||||||
|
"doc",
|
||||||
|
null,
|
||||||
|
blocks.map((b) =>
|
||||||
|
schema.node("paragraph", { id: b.id }, b.text ? schema.text(b.text) : []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWith(doc: PMNode, from: number, to: number): EditorState {
|
||||||
|
const base = EditorState.create({ schema, doc });
|
||||||
|
return base.apply(base.tr.setSelection(TextSelection.create(doc, from, to)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select every text position of the doc (pos 1 .. content.size - 1).
|
||||||
|
function selectAll(doc: PMNode): EditorState {
|
||||||
|
return stateWith(doc, 1, doc.content.size - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getEditorSelectionContext", () => {
|
||||||
|
it("returns null for an empty (collapsed) selection", () => {
|
||||||
|
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||||
|
const state = stateWith(doc, 3, 3); // caret, from === to
|
||||||
|
expect(getEditorSelectionContext(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for the default caret-at-start of a fresh editor", () => {
|
||||||
|
const editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
UniqueID.configure({ types: ["paragraph"] }),
|
||||||
|
],
|
||||||
|
content: "<p>fresh</p>",
|
||||||
|
});
|
||||||
|
expect(getEditorSelectionContext(editor.state)).toBeNull();
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a single-paragraph selection with no block-separator artifacts", () => {
|
||||||
|
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||||
|
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||||
|
expect(sel.text).toBe("Hello world");
|
||||||
|
expect(sel.blockIds).toEqual(["b1"]);
|
||||||
|
expect(sel.truncated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple blocks with a newline and collects all covered blockIds", () => {
|
||||||
|
const doc = docOf([
|
||||||
|
{ id: "b1", text: "First" },
|
||||||
|
{ id: "b2", text: "Second" },
|
||||||
|
]);
|
||||||
|
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||||
|
expect(sel.text).toBe("First\nSecond");
|
||||||
|
expect(sel.blockIds).toEqual(["b1", "b2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the text at 2000 chars and flags truncated", () => {
|
||||||
|
const doc = docOf([{ id: "b1", text: "x".repeat(2500) }]);
|
||||||
|
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||||
|
expect(sel.text).toHaveLength(2000);
|
||||||
|
expect(sel.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes before/after context and clamps it to the doc bounds", () => {
|
||||||
|
// One paragraph "0123456789abcdefghij"; select the middle "56789".
|
||||||
|
const doc = docOf([{ id: "b1", text: "0123456789abcdefghij" }]);
|
||||||
|
// text char i lives at pos (1 + i); select chars index 5..9 -> pos 6..11.
|
||||||
|
const sel = getEditorSelectionContext(stateWith(doc, 6, 11))!;
|
||||||
|
expect(sel.text).toBe("56789");
|
||||||
|
expect(sel.before).toBe("01234");
|
||||||
|
expect(sel.after).toBe("abcdefghij");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits before/after at the document boundaries (never reads past 0/size)", () => {
|
||||||
|
const doc = docOf([{ id: "b1", text: "Edge" }]);
|
||||||
|
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||||
|
// Selection spans the whole single block: nothing before or after it.
|
||||||
|
expect(sel.before).toBeUndefined();
|
||||||
|
expect(sel.after).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { EditorState } from "@tiptap/pm/state";
|
||||||
|
|
||||||
|
export interface EditorSelectionContext {
|
||||||
|
text: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
blockIds?: string[];
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side caps. The server re-caps every field independently (defence in
|
||||||
|
// depth — the payload is attacker-controllable), so these only keep the wire
|
||||||
|
// small for the common case.
|
||||||
|
const TEXT_CAP = 2000;
|
||||||
|
const CONTEXT_CHARS = 160;
|
||||||
|
const MAX_BLOCK_IDS = 20;
|
||||||
|
|
||||||
|
// Pure: takes an EditorState so it is unit-testable with a headless editor.
|
||||||
|
// Snapshots the user's current selection into the wire shape carried inside
|
||||||
|
// openPage — plain text + the ids of the blocks it covers + a little surrounding
|
||||||
|
// context. Returns null when nothing meaningful is selected.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT emit the ProseMirror positions (from/to): they rot the
|
||||||
|
// instant the document changes and the server tools address content by block id
|
||||||
|
// + text (getNode / editPageText find-replace), never by position.
|
||||||
|
export function getEditorSelectionContext(
|
||||||
|
state: EditorState,
|
||||||
|
): EditorSelectionContext | null {
|
||||||
|
const { selection, doc } = state;
|
||||||
|
// An empty selection (incl. the default caret-at-start of a fresh editor) is
|
||||||
|
// never a "this"/"here" — bail before reading any text.
|
||||||
|
if (selection.empty) return null;
|
||||||
|
|
||||||
|
const { from, to } = selection;
|
||||||
|
|
||||||
|
let text = doc.textBetween(from, to, "\n");
|
||||||
|
let truncated = false;
|
||||||
|
if (text.length > TEXT_CAP) {
|
||||||
|
text = text.slice(0, TEXT_CAP);
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
// A selection spanning only non-text nodes (e.g. an image) trims to empty ->
|
||||||
|
// treat as no selection.
|
||||||
|
if (text.trim().length === 0) return null;
|
||||||
|
|
||||||
|
// Ids of every block the selection covers, deduped and capped. These bridge
|
||||||
|
// the plain-text selection to the server tools (getNode / editPageText).
|
||||||
|
const blockIds: string[] = [];
|
||||||
|
doc.nodesBetween(from, to, (node) => {
|
||||||
|
const id = node.isBlock ? node.attrs?.id : undefined;
|
||||||
|
if (typeof id === "string" && id.length > 0 && !blockIds.includes(id)) {
|
||||||
|
blockIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ~160 chars of plain text on each side, clamped to the document bounds, so
|
||||||
|
// editPageText can disambiguate a duplicate of the selected text.
|
||||||
|
const before = doc.textBetween(Math.max(0, from - CONTEXT_CHARS), from, "\n");
|
||||||
|
const after = doc.textBetween(
|
||||||
|
to,
|
||||||
|
Math.min(doc.content.size, to + CONTEXT_CHARS),
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: EditorSelectionContext = { text };
|
||||||
|
if (truncated) result.truncated = true;
|
||||||
|
if (blockIds.length > 0) result.blockIds = blockIds.slice(0, MAX_BLOCK_IDS);
|
||||||
|
if (before.length > 0) result.before = before;
|
||||||
|
if (after.length > 0) result.after = after;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|||||||
return (
|
return (
|
||||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
{/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
|
||||||
|
their own row on narrow screens; flex:1 (basis 0) never wraps and
|
||||||
|
instead crushes the text into a one-word-per-line ladder. */}
|
||||||
|
<Group
|
||||||
|
gap="xs"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ flex: "1 1 16rem", minWidth: 0 }}
|
||||||
|
>
|
||||||
<IconClockHour4
|
<IconClockHour4
|
||||||
size={18}
|
size={18}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
@@ -87,7 +101,9 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Group gap="xs" wrap="nowrap">
|
<>
|
||||||
|
{/* Desktop: full labeled buttons. */}
|
||||||
|
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -109,6 +125,34 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|||||||
{t("Make permanent")}
|
{t("Make permanent")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
|
||||||
|
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||||
|
<Tooltip label={t("Move to trash")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={handleTrashNow}
|
||||||
|
loading={isDeleting}
|
||||||
|
aria-label={t("Move to trash")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t("Make permanent")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
onClick={handleMakePermanent}
|
||||||
|
loading={toggleTemporary.isPending}
|
||||||
|
aria-label={t("Make permanent")}
|
||||||
|
>
|
||||||
|
<IconClockHour4 size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAtom, useStore } from "jotai";
|
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
|
||||||
export type UseTreeMutation = {
|
export type UseTreeMutation = {
|
||||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
createdPage.title,
|
createdPage.title,
|
||||||
);
|
);
|
||||||
navigate(pageUrl);
|
navigate(pageUrl);
|
||||||
|
// On mobile the create action is triggered from inside the off-canvas
|
||||||
|
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||||
|
// alone leaves that drawer open on top of the freshly created page, so the
|
||||||
|
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||||
|
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||||
|
// No-op on desktop, where the mobile drawer atom is already false.
|
||||||
|
setMobileSidebar(false);
|
||||||
},
|
},
|
||||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
[
|
||||||
|
spaceId,
|
||||||
|
createPageMutation,
|
||||||
|
setData,
|
||||||
|
store,
|
||||||
|
navigate,
|
||||||
|
spaceSlug,
|
||||||
|
setMobileSidebar,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
|
|||||||
+966
-265
File diff suppressed because it is too large
Load Diff
+62
@@ -394,6 +394,10 @@ export default function AiProviderSettings() {
|
|||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||||
);
|
);
|
||||||
|
// #184: detached/autonomous agent runs (settings.ai.autonomousRuns).
|
||||||
|
const [autonomousRunsEnabled, setAutonomousRunsEnabled] = useState<boolean>(
|
||||||
|
workspace?.settings?.ai?.autonomousRuns ?? false,
|
||||||
|
);
|
||||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||||
@@ -403,6 +407,8 @@ export default function AiProviderSettings() {
|
|||||||
publicShareAssistantToggleLoading,
|
publicShareAssistantToggleLoading,
|
||||||
setPublicShareAssistantToggleLoading,
|
setPublicShareAssistantToggleLoading,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Whether a key is currently stored server-side (drives the placeholder).
|
// Whether a key is currently stored server-side (drives the placeholder).
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
const [hasApiKey, setHasApiKey] = useState(false);
|
||||||
@@ -730,6 +736,37 @@ export default function AiProviderSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic toggle for detached/autonomous agent runs
|
||||||
|
// (settings.ai.autonomousRuns). When on, a chat turn becomes a server-side run
|
||||||
|
// that survives a browser disconnect and can be reconnected to / live-followed;
|
||||||
|
// only an explicit Stop ends it. Off by default; single-instance-only in phase 1.
|
||||||
|
async function handleToggleAutonomousRuns(value: boolean) {
|
||||||
|
setAutonomousRunsToggleLoading(true);
|
||||||
|
const previous = autonomousRunsEnabled;
|
||||||
|
setAutonomousRunsEnabled(value);
|
||||||
|
try {
|
||||||
|
const updated = await updateWorkspace({ autonomousRuns: value });
|
||||||
|
setWorkspace({
|
||||||
|
...updated,
|
||||||
|
settings: {
|
||||||
|
...updated.settings,
|
||||||
|
ai: { ...updated.settings?.ai, autonomousRuns: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notifications.show({ message: t("Updated successfully") });
|
||||||
|
} catch (err) {
|
||||||
|
setAutonomousRunsEnabled(previous);
|
||||||
|
const message = (err as { response?: { data?: { message?: string } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAutonomousRunsToggleLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Admins only — match the previous behavior.
|
// Admins only — match the previous behavior.
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -960,6 +997,31 @@ export default function AiProviderSettings() {
|
|||||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Detached/autonomous agent runs: a chat turn becomes a server-side run
|
||||||
|
that survives a browser disconnect; only an explicit Stop ends it.
|
||||||
|
Single-instance-only in phase 1. */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("Autonomous agent runs")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Keep an agent turn running server-side even if the browser disconnects; reconnect and follow it on reopen. Single-instance deployments only.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Switch
|
||||||
|
label={t("Enabled")}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={autonomousRunsEnabled}
|
||||||
|
disabled={autonomousRunsToggleLoading}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleToggleAutonomousRuns(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group mt="md" align="center">
|
<Group mt="md" align="center">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface IWorkspace {
|
|||||||
aiDictation?: boolean;
|
aiDictation?: boolean;
|
||||||
aiDictationStreaming?: boolean;
|
aiDictationStreaming?: boolean;
|
||||||
aiPublicShareAssistant?: boolean;
|
aiPublicShareAssistant?: boolean;
|
||||||
|
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||||
|
// settings.ai.autonomousRuns.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||||
temporaryNoteHours?: number;
|
temporaryNoteHours?: number;
|
||||||
@@ -65,6 +68,9 @@ export interface IWorkspaceAiSettings {
|
|||||||
dictation?: boolean;
|
dictation?: boolean;
|
||||||
dictationStreaming?: boolean;
|
dictationStreaming?: boolean;
|
||||||
publicShareAssistant?: boolean;
|
publicShareAssistant?: boolean;
|
||||||
|
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||||
|
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
PALETTE,
|
||||||
|
avatarStyle,
|
||||||
|
avatarBackgroundCss,
|
||||||
|
normalizeName,
|
||||||
|
minPairwiseDistance,
|
||||||
|
relativeLuminance,
|
||||||
|
contrastRatio,
|
||||||
|
oklchToSrgb,
|
||||||
|
isInGamut,
|
||||||
|
} from "./avatar-palette";
|
||||||
|
|
||||||
|
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
|
||||||
|
function hexToRgb01(hex: string): [number, number, number] {
|
||||||
|
return [
|
||||||
|
parseInt(hex.slice(1, 3), 16) / 255,
|
||||||
|
parseInt(hex.slice(3, 5), 16) / 255,
|
||||||
|
parseInt(hex.slice(5, 7), 16) / 255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("avatar-palette validation", () => {
|
||||||
|
it("palette colors stay distinguishable", () => {
|
||||||
|
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
|
||||||
|
// RINGS tweak drops this, "almost identical" colors would reappear.
|
||||||
|
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
|
||||||
|
expect(PALETTE.length).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
|
||||||
|
// white text = luminance 1, black text = luminance 0 (per buildPalette).
|
||||||
|
const textLum = { white: 1, black: 0 } as const;
|
||||||
|
for (const entry of PALETTE) {
|
||||||
|
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
|
||||||
|
// (a) The chosen text color really clears the code's 3:1 threshold on the
|
||||||
|
// actual background hex — recomputed independently from the hex, not from
|
||||||
|
// the build-time luminance. A slot that picked the wrong text (or a color
|
||||||
|
// too dim for either text) would fail here.
|
||||||
|
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
|
||||||
|
const chosen = contrastRatio(textLum[entry.text], hexLum);
|
||||||
|
expect(chosen).toBeGreaterThanOrEqual(3);
|
||||||
|
// buildPalette prefers white and only falls back to black when white
|
||||||
|
// fails 3:1. Mirror that decision: black is used *only* when white would
|
||||||
|
// not clear the threshold — so a mis-assigned "black" on a dark color
|
||||||
|
// (where white was fine) fails here.
|
||||||
|
if (entry.text === "black") {
|
||||||
|
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
|
||||||
|
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
|
||||||
|
// outside [0,1] and fail here.
|
||||||
|
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avatarStyle", () => {
|
||||||
|
it("name-to-avatar mapping is frozen (golden values)", () => {
|
||||||
|
// Golden slice: if this breaks, all existing avatars change — make sure
|
||||||
|
// that is intentional (a config change in avatar-palette.ts).
|
||||||
|
const s = avatarStyle("Backend Developer");
|
||||||
|
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
|
||||||
|
expect(s.text).toBe("white");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic and normalizes the name", () => {
|
||||||
|
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
|
||||||
|
// Casing, surrounding and repeated whitespace must not change the avatar.
|
||||||
|
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
|
||||||
|
expect(avatarStyle("Backend Developer")).toEqual(
|
||||||
|
avatarStyle("backend developer"),
|
||||||
|
);
|
||||||
|
expect(normalizeName(" PM ")).toBe("pm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a valid base color, angle and matching text", () => {
|
||||||
|
const s = avatarStyle("Нарратор");
|
||||||
|
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
|
||||||
|
expect(idx).toBe(s.paletteIndex);
|
||||||
|
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
|
||||||
|
// Text color comes from the chosen palette entry.
|
||||||
|
expect(s.text).toBe(PALETTE[idx].text);
|
||||||
|
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
|
||||||
|
expect(s.angleDeg % 15).toBe(0);
|
||||||
|
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(s.angleDeg).toBeLessThan(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes the agents that used to collide as violet", () => {
|
||||||
|
// "Структурный редактор" and "Фактчекер" looked identically violet before.
|
||||||
|
expect(avatarStyle("Структурный редактор")).not.toEqual(
|
||||||
|
avatarStyle("Фактчекер"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avatarBackgroundCss", () => {
|
||||||
|
it("renders a two-stop gradient with a soft boundary", () => {
|
||||||
|
const s = avatarStyle("Backend Developer");
|
||||||
|
expect(avatarBackgroundCss(s)).toBe(
|
||||||
|
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic avatar backgrounds for agent roles.
|
||||||
|
*
|
||||||
|
* The palette is generated from scratch at module load in OKLCH (a perceptually
|
||||||
|
* uniform color space), so every value below is tunable: change the ring
|
||||||
|
* configuration or the partner shifts and the whole palette regenerates.
|
||||||
|
*
|
||||||
|
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
|
||||||
|
* - base color index (one of the validated palette colors)
|
||||||
|
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
|
||||||
|
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
|
||||||
|
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
|
||||||
|
* The same name always yields the same avatar, on any platform, forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------- Tunable configuration -------------------------
|
||||||
|
|
||||||
|
export interface RingConfig {
|
||||||
|
/** OKLCH lightness, 0..1 */
|
||||||
|
L: number;
|
||||||
|
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
|
||||||
|
C: number;
|
||||||
|
/** Hue of the first color in the ring, degrees */
|
||||||
|
hueStart: number;
|
||||||
|
/** Number of evenly spaced hues in the ring */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
|
||||||
|
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
|
||||||
|
* Don't add more hues per ring without re-checking minPairwiseDistance():
|
||||||
|
* beyond ~20-24 colors humans stop telling them apart reliably.
|
||||||
|
*/
|
||||||
|
const RINGS: readonly RingConfig[] = [
|
||||||
|
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
|
||||||
|
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Partner color: lightness shifted by this much (negative = darker) */
|
||||||
|
const PARTNER_L_SHIFT = -0.10;
|
||||||
|
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
|
||||||
|
const ANALOG_MIN_SHIFT = 20;
|
||||||
|
const ANALOG_SHIFT_STEP = 5;
|
||||||
|
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
|
||||||
|
/** Complementary scheme: fixed hue shift, degrees */
|
||||||
|
const COMPLEMENTARY_SHIFT = 180;
|
||||||
|
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
|
||||||
|
const TRIADIC_SHIFT = 120;
|
||||||
|
/** Number of split directions (24 -> 15deg per step) */
|
||||||
|
const SPLIT_ANGLE_STEPS = 24;
|
||||||
|
/** Position of the color boundary, percent of the gradient axis */
|
||||||
|
const SPLIT_PERCENT = 50;
|
||||||
|
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
|
||||||
|
const SPLIT_SOFTNESS = 16;
|
||||||
|
|
||||||
|
// ------------------------- OKLCH -> sRGB math -------------------------
|
||||||
|
// Matrices from Bjorn Ottosson's OKLab reference implementation.
|
||||||
|
|
||||||
|
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||||
|
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||||
|
return [
|
||||||
|
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gammaEncode(c: number): number {
|
||||||
|
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
|
||||||
|
const h = (hDeg * Math.PI) / 180;
|
||||||
|
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
|
||||||
|
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInGamut(rgb: readonly number[]): boolean {
|
||||||
|
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
|
||||||
|
function clampChroma(L: number, C: number, hDeg: number): number {
|
||||||
|
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
|
||||||
|
let lo = 0, hi = C;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(rgb: readonly number[]): string {
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
rgb
|
||||||
|
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WCAG relative luminance of an sRGB color (components 0..1). */
|
||||||
|
export function relativeLuminance(rgb: readonly number[]): number {
|
||||||
|
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastRatio(l1: number, l2: number): number {
|
||||||
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Palette generation -------------------------
|
||||||
|
|
||||||
|
export interface PaletteEntry {
|
||||||
|
/** Base background color */
|
||||||
|
hex: string;
|
||||||
|
/** OKLCH coordinates of the base color (used to derive partner colors) */
|
||||||
|
L: number;
|
||||||
|
C: number;
|
||||||
|
h: number;
|
||||||
|
/** Text/icon color with the best WCAG contrast on the base color */
|
||||||
|
text: "white" | "black";
|
||||||
|
/** OKLab coordinates of the base color (kept for validation) */
|
||||||
|
lab: readonly [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPalette(): PaletteEntry[] {
|
||||||
|
const entries: PaletteEntry[] = [];
|
||||||
|
for (const ring of RINGS) {
|
||||||
|
const step = 360 / ring.count;
|
||||||
|
for (let i = 0; i < ring.count; i++) {
|
||||||
|
const h = (ring.hueStart + i * step) % 360;
|
||||||
|
const C = clampChroma(ring.L, ring.C, h);
|
||||||
|
const rgb = oklchToSrgb(ring.L, C, h);
|
||||||
|
const lum = relativeLuminance(rgb);
|
||||||
|
entries.push({
|
||||||
|
hex: toHex(rgb),
|
||||||
|
L: ring.L,
|
||||||
|
C,
|
||||||
|
h,
|
||||||
|
// White text needs >= 3:1 contrast; otherwise fall back to black.
|
||||||
|
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
|
||||||
|
lab: [
|
||||||
|
ring.L,
|
||||||
|
C * Math.cos((h * Math.PI) / 180),
|
||||||
|
C * Math.sin((h * Math.PI) / 180),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
|
||||||
|
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
|
||||||
|
const h2 = (entry.h + shiftDeg + 360) % 360;
|
||||||
|
const L2 = entry.L + PARTNER_L_SHIFT;
|
||||||
|
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated once at module load; regenerates on every build from the config above. */
|
||||||
|
export const PALETTE: readonly PaletteEntry[] = buildPalette();
|
||||||
|
|
||||||
|
// ------------------------- Name -> avatar style -------------------------
|
||||||
|
|
||||||
|
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
|
||||||
|
export function normalizeName(name: string): string {
|
||||||
|
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cyrb53: deterministic 53-bit string hash with good avalanche.
|
||||||
|
* Pure JS, cross-platform — never use language built-in hashing here.
|
||||||
|
*/
|
||||||
|
function cyrb53(str: string, seed = 0): number {
|
||||||
|
let h1 = 0xdeadbeef ^ seed;
|
||||||
|
let h2 = 0x41c6ce57 ^ seed;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const ch = str.charCodeAt(i);
|
||||||
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||||
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||||
|
}
|
||||||
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||||
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||||
|
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarStyle {
|
||||||
|
/** Index of the base color in PALETTE */
|
||||||
|
paletteIndex: number;
|
||||||
|
/** Base color hex */
|
||||||
|
bg: string;
|
||||||
|
/** Second color hex (split partner) */
|
||||||
|
bg2: string;
|
||||||
|
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
|
||||||
|
hueShift: number;
|
||||||
|
/** Direction of the split, degrees */
|
||||||
|
angleDeg: number;
|
||||||
|
/** Text/icon color for the base color */
|
||||||
|
text: "white" | "black";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure function: the same (normalized) name always returns the same style. */
|
||||||
|
export function avatarStyle(agentName: string): AvatarStyle {
|
||||||
|
const h = cyrb53(normalizeName(agentName));
|
||||||
|
// Slice the hash into independent fields, like digits of a number:
|
||||||
|
const paletteIndex = h % PALETTE.length;
|
||||||
|
let rest = Math.floor(h / PALETTE.length);
|
||||||
|
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
|
||||||
|
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
|
||||||
|
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
|
||||||
|
const scheme = rest % 4;
|
||||||
|
rest = Math.floor(rest / 4);
|
||||||
|
let hueShift: number;
|
||||||
|
if (scheme === 2) {
|
||||||
|
hueShift = COMPLEMENTARY_SHIFT;
|
||||||
|
} else if (scheme === 3) {
|
||||||
|
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
|
||||||
|
} else {
|
||||||
|
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
|
||||||
|
hueShift = scheme === 0 ? -magnitude : magnitude;
|
||||||
|
}
|
||||||
|
const entry = PALETTE[paletteIndex];
|
||||||
|
return {
|
||||||
|
paletteIndex,
|
||||||
|
bg: entry.hex,
|
||||||
|
bg2: partnerHex(entry, hueShift),
|
||||||
|
hueShift,
|
||||||
|
angleDeg,
|
||||||
|
text: entry.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS background value: two colors with a slightly blurred boundary. */
|
||||||
|
export function avatarBackgroundCss(style: AvatarStyle): string {
|
||||||
|
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
|
||||||
|
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
|
||||||
|
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Validation -------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
|
||||||
|
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
|
||||||
|
* look alike. Intended for a unit test or a dev-time assertion.
|
||||||
|
*/
|
||||||
|
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
|
||||||
|
let min = Infinity;
|
||||||
|
let pair: [string, string] = ["", ""];
|
||||||
|
for (let i = 0; i < PALETTE.length; i++) {
|
||||||
|
for (let j = i + 1; j < PALETTE.length; j++) {
|
||||||
|
const a = PALETTE[i].lab, b = PALETTE[j].lab;
|
||||||
|
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
|
if (d < min) {
|
||||||
|
min = d;
|
||||||
|
pair = [PALETTE[i].hex, PALETTE[j].hex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { distance: min, pair };
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
|||||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — operator toggle for client perf-telemetry. DEFAULT OFF: the server
|
||||||
|
// mirrors CLIENT_TELEMETRY_ENABLED into window.CONFIG; when off the client
|
||||||
|
// installs no observers and sends nothing (the sink endpoint doesn't exist).
|
||||||
|
export function isClientTelemetryEnabled(): boolean {
|
||||||
|
return castToBoolean(getConfigValue("CLIENT_TELEMETRY_ENABLED", "false"));
|
||||||
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { templateRoute } from "./route-template";
|
||||||
|
|
||||||
|
describe("templateRoute", () => {
|
||||||
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
|
const t = templateRoute("/s/engineering/p/design-doc-abc123");
|
||||||
|
expect(t).toBe("/s/:space/p/:slug");
|
||||||
|
expect(t).not.toContain("engineering");
|
||||||
|
expect(t).not.toContain("design-doc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("templates share, redirect and space paths", () => {
|
||||||
|
expect(templateRoute("/share/abc/p/xyz")).toBe("/share/:shareId/p/:slug");
|
||||||
|
expect(templateRoute("/share/p/xyz")).toBe("/share/p/:slug");
|
||||||
|
expect(templateRoute("/p/some-slug")).toBe("/p/:slug");
|
||||||
|
expect(templateRoute("/s/team")).toBe("/s/:space");
|
||||||
|
expect(templateRoute("/s/team/trash")).toBe("/s/:space/trash");
|
||||||
|
expect(templateRoute("/labels/urgent")).toBe("/labels/:label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps known static routes verbatim", () => {
|
||||||
|
expect(templateRoute("/home")).toBe("/home");
|
||||||
|
expect(templateRoute("/settings/members")).toBe("/settings/members");
|
||||||
|
expect(templateRoute("/")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises a trailing slash", () => {
|
||||||
|
expect(templateRoute("/s/team/p/slug/")).toBe("/s/:space/p/:slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses unknown paths to 'other' (bounded cardinality)", () => {
|
||||||
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
||||||
|
*
|
||||||
|
* Perf metrics must be labelled by route template only — never a raw path with
|
||||||
|
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
||||||
|
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
||||||
|
* does not match a known pattern collapses to `other`.
|
||||||
|
*
|
||||||
|
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
||||||
|
*/
|
||||||
|
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
||||||
|
// Share pages (public).
|
||||||
|
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
||||||
|
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
||||||
|
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
||||||
|
// Page redirect.
|
||||||
|
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
||||||
|
// Space + page.
|
||||||
|
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
||||||
|
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
||||||
|
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
||||||
|
// Misc dynamic.
|
||||||
|
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
||||||
|
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
||||||
|
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Static routes we accept verbatim (finite set).
|
||||||
|
const STATIC_ROUTES = new Set<string>([
|
||||||
|
'/home',
|
||||||
|
'/spaces',
|
||||||
|
'/favorites',
|
||||||
|
'/login',
|
||||||
|
'/forgot-password',
|
||||||
|
'/password-reset',
|
||||||
|
'/setup/register',
|
||||||
|
'/settings/account/profile',
|
||||||
|
'/settings/account/preferences',
|
||||||
|
'/settings/workspace',
|
||||||
|
'/settings/ai',
|
||||||
|
'/settings/members',
|
||||||
|
'/settings/groups',
|
||||||
|
'/settings/spaces',
|
||||||
|
'/settings/sharing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function templateRoute(pathname: string): string {
|
||||||
|
// Normalise a trailing slash (except root).
|
||||||
|
const path =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/')
|
||||||
|
? pathname.slice(0, -1)
|
||||||
|
: pathname;
|
||||||
|
|
||||||
|
if (path === '' || path === '/') return '/';
|
||||||
|
if (STATIC_ROUTES.has(path)) return path;
|
||||||
|
|
||||||
|
for (const { re, template } of ROUTE_PATTERNS) {
|
||||||
|
if (re.test(path)) return template;
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template for the current window location. */
|
||||||
|
export function currentRouteTemplate(): string {
|
||||||
|
try {
|
||||||
|
return templateRoute(window.location.pathname);
|
||||||
|
} catch {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
onCLS,
|
||||||
|
onINP,
|
||||||
|
onLCP,
|
||||||
|
onTTFB,
|
||||||
|
type CLSMetricWithAttribution,
|
||||||
|
type INPMetricWithAttribution,
|
||||||
|
type LCPMetricWithAttribution,
|
||||||
|
type TTFBMetricWithAttribution,
|
||||||
|
} from "web-vitals/attribution";
|
||||||
|
import { isClientTelemetryEnabled } from "@/lib/config";
|
||||||
|
import { currentRouteTemplate } from "./route-template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client perf-telemetry (#355): web-vitals + custom metrics buffered and posted
|
||||||
|
* to POST /api/telemetry/vitals via sendBeacon.
|
||||||
|
*
|
||||||
|
* Design constraints from the issue:
|
||||||
|
* - Sampling is decided ONCE per session (25%), cached in sessionStorage,
|
||||||
|
* BEFORE any observer is subscribed. Non-sampled sessions send nothing.
|
||||||
|
* - Route labels are TEMPLATES only; attr is truncated to 120 chars; no page
|
||||||
|
* titles/slugs/text ever leave the browser.
|
||||||
|
* - Observers are passive and reporting is best-effort — telemetry must not
|
||||||
|
* degrade the perf it measures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ENDPOINT = "/api/telemetry/vitals";
|
||||||
|
const SAMPLE_RATE = 0.25;
|
||||||
|
const SAMPLE_KEY = "gm_vitals_sampled";
|
||||||
|
const FLUSH_INTERVAL_MS = 15_000;
|
||||||
|
const MAX_BUFFER = 40; // flush early if the buffer fills between timers
|
||||||
|
const MAX_ATTR_LENGTH = 120;
|
||||||
|
const EDITOR_TX_MIN_MS = 8; // only report editor transactions slower than this
|
||||||
|
|
||||||
|
const ALLOWED_NAMES = new Set([
|
||||||
|
"INP",
|
||||||
|
"LCP",
|
||||||
|
"CLS",
|
||||||
|
"TTFB",
|
||||||
|
"editor_tx_ms",
|
||||||
|
"page_open_ms",
|
||||||
|
"longtask_ms",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface VitalEvent {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating?: string;
|
||||||
|
route?: string;
|
||||||
|
attr?: string;
|
||||||
|
docSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampledCache: boolean | null = null;
|
||||||
|
let initialised = false;
|
||||||
|
let buffer: VitalEvent[] = [];
|
||||||
|
let longtaskSum = 0; // accumulated longtask duration (ms) for the current window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide once per session whether this session is sampled. Cached in
|
||||||
|
* sessionStorage so the choice is stable across reloads within the session and
|
||||||
|
* identical for every observer/custom-metric caller.
|
||||||
|
*/
|
||||||
|
export function isVitalsSampled(): boolean {
|
||||||
|
if (sampledCache !== null) return sampledCache;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(SAMPLE_KEY);
|
||||||
|
if (stored === "1") return (sampledCache = true);
|
||||||
|
if (stored === "0") return (sampledCache = false);
|
||||||
|
const sampled = Math.random() < SAMPLE_RATE;
|
||||||
|
sessionStorage.setItem(SAMPLE_KEY, sampled ? "1" : "0");
|
||||||
|
return (sampledCache = sampled);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / SSR): default to not sampled.
|
||||||
|
return (sampledCache = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when telemetry is BOTH enabled by the operator (F1 flag) AND this
|
||||||
|
* session is sampled. Callers outside initVitals (e.g. the editor dispatch
|
||||||
|
* wrapper) use this to skip ALL instrumentation cost on disabled/non-sampled
|
||||||
|
* sessions — no observers, no per-transaction timing.
|
||||||
|
*/
|
||||||
|
export function isVitalsActive(): boolean {
|
||||||
|
return isClientTelemetryEnabled() && isVitalsSampled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAttr(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||||
|
return value.slice(0, MAX_ATTR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(event: VitalEvent): void {
|
||||||
|
if (!ALLOWED_NAMES.has(event.name)) return;
|
||||||
|
if (!Number.isFinite(event.value)) return;
|
||||||
|
buffer.push(event);
|
||||||
|
if (buffer.length >= MAX_BUFFER) flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
// Fold any pending longtask total into the batch first.
|
||||||
|
if (longtaskSum > 0) {
|
||||||
|
buffer.push({
|
||||||
|
name: "longtask_ms",
|
||||||
|
value: Math.round(longtaskSum),
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
});
|
||||||
|
longtaskSum = 0;
|
||||||
|
}
|
||||||
|
if (buffer.length === 0) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ events: buffer });
|
||||||
|
buffer = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = new Blob([payload], { type: "application/json" });
|
||||||
|
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
|
||||||
|
// Fallback for browsers without sendBeacon: keepalive fetch.
|
||||||
|
void fetch(ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: never throw out of telemetry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a custom client metric (editor_tx_ms, page_open_ms). No-op unless the
|
||||||
|
* session is sampled. Route is always the current TEMPLATE.
|
||||||
|
*/
|
||||||
|
export function reportClientMetric(
|
||||||
|
name: "editor_tx_ms" | "page_open_ms",
|
||||||
|
value: number,
|
||||||
|
extra?: { docSize?: number },
|
||||||
|
): void {
|
||||||
|
if (!isVitalsActive()) return;
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
enqueue({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
docSize: extra?.docSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threshold-gated editor transaction reporter (only reports slow syncs). */
|
||||||
|
export function reportEditorTx(ms: number, docSize: number): void {
|
||||||
|
if (ms <= EDITOR_TX_MIN_MS) return;
|
||||||
|
reportClientMetric("editor_tx_ms", ms, { docSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_OPEN_MARK = "gm_page_open_start";
|
||||||
|
|
||||||
|
/** Mark the start of a page-open interaction (tree-row / link click). */
|
||||||
|
export function markPageOpenStart(): void {
|
||||||
|
try {
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
performance.mark(PAGE_OPEN_MARK);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure page_open_ms at first editor-content render, if a start mark exists.
|
||||||
|
* Consumes the mark so a later render doesn't double-count.
|
||||||
|
*/
|
||||||
|
export function measurePageOpen(): void {
|
||||||
|
try {
|
||||||
|
const marks = performance.getEntriesByName(PAGE_OPEN_MARK, "mark");
|
||||||
|
if (marks.length === 0) return;
|
||||||
|
const started = marks[0].startTime;
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
performance.clearMarks(PAGE_OPEN_MARK);
|
||||||
|
if (elapsed > 0 && Number.isFinite(elapsed)) {
|
||||||
|
reportClientMetric("page_open_ms", elapsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrTarget(
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
): string | undefined {
|
||||||
|
const a = metric.attribution as Record<string, unknown> | undefined;
|
||||||
|
if (!a) return undefined;
|
||||||
|
// Different vitals expose their culprit element under different keys; only a
|
||||||
|
// CSS-selector-ish target string is taken (no text content / titles).
|
||||||
|
return (
|
||||||
|
truncateAttr(a.interactionTarget) ??
|
||||||
|
truncateAttr(a.element) ??
|
||||||
|
truncateAttr(a.largestShiftTarget) ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise client telemetry. Safe to call multiple times (idempotent). Returns
|
||||||
|
* immediately without subscribing when the session is not sampled — so a
|
||||||
|
* non-sampled session subscribes to NO observers and sends nothing.
|
||||||
|
*/
|
||||||
|
export function initVitals(): void {
|
||||||
|
if (initialised) return;
|
||||||
|
initialised = true;
|
||||||
|
|
||||||
|
// Operator flag gate (F1, default OFF): when telemetry is disabled the sink
|
||||||
|
// endpoint does not even exist server-side, so install ZERO observers.
|
||||||
|
if (!isClientTelemetryEnabled()) return;
|
||||||
|
|
||||||
|
// Sampling gate is evaluated BEFORE any observer subscription.
|
||||||
|
if (!isVitalsSampled()) return;
|
||||||
|
|
||||||
|
const report = (
|
||||||
|
metric:
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution
|
||||||
|
| TTFBMetricWithAttribution,
|
||||||
|
) => {
|
||||||
|
enqueue({
|
||||||
|
name: metric.name,
|
||||||
|
value: metric.value,
|
||||||
|
rating: metric.rating,
|
||||||
|
route: currentRouteTemplate(),
|
||||||
|
attr:
|
||||||
|
metric.name === "TTFB"
|
||||||
|
? undefined
|
||||||
|
: attrTarget(
|
||||||
|
metric as
|
||||||
|
| INPMetricWithAttribution
|
||||||
|
| LCPMetricWithAttribution
|
||||||
|
| CLSMetricWithAttribution,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onINP(report);
|
||||||
|
onLCP(report);
|
||||||
|
onCLS(report);
|
||||||
|
onTTFB(report);
|
||||||
|
|
||||||
|
// Long tasks: aggregate the total blocking time per flush window (a passive
|
||||||
|
// observer; individual entries are summed, never stored/sent individually).
|
||||||
|
try {
|
||||||
|
if (typeof PerformanceObserver !== "undefined") {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
longtaskSum += entry.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe({ type: "longtask", buffered: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// longtask entry type unsupported: skip silently.
|
||||||
|
}
|
||||||
|
|
||||||
|
// page_open_ms start: mark when the user clicks a page link/tree-row (any
|
||||||
|
// anchor navigating to a page URL). Passive capture listener; the matching
|
||||||
|
// measure fires at first editor-content render (measurePageOpen). No page
|
||||||
|
// titles/slugs are read — only the click timing is marked.
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute("href") ?? "";
|
||||||
|
// A page link is `/s/:space/p/:slug`, `/p/:slug` or a share page path.
|
||||||
|
if (/\/p\//.test(href)) markPageOpenStart();
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flush on tab hide (most reliable delivery point) and periodically.
|
||||||
|
const onHidden = () => {
|
||||||
|
if (document.visibilityState === "hidden") flush();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onHidden);
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
|
||||||
|
setInterval(flush, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -43,6 +44,10 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
|
initVitals();
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||||
|
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||||
|
// set a few points below the level measured on develop, scoped to the files
|
||||||
|
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||||
|
// passes today but fails on a genuine coverage regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 53,
|
||||||
|
functions: 44,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"postmark": "^4.0.7",
|
"postmark": "^4.0.7",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-email": "6.0.8",
|
"react-email": "6.0.8",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -203,7 +205,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { McpModule } from './integrations/mcp/mcp.module';
|
|||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
import { AiModule } from './integrations/ai/ai.module';
|
import { AiModule } from './integrations/ai/ai.module';
|
||||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||||
|
import { MetricsModule } from './integrations/metrics/metrics.module';
|
||||||
|
import { ClientTelemetryModule } from './core/telemetry/client-telemetry.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +95,10 @@ try {
|
|||||||
SandboxModule,
|
SandboxModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AiChatModule,
|
AiChatModule,
|
||||||
|
MetricsModule,
|
||||||
|
// Gated OFF by default: only registers the public vitals sink controller
|
||||||
|
// when CLIENT_TELEMETRY_ENABLED=true (maintainer decision E1=B).
|
||||||
|
ClientTelemetryModule.register(),
|
||||||
...enterpriseModules,
|
...enterpriseModules,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused test for the COLLAB_DISABLE_REDIS fallback in handleYjsEvent.
|
||||||
|
*
|
||||||
|
* With Redis disabled the gateway builds no RedisSyncExtension, so the old code
|
||||||
|
* (`return this.redisSync?.handleEvent(...)`) returned undefined and every
|
||||||
|
* doc-mutation event silently no-opped. The fallback must instead invoke the
|
||||||
|
* handler locally against the single hocuspocus instance and return its verdict.
|
||||||
|
*
|
||||||
|
* We construct the gateway with stub extensions and an EnvironmentService whose
|
||||||
|
* isCollabDisableRedis() returns true (redisSync stays null, real hocuspocus is
|
||||||
|
* still built), then spy getHandlers so no real direct connection is opened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stubExtension = {} as any;
|
||||||
|
|
||||||
|
function makeEnv() {
|
||||||
|
return {
|
||||||
|
getRedisUrl: () => 'redis://localhost:6379',
|
||||||
|
isCollabDisableRedis: () => true,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationGateway.handleYjsEvent (no-Redis fallback)', () => {
|
||||||
|
it('invokes the handler locally and returns its verdict instead of undefined', async () => {
|
||||||
|
const collabHandler = new CollaborationHandler();
|
||||||
|
const verdict = { applied: true, currentText: 'new' };
|
||||||
|
const fakeHandler = jest.fn().mockResolvedValue(verdict);
|
||||||
|
// Bypass the real direct-connection code path — assert dispatch only.
|
||||||
|
jest
|
||||||
|
.spyOn(collabHandler, 'getHandlers')
|
||||||
|
.mockReturnValue({ applyCommentSuggestion: fakeHandler } as any);
|
||||||
|
|
||||||
|
const gateway = new CollaborationGateway(
|
||||||
|
stubExtension,
|
||||||
|
stubExtension,
|
||||||
|
stubExtension,
|
||||||
|
makeEnv(),
|
||||||
|
collabHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'old',
|
||||||
|
newText: 'new',
|
||||||
|
user: { id: 'u1' } as any,
|
||||||
|
};
|
||||||
|
const result = await gateway.handleYjsEvent(
|
||||||
|
'applyCommentSuggestion' as any,
|
||||||
|
'doc-1',
|
||||||
|
payload as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fakeHandler).toHaveBeenCalledWith('doc-1', payload);
|
||||||
|
expect(result).toEqual(verdict);
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,8 +147,41 @@ export class CollaborationGateway {
|
|||||||
eventName: TName,
|
eventName: TName,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: Parameters<CollabEventHandlers[TName]>[1],
|
payload: Parameters<CollabEventHandlers[TName]>[1],
|
||||||
) {
|
): ReturnType<CollabEventHandlers[TName]> {
|
||||||
return this.redisSync?.handleEvent(eventName, documentName, payload);
|
if (this.redisSync) {
|
||||||
|
// Normal path: the Redis bridge routes the event to the instance that owns
|
||||||
|
// the document (local or another worker) and carries the handler's return
|
||||||
|
// value back to us (customEventComplete + replyId).
|
||||||
|
return this.redisSync.handleEvent(
|
||||||
|
eventName,
|
||||||
|
documentName,
|
||||||
|
payload,
|
||||||
|
) as ReturnType<CollabEventHandlers[TName]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLLAB_DISABLE_REDIS: there is no cross-process bridge, so a single local
|
||||||
|
// hocuspocus instance owns every document. Invoke the handler directly
|
||||||
|
// against it instead of returning undefined — otherwise doc-mutation events
|
||||||
|
// (setCommentMark / resolveCommentMark / applyCommentSuggestion) would
|
||||||
|
// silently no-op and, for suggestions, the caller could never learn the
|
||||||
|
// verdict. openDirectConnection loads the doc via the persistence extension
|
||||||
|
// if it is not already in memory.
|
||||||
|
if (this.hocuspocus) {
|
||||||
|
const handlers = this.collabEventsService.getHandlers(this.hocuspocus);
|
||||||
|
const handler = handlers[eventName] as (
|
||||||
|
documentName: string,
|
||||||
|
payload: unknown,
|
||||||
|
) => ReturnType<CollabEventHandlers[TName]>;
|
||||||
|
return handler(documentName, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collaboration was never initialized (no live instance). Fail loudly rather
|
||||||
|
// than silently dropping a mutation; phase 4's caller maps this to a 5xx.
|
||||||
|
throw new Error(
|
||||||
|
`Cannot handle collaboration event "${String(
|
||||||
|
eventName,
|
||||||
|
)}": requires a live collaboration instance`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openDirectConnection(documentName: string, context?: any) {
|
openDirectConnection(documentName: string, context?: any) {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
|
import * as yjsUtil from './yjs.util';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
||||||
|
*
|
||||||
|
* The handler runs `replaceYjsMarkedText` inside the owning instance's Y
|
||||||
|
* transaction and returns the verdict to the caller. We exercise it against a
|
||||||
|
* REAL in-memory Y.Doc carrying a marked comment run, driven through a FAKE
|
||||||
|
* hocuspocus whose openDirectConnection().transact(fn) simply runs fn(doc) —
|
||||||
|
* mirroring how the real hocuspocus DirectConnection invokes the callback with
|
||||||
|
* the shared document (it does not forward the callback's return value, which is
|
||||||
|
* exactly why withYdocConnection captures it via a closure).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Build a Y.Doc with a single paragraph whose text carries a `comment` mark for
|
||||||
|
// the given commentId — the shape `replaceYjsMarkedText` walks in production.
|
||||||
|
function buildDocWithComment(text: string, commentId: string) {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
const paragraph = new Y.XmlElement('paragraph');
|
||||||
|
const xmlText = new Y.XmlText();
|
||||||
|
xmlText.insert(0, text);
|
||||||
|
xmlText.format(0, text.length, { comment: { commentId, resolved: false } });
|
||||||
|
paragraph.insert(0, [xmlText]);
|
||||||
|
fragment.insert(0, [paragraph]);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake hocuspocus exposing only what withYdocConnection needs: a direct
|
||||||
|
// connection whose transact() runs the callback against `doc`.
|
||||||
|
function fakeHocuspocus(doc: Y.Doc) {
|
||||||
|
const connection = {
|
||||||
|
transact: jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||||
|
fn(doc);
|
||||||
|
}),
|
||||||
|
disconnect: jest.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const hocuspocus = {
|
||||||
|
openDirectConnection: jest.fn(async () => connection),
|
||||||
|
} as any;
|
||||||
|
return { hocuspocus, connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { id: 'u1' } as unknown as User;
|
||||||
|
|
||||||
|
describe('CollaborationHandler.applyCommentSuggestion', () => {
|
||||||
|
it('applies the replacement and returns the verdict when the marked text matches', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'Hello world',
|
||||||
|
newText: 'Goodbye world',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Goodbye world' });
|
||||||
|
// The mutation ran inside the transaction and hit the real doc.
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
||||||
|
'Goodbye world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects (applied=false) and returns the current text when it changed', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'Stale expected text',
|
||||||
|
newText: 'Goodbye world',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
|
||||||
|
// Nothing was replaced.
|
||||||
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
||||||
|
'Hello world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the exact args to replaceYjsMarkedText and returns its result', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(yjsUtil, 'replaceYjsMarkedText')
|
||||||
|
.mockReturnValue({ applied: true, currentText: 'xyz' });
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c9',
|
||||||
|
expectedText: 'abc',
|
||||||
|
newText: 'xyz',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'c9',
|
||||||
|
'abc',
|
||||||
|
'xyz',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'xyz' });
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withYdocConnection returns the callback result (transact does not forward it)', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
|
||||||
|
const value = await handler.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
'doc-1',
|
||||||
|
{},
|
||||||
|
() => 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(value).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||||
|
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
'c9',
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a different comment\'s mark intact', async () => {
|
||||||
|
const doc = buildDocWithComment('keep me', 'other');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'keep me',
|
||||||
|
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,13 @@ import {
|
|||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
import {
|
||||||
|
removeYjsMarkByAttribute,
|
||||||
|
replaceYjsMarkedText,
|
||||||
|
setYjsMark,
|
||||||
|
updateYjsMarkAttribute,
|
||||||
|
YjsSelection,
|
||||||
|
} from './yjs.util';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@@ -73,6 +79,69 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
deleteCommentMark: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { commentId, user } = payload;
|
||||||
|
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||||
|
// applied one has no replies, the comment is hard-deleted and its inline
|
||||||
|
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||||
|
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||||
|
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||||
|
// the collaborative document.
|
||||||
|
//
|
||||||
|
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||||
|
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||||
|
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||||
|
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
removeYjsMarkByAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
applyCommentSuggestion: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
expectedText: string;
|
||||||
|
newText: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
): Promise<{ applied: boolean; currentText: string | null }> => {
|
||||||
|
const { commentId, expectedText, newText, user } = payload;
|
||||||
|
// Run the check-and-replace inside the owning instance's Y transaction so
|
||||||
|
// the delete+insert are atomic. The verdict from replaceYjsMarkedText is
|
||||||
|
// returned to the API-server caller (cross-process via the Redis bridge,
|
||||||
|
// or locally when Redis is disabled — see collaboration.gateway.ts).
|
||||||
|
return this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
return replaceYjsMarkedText(
|
||||||
|
fragment,
|
||||||
|
commentId,
|
||||||
|
expectedText,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
updatePageContent: async (
|
updatePageContent: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -115,18 +184,28 @@ export class CollaborationHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async withYdocConnection(
|
async withYdocConnection<T>(
|
||||||
hocuspocus: Hocuspocus,
|
hocuspocus: Hocuspocus,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
context: any = {},
|
context: any = {},
|
||||||
fn: (doc: Document) => void,
|
// `fn` MUST be synchronous: hocuspocus `connection.transact(fn)` runs fn
|
||||||
): Promise<void> {
|
// synchronously and does NOT await it, so any mutations after an `await`
|
||||||
|
// inside fn would execute OUTSIDE the Yjs transaction and lose atomicity.
|
||||||
|
fn: (doc: Document) => T,
|
||||||
|
): Promise<T> {
|
||||||
const connection = await hocuspocus.openDirectConnection(
|
const connection = await hocuspocus.openDirectConnection(
|
||||||
documentName,
|
documentName,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await connection.transact(fn);
|
// hocuspocus `connection.transact(fn)` invokes fn(document) but does NOT
|
||||||
|
// forward fn's return value, so we capture it in a closure and return it
|
||||||
|
// after the transaction (and its storeDocument hooks) resolve.
|
||||||
|
let result: T;
|
||||||
|
await connection.transact((doc) => {
|
||||||
|
result = fn(doc);
|
||||||
|
});
|
||||||
|
return result!;
|
||||||
} finally {
|
} finally {
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
@@ -239,6 +239,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToMarkdown(tiptapJson: any): string {
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
const html = jsonToHtml(tiptapJson);
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
return htmlToMarkdown(html);
|
// (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second
|
||||||
|
// editor-ext markdown layer. Same serializer as the page/space export and the
|
||||||
|
// git-sync vault writer, so every server PM->MD path emits identical canonical
|
||||||
|
// markdown (issue #345).
|
||||||
|
return convertProseMirrorToMarkdown(tiptapJson);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user