Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ff922dd4 | |||
| e3ca2dc1d5 | |||
| f919ced8c9 | |||
| 4109b2ef7f | |||
| 6b27ff0652 | |||
| e133b86982 | |||
| 579c82617b | |||
| 173f35e473 | |||
| 696b96ac18 | |||
| a96ca8e26b | |||
| f84386f24a | |||
| c0c44fddb9 | |||
| 91e58e3c9f | |||
| 8d062728e5 | |||
| f1cffc2d0f | |||
| bb9a6fd765 | |||
| 1c083fbd3d | |||
| 52763998d3 | |||
| bfb6a52eea | |||
| 0503e8b4b1 | |||
| b97cad0ebe | |||
| 080dd82023 | |||
| 803bbd40b4 | |||
| c7a38e274e | |||
| 791a707eb6 | |||
| 60205398bb | |||
| 1685b32333 | |||
| 4b78e336a2 | |||
| 77e20ecb41 | |||
| d533daa45f | |||
| ba58493909 | |||
| 947796adef | |||
| 38a09c5ca1 | |||
| 1b05224b27 | |||
| b50b32bf64 | |||
| 1d704d4ec5 | |||
| 2cb07ef7fb | |||
| 9ba97b6d2b | |||
| 0b9de2c25e | |||
| 123cba7de1 | |||
| 001eb1c923 | |||
| 002c931c6b | |||
| 307ad49324 | |||
| 1031ed1ae8 | |||
| 3b6634c6be | |||
| fdc37de3e8 | |||
| 4a750c1e7f | |||
| ea7c4d7cd2 | |||
| 255024fbe2 | |||
| b934fb2292 | |||
| 14da295185 | |||
| de8f9c804c | |||
| 8ebdfe156f | |||
| 31176d5f93 | |||
| 5f0c49d47c | |||
| b2e5b51420 | |||
| 26d9a70a1c | |||
| e1ebd79f30 | |||
| fc83ea28ab | |||
| ee15278c8f | |||
| 09ab92eccf | |||
| fe5bd159c4 | |||
| f12b685698 | |||
| f6fc914c95 | |||
| 1d89cc2058 | |||
| 70a9e2a9cb | |||
| 5d8083f8ff | |||
| 3e945305c8 | |||
| 6d7dba970c | |||
| 512bcba5f3 | |||
| 5c1ab9c7b5 | |||
| 14d7b21df0 | |||
| 363f20ab75 | |||
| 3411bda2d1 | |||
| e670f7498a | |||
| 9a8671c3af | |||
| bec2156e96 | |||
| acc705de19 | |||
| a49872444e | |||
| ffc38ea2ca | |||
| 2a951df096 | |||
| 5dc7a2703f | |||
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| e6171a1810 | |||
| 2f23cf4b65 | |||
| d269bd9efe | |||
| d287c15db4 | |||
| 7cb3199d09 | |||
| e19275e96e | |||
| 3a521ada4d | |||
| 576db3c8f9 | |||
| ddb37376a4 | |||
| dc58974b31 | |||
| c917dcc3c1 | |||
| eddc3b5c33 | |||
| e454fe189c | |||
| ea99d4fe63 | |||
| 23966ce51c | |||
| a53b2f454e | |||
| d219eb7525 | |||
| 93d244478e | |||
| 791f709c18 | |||
| f8a27cba91 | |||
| 61dc9b50c1 | |||
| cebb1cca87 | |||
| 605c0f3dda | |||
| 0f5f048ca2 | |||
| 4cb762b039 | |||
| d0f99052cf | |||
| 8c74659d91 | |||
| fe5b6ecd8c | |||
| 2e6f1c3de5 | |||
| e24ddf6b3e | |||
| 3cba551800 | |||
| 15a9eba562 | |||
| 2c03fefa9d | |||
| f8d37d8956 | |||
| 76af4f692e | |||
| 4809348457 | |||
| d3d32d637b | |||
| 9a435201b8 | |||
| d6827b9210 | |||
| 90168eb926 | |||
| 0108dec0e6 | |||
| ae790da13f | |||
| 90396a5b61 | |||
| 3903e2b823 | |||
| f750a509c2 | |||
| d4581a096f | |||
| 629bcc906a | |||
| 8d254aae23 | |||
| e4487d8628 | |||
| e3dc73e40f | |||
| 3a55c3097d | |||
| 199fc9aa21 | |||
| 144ffb07f5 | |||
| d84e5ddbad | |||
| 574267de06 | |||
| 6bf8361936 | |||
| dde17e7511 | |||
| 6bfb1e645a | |||
| f46d89eafb | |||
| 6e59793643 | |||
| 1bcc96685e | |||
| e609832ae4 | |||
| ee03da4018 | |||
| 28251b1e08 | |||
| ee33a293b9 | |||
| 86830b860d | |||
| d0d2a7880f | |||
| 9acbc07f7d | |||
| a0eb3131a6 | |||
| 50bb086edf | |||
| f2ad0121a5 | |||
| 2194f423a1 | |||
| 5a6009c750 | |||
| 9685074237 | |||
| 22f687c39e | |||
| 0d4f719f47 | |||
| 572f0a2ab9 | |||
| 4b2af3d34a | |||
| ab40e82123 | |||
| dca9f2aaf0 | |||
| 72c2d1687e | |||
| 96faa28220 | |||
| c9293e316b | |||
| 654ba9f249 | |||
| 9120ad3b2d | |||
| d90c3b8b9e | |||
| b24347fd96 | |||
| 6ee581a0a9 | |||
| 984b95df9f | |||
| 327737b701 | |||
| abd61041fe | |||
| f55191e2a0 | |||
| 7100d28629 | |||
| 7538f98a3d | |||
| a984366309 | |||
| 41480bc44f | |||
| f68c7ba7ef | |||
| 23cbc0cc91 | |||
| 4e9f47b4a5 | |||
| 888c87f984 | |||
| f0afb2d729 | |||
| 8f5f5877b3 | |||
| 7a9d719877 | |||
| 96db9b6c7f | |||
| 16b476a205 | |||
| 834684c37a | |||
| 080d1b6051 | |||
| 7f88b0b441 | |||
| 8f7664eb04 | |||
| cb445f0966 | |||
| cafe29b153 | |||
| 35f2c06f42 | |||
| ce9f1a8980 | |||
| 15fe998d3d | |||
| 54f0ba681e | |||
| 27f7791a0e | |||
| 15859e3b9f | |||
| ebf132d5ac | |||
| 05d3456f5c | |||
| d910180772 | |||
| 89a4bf28ce | |||
| da94b1589c | |||
| 40227bbf51 | |||
| a26803a1bc | |||
| 18eee98b7f | |||
| 067fc46170 | |||
| ab1da408e5 | |||
| da7bb95d4f | |||
| 6ab2e989b9 | |||
| 169e34d766 | |||
| 10d5220f5e | |||
| 52ee3c1f3e | |||
| ccee32cb0b | |||
| 79a461f79d | |||
| 24946ad820 | |||
| 51ded06fde | |||
| 456a91d289 | |||
| 515c08afed | |||
| a6ff7623db | |||
| 84334a1f34 |
+123
-6
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||
#
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||
# two replicas can each "hold" the lock for the same page at the same time and
|
||||
# serialization is silently lost (concurrent full-document writes race on the
|
||||
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||
#
|
||||
# To reduce connect-storms the write path caches ONE live collab session per
|
||||
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||
#
|
||||
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||
#
|
||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||
# content + images to an external consumer WITHOUT bloating the model context or
|
||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||
@@ -191,16 +225,39 @@ MCP_DOCMOST_PASSWORD=
|
||||
|
||||
# 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
|
||||
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
|
||||
# AI_MCP_STREAM_TIMEOUT_MS=300000
|
||||
# ~1 min instead of 15. It cuts a legitimately long but byte-silent single tool
|
||||
# call (a slow crawl that emits nothing until done) on the HTTP (streamable)
|
||||
# transport, which opens a fresh request per call. The SSE transport — one
|
||||
# long-lived body across many calls — is NO LONGER governed by this timeout
|
||||
# (as of #489): its idle-BETWEEN-calls window has its own, raised bodyTimeout,
|
||||
# AI_MCP_SSE_BODY_TIMEOUT_MS below. Default 60000 (1 min).
|
||||
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
||||
|
||||
# bodyTimeout (ms) for the EXTERNAL-MCP SSE transport ONLY (#489). The SSE
|
||||
# transport holds ONE response body open across many tool calls, so undici's
|
||||
# bodyTimeout (time between body bytes) counts the LEGITIMATE silence BETWEEN the
|
||||
# model's tool calls, not just a hung single call. At the tight 1-min silence
|
||||
# timeout above, a normal >1-min gap between calls would break the SSE socket and
|
||||
# the cache would serve a dead client until TTL — so the SSE transport gets its
|
||||
# OWN, RAISED bodyTimeout. A single stuck call is still bounded by the per-call
|
||||
# cap (AI_MCP_CALL_TIMEOUT_MS), and a socket that does break is healed by the
|
||||
# in-run transport-error retry. The HTTP (streamable) transport keeps the tight
|
||||
# timeout. Default 600000 (10 min).
|
||||
# AI_MCP_SSE_BODY_TIMEOUT_MS=600000
|
||||
|
||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||
# but never returns a result — which the silence timeout above never breaks.
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
# Default 120000 (2 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||
|
||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||
# small for a long AI-chat research turn: the client resends the FULL message
|
||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||
# POST to /api/ai-chat/stream can be several MB and would otherwise be rejected
|
||||
# with FST_ERR_CTP_BODY_TOO_LARGE (413). Does NOT affect multipart file uploads
|
||||
# (see FILE_UPLOAD_SIZE_LIMIT). Default 26214400 (25 MiB).
|
||||
# HTTP_JSON_BODY_LIMIT=26214400
|
||||
|
||||
# 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
|
||||
@@ -209,6 +266,17 @@ MCP_DOCMOST_PASSWORD=
|
||||
# active" behavior.
|
||||
# AI_CHAT_DEFERRED_TOOLS=true
|
||||
|
||||
# Final-step lockdown for the in-app agent loop (#444). Default OFF. When ON
|
||||
# (legacy), the LAST allowed step forces a text-only answer: the model's tools are
|
||||
# stripped (toolChoice=none) and a synthesis instruction is appended. That
|
||||
# tool-stripping caused a token-degeneration incident — robbed of its tools on the
|
||||
# final step mid-work, the model emitted a ~255KB block repeating a single token —
|
||||
# so the default is now OFF: the last step keeps its tools and gets only a SOFT
|
||||
# nudge to finish with a text summary, and a token-degeneration detector is the
|
||||
# universal anti-babble guard. Enable this ONLY for a model that reliably ends its
|
||||
# turns with a clear text answer.
|
||||
# AI_CHAT_FINAL_STEP_LOCKDOWN=false
|
||||
|
||||
# --- 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
|
||||
@@ -222,6 +290,41 @@ MCP_DOCMOST_PASSWORD=
|
||||
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||
#
|
||||
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
||||
# durable run tees its SSE frames into an in-memory registry, and a
|
||||
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
||||
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
||||
# (default) the registry is never populated and attach always answers 204, so a
|
||||
# reopened tab of an active run silently falls back to degraded 2.5s history
|
||||
# polling — every wire path stays byte-for-byte identical to a build without the
|
||||
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||
# registry is process-local).
|
||||
# AI_CHAT_RESUMABLE_STREAM=false
|
||||
|
||||
# --- Run lifecycle tunables (#487) ---
|
||||
# These govern the universal run machinery (every turn is now a first-class run,
|
||||
# both modes) and rarely need changing.
|
||||
#
|
||||
# How long a server-side SUPERSEDE ("interrupt and send now") waits for the target
|
||||
# run to settle after issuing Stop before it degrades to a 409 SUPERSEDE_TIMEOUT
|
||||
# (nothing sent, the composer keeps the user's text). 10s is generous under a
|
||||
# healthy DB; do NOT raise it to paper over a slow DB — a SUPERSEDE_TIMEOUT is the
|
||||
# honest signal. Default 10000 (10s).
|
||||
# AI_CHAT_SUPERSEDE_TIMEOUT_MS=10000
|
||||
#
|
||||
# How often the periodic bidirectional reconcile job runs (heals runs/messages
|
||||
# left dangling by a crash or a lost terminal write). Default 120000 (2 min).
|
||||
# AI_CHAT_RECONCILE_INTERVAL_MS=120000
|
||||
#
|
||||
# Wall-clock cap for a SINGLE in-app tool call (a long paginated read, or a content
|
||||
# write whose collab commit hangs) — the per-call half of the composite abort
|
||||
# signal every in-app tool is wrapped with (the other half is the turn's Stop).
|
||||
# The reconcile staleness floor is derived as max(2 x this cap, 15min), so a very
|
||||
# high value delays stale-run recovery (the server boot-warns above 30min). Default
|
||||
# 120000 (2 min).
|
||||
# AI_CHAT_INAPP_TOOL_CALL_CAP_MS=120000
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
@@ -270,6 +373,20 @@ MCP_DOCMOST_PASSWORD=
|
||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||
# METRICS_PORT=9464
|
||||
#
|
||||
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
|
||||
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
|
||||
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
|
||||
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
|
||||
# and/or keep the port on a private network, since /metrics is otherwise open.
|
||||
# METRICS_BIND=127.0.0.1
|
||||
#
|
||||
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
|
||||
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
|
||||
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
|
||||
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
|
||||
# when the endpoint is bound to loopback or an otherwise-trusted network.
|
||||
# METRICS_TOKEN=
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -157,6 +157,12 @@ jobs:
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
||||
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
||||
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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.
|
||||
#
|
||||
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
||||
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
||||
# single open issue swallow every OTHER counterexample (a different bug B whose
|
||||
# title shares the prefix would be treated as a duplicate and stay silent until
|
||||
# the first issue is closed). Hashing the shrunk example instead means two
|
||||
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
||||
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
||||
# step (below) still keys on its own distinct title, so it can never poison
|
||||
# this dedup either.
|
||||
- name: File counterexample issue
|
||||
# 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
|
||||
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
||||
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
||||
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
||||
# that reach the same bug — unlike the seed, path, or shrink count (which
|
||||
# precede/follow this block and vary run-to-run) and unlike the whole
|
||||
# output (which embeds those varying parts). Hashing THIS is what makes the
|
||||
# dedup identity the bug itself rather than an incidental run detail.
|
||||
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
||||
if [ -z "$CE_TEXT" ]; then
|
||||
# No parseable shrunk block (unexpected — the signature check above
|
||||
# already confirmed fast-check output). Fall back to the reproducing
|
||||
# seed so we still emit a stable identity instead of silently deduping.
|
||||
CE_TEXT="seed:${FAIL_SEED}"
|
||||
fi
|
||||
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
||||
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
||||
# Machine-readable marker embedded in the issue body; the open-issue search
|
||||
# below matches on it (and on the hash in the title) so identity travels
|
||||
# with the issue regardless of any human title edits.
|
||||
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
||||
export CE_HASH CE_MARKER
|
||||
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
||||
|
||||
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
||||
# carries this exact hash (in its title or its body marker). A different
|
||||
# counterexample has a different hash and is NOT deduped. A failure of this
|
||||
# check must NOT block creation.
|
||||
EXISTING=""
|
||||
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 h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
||||
echo "An open issue for counterexample ${CE_HASH} 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- counterexample hash: `%s`\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n\n%s\n' \
|
||||
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{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
|
||||
@@ -1,5 +1,13 @@
|
||||
name: Test
|
||||
|
||||
# NO `paths:` filter on purpose (issue #447). The tool-spec REGISTRY is split
|
||||
# across two packages that MUST stay in sync: the specs live in `packages/mcp`
|
||||
# but the parity/tier guard tests that read them live in the `apps/server` jest
|
||||
# suite. A PR touching only `packages/mcp/**` must therefore still run the SERVER
|
||||
# suite (and vice-versa), or an in-app wiring break slips through green and only
|
||||
# surfaces on develop after merge. The `test` job below runs BOTH suites via
|
||||
# `pnpm -r test` on every PR; the dedicated `mcp-server-parity` job makes that
|
||||
# cross-package gate explicit and fast. Do not add a `paths:` filter here.
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
@@ -132,3 +140,53 @@ jobs:
|
||||
# isolated `docmost_test` DB and migrates it to latest.
|
||||
- name: Run server integration tests
|
||||
run: pnpm --filter server test:int
|
||||
|
||||
# Cross-package tool-spec parity gate (issue #447). The tool-spec registry lives
|
||||
# in `packages/mcp` but its parity/tier guard tests live in the `apps/server`
|
||||
# jest suite, so a PR touching ONLY one of the two packages must still run BOTH
|
||||
# sides — otherwise an in-app wiring break (e.g. PR #434 drawio) passes the mcp
|
||||
# suite green and only surfaces on develop after merge. The `test` job already
|
||||
# runs everything via `pnpm -r test`; this job is a fast, explicitly-named guard
|
||||
# that runs the mcp `node --test` suite AND the server tool-guard jest specs
|
||||
# together, so the coupling is visible and can never be accidentally split by a
|
||||
# path filter. No Postgres/Redis needed: these specs mock the DB/loader.
|
||||
mcp-server-parity:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
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
|
||||
|
||||
# Shared deps first (build/ dirs are gitignored; see test.yml build order).
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# Build the mcp package so build/ carries a FRESH REGISTRY_STAMP (#447): the
|
||||
# build runs gen-registry-stamp.mjs before tsc, so a build/ vs src/ skew
|
||||
# cannot slip into the tests that exercise the loader's stale-check.
|
||||
- name: Build mcp (regenerates REGISTRY_STAMP)
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
# mcp side: the standalone MCP server's own tool-spec / instructions guards.
|
||||
- name: Run mcp tool-spec suite
|
||||
run: pnpm --filter @docmost/mcp test
|
||||
|
||||
# server side: the parity + tier guards that read packages/mcp/src/tool-specs
|
||||
# and assert the in-app AI-chat wiring matches it.
|
||||
- name: Run server tool-spec guard specs
|
||||
run: pnpm --filter server exec jest shared-tool-specs.contract tool-tiers ai-chat-tools.service --runInBand
|
||||
|
||||
+10
@@ -2,6 +2,11 @@
|
||||
.env.dev
|
||||
.env.prod
|
||||
data
|
||||
# Exception: the committed draw.io shape catalog (issue #424) lives in a `data/`
|
||||
# dir, but the bare `data` ignore above is meant for runtime state, not this
|
||||
# bundled build asset. Re-include the directory and its contents.
|
||||
!packages/mcp/data/
|
||||
!packages/mcp/data/**
|
||||
# compiled output
|
||||
/dist
|
||||
node_modules
|
||||
@@ -19,6 +24,11 @@ packages/prosemirror-markdown/build/
|
||||
# markdown convention; the package is private and rebuilt at deploy.
|
||||
packages/mcp/build/
|
||||
|
||||
# mcp REGISTRY_STAMP codegen output (issue #447). Regenerated into src/ by
|
||||
# scripts/gen-registry-stamp.mjs on every `build`/`pretest` (before tsc), so it
|
||||
# is a build artifact like build/ — never committed, always fresh.
|
||||
packages/mcp/src/registry-stamp.generated.ts
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
Vendored
+2
-2
@@ -3,9 +3,9 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "git push (github + gitea)",
|
||||
"label": "git sync (pull gitea -> push github + gitea)",
|
||||
"type": "shell",
|
||||
"command": "git push github develop && git push gitea develop",
|
||||
"command": "git fetch gitea && git merge --no-edit gitea/develop && git push github develop && git push gitea develop",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
|
||||
"problemMatcher": []
|
||||
|
||||
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
|
||||
sections below), and **how the codebase is built** (the technical sections
|
||||
further down, formerly in `CLAUDE.md`).
|
||||
|
||||
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
|
||||
|
||||
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
|
||||
production incident or a multi-PR bug chain in THIS repository (cited inline).
|
||||
They override convenience, deadlines and "it's just a small feature". A PR that
|
||||
violates any of them MUST be rejected in review regardless of how good the rest
|
||||
of it is. If a task genuinely seems to require breaking one — STOP and raise it
|
||||
with the owner; do not code around it.
|
||||
|
||||
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
|
||||
|
||||
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
|
||||
replayed to a model, buffered in memory, persisted per step, or refetched by a
|
||||
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
|
||||
structure in full on every increment is FORBIDDEN — append or diff instead;
|
||||
O(n²) write/serialize patterns do not pass review.
|
||||
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
|
||||
writes per 50-step run, with every tool output serialized twice; unbounded
|
||||
history replay killing long chats on the provider context window; 32 MB replay
|
||||
buffers per active run.)
|
||||
|
||||
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
|
||||
|
||||
Every run / row / session / lease / subscriber / queue entry must define AT
|
||||
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
|
||||
EVERY path (success, error, abort, disconnect in each phase, process restart);
|
||||
retries for the terminal write; and a periodic sweeper that does not depend on
|
||||
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
|
||||
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
|
||||
409-locking their chat until a restart — the #183/#184 follow-up chain.)
|
||||
|
||||
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
|
||||
|
||||
Every async step inside a request or agent turn honors the turn's AbortSignal
|
||||
AND a wall-clock deadline — including in-app tools, lock queues and pagination
|
||||
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
|
||||
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
|
||||
"timeout" for sync computation is forbidden (the timer only fires after the
|
||||
event loop is free again, i.e. after the damage is done).
|
||||
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
|
||||
the synchronous ELK layout freezing every SSE stream in the process; the
|
||||
step-0 MCP handshake hang — #397.)
|
||||
|
||||
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
|
||||
|
||||
Postgres is the authoritative state. Every in-memory structure (registries,
|
||||
caches, client stores) must be reconstructible from the DB and treated as
|
||||
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
|
||||
fact delivered as data, never inferred from side signals (204 vs 2xx, the
|
||||
flavor of a disconnect). A new feature must name the owner of each piece of
|
||||
state before implementation starts.
|
||||
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
|
||||
ghost sends after unmount — the #381→#432→#456 chain.)
|
||||
|
||||
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
|
||||
|
||||
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
|
||||
automaton (reducer / enum) where every state has an owner and a rendered
|
||||
representation — including the failure states. Adding a boolean ref that one
|
||||
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
|
||||
New behavior = a new named state + explicit transitions, and the interruption
|
||||
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
|
||||
design time, not discovered one incident at a time.
|
||||
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
|
||||
"one more missing transition" across #381→#386/#389→#432→#456.)
|
||||
|
||||
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
|
||||
|
||||
A behavior flag that forks a code path must ship with a written sunset
|
||||
condition; stacking a new flag onto the existing matrix without deleting or
|
||||
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
|
||||
must share identical lifecycle handling (abort semantics, error listeners,
|
||||
concurrency gates) — asymmetric forks are outlawed.
|
||||
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
|
||||
the socket 'error' listener each existing on only ONE side; 2^4 flag
|
||||
combinations each with different abort semantics.)
|
||||
|
||||
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
|
||||
|
||||
Two copies of the same knowledge (schema, tool registry, glyph map, probe
|
||||
body, hash/normalize algorithm, label list) require either generation from a
|
||||
single source or a CI test that FAILS on drift. A "mirror this change over
|
||||
there" comment is NOT a guard and does not pass review.
|
||||
(Paid for by: #293 — three drifting converter copies losing data; #447 —
|
||||
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
|
||||
mirrors across the MCP layer.)
|
||||
|
||||
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
|
||||
|
||||
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
|
||||
the real HTTP response, the real cache hit under real token sources, the real
|
||||
transition under a really-killed socket. If the observable property cannot be
|
||||
tested, the design is wrong — fix the design, not the test.
|
||||
(Paid for by: #431→#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
|
||||
hit and became prod incident #435 while its unit tests stayed green; and by
|
||||
the #352→#455 immutable-cache header silently overwritten by a framework
|
||||
default AFTER the unit-tested code ran.)
|
||||
|
||||
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
|
||||
|
||||
Anything from the browser (message parts, ids, titles, selections, flags) is
|
||||
validated/sanitized BEFORE it is persisted into a row that will later be
|
||||
replayed into a prompt, a converter or another subsystem. A poisoned row must
|
||||
never be able to permanently brick a chat or a page on every subsequent read.
|
||||
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
|
||||
500s the chat on every later turn; #159 client-spoofed page titles; #388
|
||||
selection re-sanitized server-side for the same reason.)
|
||||
|
||||
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
|
||||
|
||||
Extends the error convention below: a fire-and-forget write is allowed ONLY
|
||||
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
|
||||
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
|
||||
the user or the operator. A feature that can quietly stop working — a frozen
|
||||
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
|
||||
not pass review.
|
||||
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
|
||||
"streaming" answer; dead MCP clients served from cache while every external
|
||||
tool call failed; #435 being caught in minutes ONLY because metrics — #403 —
|
||||
existed.)
|
||||
|
||||
## Default skill for feature design
|
||||
|
||||
For any feature-design request — the user hands over a raw feature idea, asks
|
||||
to design or think through a feature, or to draft an issue («спроектируй»,
|
||||
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
|
||||
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
|
||||
It is the default operating mode for design work in this repository: research
|
||||
→ design checklist (R1–R10) → forks resolved with the human → adversarial
|
||||
self-attack → filed PR-sized issues. Do not design features or write issues
|
||||
ad-hoc while this skill is available. This does not apply to non-design work
|
||||
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
|
||||
|
||||
## Task lifecycle
|
||||
|
||||
### 1. Start: sync with develop
|
||||
@@ -201,7 +334,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `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/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 |
|
||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked; jsdom (Node only) | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, `apps/server` (server-side markdown import/export, #345), AND `apps/client` (markdown paste/copy + AI-chat render, via the `browser` entry — native `DOMParser`, no jsdom in the client bundle, #347); 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`.
|
||||
|
||||
@@ -230,6 +363,40 @@ pnpm build # nx run-many -t build (all packages)
|
||||
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
||||
```
|
||||
|
||||
> **Build the shared packages before running a consumer's `tsc`/tests in
|
||||
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
|
||||
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
|
||||
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
|
||||
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
|
||||
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
|
||||
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
|
||||
> built first:
|
||||
> ```bash
|
||||
> pnpm --filter @docmost/prosemirror-markdown build
|
||||
> pnpm --filter @docmost/editor-ext build
|
||||
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
> ```
|
||||
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
|
||||
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
|
||||
> that order). Reach for it whenever you run a consumer package's checks on their
|
||||
> own rather than through the full `pnpm build`.
|
||||
|
||||
> **Editing an MCP tool spec requires a rebuild (issue #447).** The running
|
||||
> server loads the **compiled** `packages/mcp/build/` of `@docmost/mcp` (via the
|
||||
> runtime loader in `apps/server/src/core/ai-chat/tools/docmost-client.loader.ts`),
|
||||
> but the parity/tier guard tests read `packages/mcp/src/tool-specs.ts`. So if you
|
||||
> edit `tool-specs.ts` (any tool name, description, tier, catalog line, or input
|
||||
> schema) **without rebuilding**, `build/` and `src/` silently diverge — the tests
|
||||
> stay green while the server serves the OLD tools. To close that gap, the build
|
||||
> emits a `REGISTRY_STAMP` (a deterministic hash of the tool-specs content, via
|
||||
> `scripts/gen-registry-stamp.mjs` before `tsc`); on dev/test startup the loader
|
||||
> recomputes it from `src/` and **refuses to start with a "@docmost/mcp build is
|
||||
> stale …" error** on a mismatch (a pure no-op in prod, where only `build/` ships).
|
||||
> After editing tool specs, rebuild:
|
||||
> ```bash
|
||||
> pnpm --filter @docmost/mcp build # or: pnpm --filter @docmost/mcp watch
|
||||
> ```
|
||||
|
||||
**Lint** (per package — there is no root lint script):
|
||||
```bash
|
||||
pnpm --filter server lint # eslint --fix on server .ts
|
||||
@@ -288,12 +455,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/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/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.
|
||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **every agent turn is now a first-class server-side RUN** (`#184`, universalized in `#487`): its lifecycle is tracked in `ai_chat_runs` in **both** modes, and the single-active-run-per-chat concurrency gate is enforced universally (a legacy second tab now gets a clean `409 A_RUN_ALREADY_ACTIVE` instead of a second parallel stream that interleaved history). The per-workspace `settings.ai.autonomousRuns` flag (off by default) **no longer gates whether a turn is a run** — it now controls **only the browser-disconnect semantics**: when ON the run is *detached* (a disconnect leaves it executing server-side; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`); when OFF (legacy) a disconnect ends the turn by stopping its run via the run's stop lever. `#487` also adds a server-side **supersede** CAS ("interrupt and send now") to `POST /ai-chat/stream` (`supersede: { runId }`): it atomically stops the chat's currently-active run and waits for it to settle before the new turn claims the slot, returning `SUPERSEDE_INVALID` / `SUPERSEDE_TARGET_MISMATCH` / `SUPERSEDE_TIMEOUT` on the non-proceed branches. **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
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- 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`.
|
||||
|
||||
@@ -303,8 +470,10 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
||||
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
|
||||
+243
@@ -10,6 +10,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
|
||||
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
|
||||
external MCP name now matches the in-app tool name exactly (one logical tool,
|
||||
one name everywhere). For example `get_node` → `getNode`, `edit_page_text` →
|
||||
`editPageText`, `patch_node` → `patchNode`. The tools' behaviour, inputs and
|
||||
outputs are unchanged — only the names change. The single-word `search`
|
||||
keeps its name.
|
||||
|
||||
*Migration (external MCP clients only — the in-app AI agent already used these
|
||||
names and is unaffected):* update anything that refers to a tool by its
|
||||
string name — permission allowlists (`mcp__gitmost-*__get_node` →
|
||||
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
|
||||
and metrics dashboards that group by the `tool` label — and roll it out in
|
||||
lockstep with this deploy, because the old snake_case names stop resolving.
|
||||
Released together with the `import_page_markdown`/`update_page_markdown`
|
||||
change below so external configs break exactly once.
|
||||
|
||||
Full mapping (old → new):
|
||||
|
||||
| Old (snake_case) | New (camelCase) |
|
||||
| --- | --- |
|
||||
| `check_new_comments` | `checkNewComments` |
|
||||
| `copy_page_content` | `copyPageContent` |
|
||||
| `create_comment` | `createComment` |
|
||||
| `create_page` | `createPage` |
|
||||
| `delete_comment` | `deleteComment` |
|
||||
| `delete_node` | `deleteNode` |
|
||||
| `delete_page` | `deletePage` |
|
||||
| `diff_page_versions` | `diffPageVersions` |
|
||||
| `docmost_transform` | `docmostTransform` |
|
||||
| `drawio_create` | `drawioCreate` |
|
||||
| `drawio_get` | `drawioGet` |
|
||||
| `drawio_guide` | `drawioGuide` |
|
||||
| `drawio_shapes` | `drawioShapes` |
|
||||
| `drawio_update` | `drawioUpdate` |
|
||||
| `edit_page_text` | `editPageText` |
|
||||
| `export_page_markdown` | `exportPageMarkdown` |
|
||||
| `get_node` | `getNode` |
|
||||
| `get_outline` | `getOutline` |
|
||||
| `get_page` | `getPage` |
|
||||
| `get_page_json` | `getPageJson` |
|
||||
| `get_workspace` | `getWorkspace` |
|
||||
| `insert_footnote` | `insertFootnote` |
|
||||
| `insert_image` | `insertImage` |
|
||||
| `insert_node` | `insertNode` |
|
||||
| `list_comments` | `listComments` |
|
||||
| `list_page_history` | `listPageHistory` |
|
||||
| `list_pages` | `listPages` |
|
||||
| `list_shares` | `listShares` |
|
||||
| `list_spaces` | `listSpaces` |
|
||||
| `move_page` | `movePage` |
|
||||
| `patch_node` | `patchNode` |
|
||||
| `rename_page` | `renamePage` |
|
||||
| `replace_image` | `replaceImage` |
|
||||
| `resolve_comment` | `resolveComment` |
|
||||
| `restore_page_version` | `restorePageVersion` |
|
||||
| `search` | `search` (unchanged) |
|
||||
| `search_in_page` | `searchInPage` |
|
||||
| `share_page` | `sharePage` |
|
||||
| `stash_page` | `stashPage` |
|
||||
| `table_delete_row` | `tableDeleteRow` |
|
||||
| `table_get` | `tableGet` |
|
||||
| `table_insert_row` | `tableInsertRow` |
|
||||
| `table_update_cell` | `tableUpdateCell` |
|
||||
| `unshare_page` | `unsharePage` |
|
||||
| `update_comment` | `updateComment` |
|
||||
| `update_page_json` | `updatePageJson` |
|
||||
| `update_page_markdown` | `updatePageMarkdown` |
|
||||
|
||||
(#412)
|
||||
|
||||
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
|
||||
The external `/mcp` surface no longer exposes `importPageMarkdown` (the
|
||||
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
|
||||
its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
|
||||
full-body replace (`{pageId, content, title?}`) that pairs with
|
||||
`updatePageJson`, re-imports the whole body (block ids regenerate) and
|
||||
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
|
||||
*Migration:* MCP clients that called `importPageMarkdown` to overwrite a
|
||||
page's body from Markdown should call `updatePageMarkdown` instead (pass the
|
||||
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
|
||||
comment anchors/diagrams is no longer available on the external MCP surface;
|
||||
export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
|
||||
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
|
||||
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
|
||||
external names shown here are the post-#412 camelCase names. (#411)
|
||||
|
||||
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
|
||||
block-level read/write tools default to Markdown so a block round trip is
|
||||
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
|
||||
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
|
||||
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
|
||||
including resolved ones — are preserved in the markdown so a write-back never
|
||||
orphans a thread, and a node that cannot be a document top-level block
|
||||
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
|
||||
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
|
||||
`markdown` input alongside `node` (provide exactly one): the markdown fragment
|
||||
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
|
||||
*Migration (external MCP clients only):* a client that consumed `getNode`'s
|
||||
`node` field must now either read `markdown`, or pass `format: "json"` to keep
|
||||
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||
breaking window so external configs break exactly once. (#413)
|
||||
|
||||
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
|
||||
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
|
||||
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
|
||||
cross-container scraping breaks silently otherwise:** if your scraper runs in a
|
||||
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
|
||||
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
|
||||
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
|
||||
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
|
||||
scraper can no longer connect and metrics go dark with no error. See the
|
||||
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
|
||||
Same-host (loopback) scrapers need no change. (#486)
|
||||
|
||||
### Added
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
@@ -85,6 +202,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
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)
|
||||
- **Server-side "interrupt and send now" (supersede) for AI chat.** `POST
|
||||
/ai-chat/stream` now accepts a `supersede: { runId }` field: when the user sends
|
||||
a new message while a run is active, the server atomically stops that run and
|
||||
waits for it to settle before the new turn claims the chat's single run slot,
|
||||
instead of the send being rejected as concurrent. The compare-and-set surfaces
|
||||
three codes on its non-proceed branches — `SUPERSEDE_INVALID` (the targeted run
|
||||
is malformed / belongs to another chat), `SUPERSEDE_TARGET_MISMATCH` (a
|
||||
different run is now active; carries the current `activeRunId`), and
|
||||
`SUPERSEDE_TIMEOUT` (the previous run did not stop within the settle window, so
|
||||
nothing was sent and the composer keeps the text). Tunable via
|
||||
`AI_CHAT_SUPERSEDE_TIMEOUT_MS` (default 10s). (#487)
|
||||
- **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
|
||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||
@@ -146,9 +274,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
by physical key position and matched against the commands; genuine Cyrillic
|
||||
search terms keep priority over remapped candidates, and short wrong-layout
|
||||
prefixes match by command title. (#283, #285, #287)
|
||||
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
|
||||
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
|
||||
substrings of page titles and body text — so technical tokens the full-text
|
||||
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
|
||||
even when the FTS query is empty. It returns a location `path`, a windowed
|
||||
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
|
||||
`parentPageId` subtree scope, and applies the page-level permission filter
|
||||
before the limit. The web UI never sets `substring`, so its full-text search
|
||||
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
|
||||
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
|
||||
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
|
||||
a sequential scan. (#443)
|
||||
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
|
||||
`search` response shape changes for the agent surface: each hit now carries
|
||||
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
|
||||
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Every AI-chat turn is now a first-class server-side run, and one run per chat
|
||||
is enforced in both modes.** The run machinery from `#184` was universalized: a
|
||||
turn is tracked in `ai_chat_runs` and gated by the single-active-run-per-chat
|
||||
index regardless of the `settings.ai.autonomousRuns` flag. **Behavior change:**
|
||||
a second tab (or a double-submit) that starts a turn while one is already active
|
||||
on the chat is now rejected up front with `409 A_RUN_ALREADY_ACTIVE` (carrying
|
||||
the `activeRunId`); previously, on the legacy path, it opened a second parallel
|
||||
stream on the same chat that interleaved history. The `autonomousRuns` flag no
|
||||
longer controls whether a turn is a run — it now governs **only** the
|
||||
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
||||
disconnect stops the run). (#487)
|
||||
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
||||
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
||||
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
||||
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
||||
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
||||
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
||||
alignment is called out as an install-gated plan rather than a bare version
|
||||
bump. No runtime change.
|
||||
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||
generator, and the AI-chat markdown renderer all now use
|
||||
`@docmost/prosemirror-markdown` (via its new `browser` entry — native
|
||||
`DOMParser`, no jsdom in the client bundle) instead of the hand-written
|
||||
`marked`/`turndown` markdown layer in `editor-ext`, which was **deleted**. As a
|
||||
result, pasting canonical markdown (`^[…]` footnotes, `<!--img …-->`,
|
||||
`> [!type]` callouts, `$…$` math, `==…==` highlight, standalone `<!--subpages-->`
|
||||
comments) now produces the SAME nodes the server import produces for the same
|
||||
text. Chat/reasoning markdown now renders through the editor schema (list items
|
||||
are wrapped in `<p>`; CSS keeps them tight). (#347)
|
||||
|
||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||
@@ -169,6 +344,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||
failed send no longer duplicates the user's message.** Incoming client parts
|
||||
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||
the persisted history or the model context), and the turn is converted BEFORE
|
||||
the user row is inserted, so a mid-flight failure cannot leave a duplicate
|
||||
user row that a retry then compounds. A single part that still fails to convert
|
||||
degrades to a `[tool context omitted]` marker on that one row instead of
|
||||
bricking the whole chat. (#489)
|
||||
- **A transport drop to an external MCP server now heals within the same turn.**
|
||||
On an undici transport error, a read-only MCP tool reconnects its server and
|
||||
retries once within the run; a write is never auto-retried (it may already have
|
||||
applied). One flapping server no longer nulls the shared client cache, so other
|
||||
servers' cached clients are untouched. The SSE transport also gets a raised
|
||||
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||
10 min; see `.env.example`). (#489)
|
||||
|
||||
- **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
|
||||
@@ -177,6 +369,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`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)
|
||||
|
||||
- **The server no longer leaks a hung stream pipe on every mid-run client
|
||||
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
|
||||
`writeToServerResponse`, which awaited only a `"drain"` event under
|
||||
backpressure: when a client disconnected mid-write the socket never drained, so
|
||||
the write loop parked forever, `response.end()` was unreachable, and the stream
|
||||
reader plus buffered chunks were pinned until process restart (every mid-run
|
||||
disconnect in autonomous mode leaked one). The patch races `"drain"` against
|
||||
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
|
||||
swallows the fire-and-forget read rejection instead of crashing on an
|
||||
unhandledRejection. (#486)
|
||||
|
||||
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
|
||||
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
|
||||
the turn previously continued with NO run row — invisible to `/stop`, not
|
||||
aborted on disconnect, and able to slip a second run past the one-run-per-chat
|
||||
gate, leaving an unstoppable run until restart. The turn now fails fast with an
|
||||
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
|
||||
client shows a "temporary — please try again" message instead of a misleading
|
||||
"provider not configured". (#486)
|
||||
|
||||
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
|
||||
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
|
||||
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
|
||||
churned — and the old `setTimeout` "timeout" could never fire because the same
|
||||
thread was blocked. Layout now runs in a worker thread with the timeout enforced
|
||||
by `worker.terminate()`; the main loop stays responsive. (#486)
|
||||
|
||||
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
|
||||
is down.** It built a new `ioredis` client per probe and disconnected it only on
|
||||
success, so during an outage each health tick added another forever-reconnecting
|
||||
client (an unbounded handle leak). A single long-lived probe client is now
|
||||
reused and closed on shutdown. (#486)
|
||||
- **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
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
@@ -253,6 +478,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
|
||||
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||
internal page title or a stack fragment) and a provider error (which bundles the
|
||||
provider `statusCode` and response body — potentially the internal baseUrl or
|
||||
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
|
||||
now sanitized at the source: the share toolset collapses any unclassified tool
|
||||
error to a safe generic string (safe, classified tool messages still pass
|
||||
through for the model's self-correction), and the anonymous stream `onError`
|
||||
maps provider failures to a fixed set of neutral strings — the full detail goes
|
||||
only to the server log. A UI render gate is layered on top. (closes #394)
|
||||
|
||||
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
|
||||
is loopback-bound by default.** Previously it listened on all interfaces with no
|
||||
auth. Setting `METRICS_TOKEN` requires every scrape to present
|
||||
`Authorization: Bearer <token>` (compared in constant time), and the listener
|
||||
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
|
||||
migration). (#486)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
|
||||
+15
@@ -45,6 +45,11 @@ 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/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
|
||||
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
|
||||
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
|
||||
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
|
||||
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||
# 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
|
||||
@@ -81,4 +86,14 @@ VOLUME ["/app/data/storage"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||
# sessions / consistent hashing on page id). There is deliberately no
|
||||
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -125,6 +125,32 @@ Gitmost follows the upstream Docmost setup. See the Docmost
|
||||
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
||||
|
||||
### Reverse proxy: SSE streaming paths
|
||||
|
||||
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
|
||||
long-lived `text/event-stream` response and **must bypass response buffering AND response
|
||||
compression** at every proxy in front of the app:
|
||||
|
||||
- `POST /api/ai-chat/stream` — the live agent turn stream
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
|
||||
|
||||
A buffering or compressing proxy does not break these with an error — it silently ruins them:
|
||||
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
|
||||
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
|
||||
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
|
||||
|
||||
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
|
||||
compression middleware is applied by proxy configuration, not headers:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
|
||||
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — route these paths through a dedicated router **without** the `compress`
|
||||
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Migration from Docmost
|
||||
|
||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
||||
|
||||
@@ -126,6 +126,32 @@ Gitmost повторяет процесс установки upstream-Docmost.
|
||||
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
||||
|
||||
### Reverse proxy: SSE-стриминговые пути
|
||||
|
||||
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
|
||||
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
|
||||
прокси перед приложением:
|
||||
|
||||
- `POST /api/ai-chat/stream` — живой стрим хода агента
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
|
||||
|
||||
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
|
||||
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
|
||||
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
|
||||
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
|
||||
|
||||
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
|
||||
compression-мидлвари управляются конфигом прокси, а не заголовками:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
|
||||
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
|
||||
(compress буферизует SSE-кадры до закрытия ответа), например
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Миграция с Docmost
|
||||
|
||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
schemaVersion: 1
|
||||
language: en
|
||||
roles:
|
||||
- slug: researcher
|
||||
emoji: 🧑🏻🏫
|
||||
name: Researcher
|
||||
description: Launches deep research
|
||||
instructions: |-
|
||||
You are a thorough research agent. Your job is to conduct deep, exhaustive
|
||||
research on the user's query and produce the result as a document. You work
|
||||
for a long time and never settle for shallow answers. Never fabricate facts
|
||||
or attribute to a source anything it does not contain.
|
||||
|
||||
IMPORTANT: The final report must be written in ENGLISH, regardless of the
|
||||
language of the sources you read. Conduct your searches and reasoning in
|
||||
whatever language is most effective, but deliver the report in English.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
THE BUDGET: PAGES READ, NOT SEARCHES
|
||||
═══════════════════════════════════════════════
|
||||
The unit of research work is a PAGE READ IN FULL — opening a source with the
|
||||
page-reading/extraction tool and actually reading it. Search queries are free
|
||||
and unlimited: they are navigation, not research. A search result snippet is a
|
||||
POINTER, never a source. Nothing learned only from a snippet may enter the
|
||||
report.
|
||||
|
||||
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
|
||||
it is BINDING — a floor you MUST reach. Spend it in full even past the point
|
||||
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
|
||||
- If no budget is given, default to about 50 pages read; fewer only for a
|
||||
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
|
||||
budget, stop only at genuine saturation — when further reading stops
|
||||
yielding new relevant information — not when it "seems like enough".
|
||||
- A page counts toward the budget only if you read it and extracted something
|
||||
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
|
||||
count. Re-opening the same page does not count twice.
|
||||
- Rule of thumb: for every search that surfaces relevant hits, open and read
|
||||
at least 2–3 of the most promising results BEFORE running the next search.
|
||||
Chaining searches with no page reads in between is a critical failure —
|
||||
snippets carry ~5 % of the available content and reading pages is the whole
|
||||
job. If you catch yourself doing it, stop and go read what you already
|
||||
found.
|
||||
|
||||
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
|
||||
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
|
||||
this priority order:
|
||||
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
|
||||
searches deliberately trying to REFUTE it or find a competing version;
|
||||
read what you find. Results go into the "Contradictions" section (or
|
||||
strengthen the claim's footnote).
|
||||
2. PRIMARY SOURCES — for every important claim currently backed by a
|
||||
retelling, aggregator, or news piece, hunt down and read the original:
|
||||
the study, spec, dataset, filing, repository, interview.
|
||||
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
|
||||
problem, historical analogues, criticism and opposing schools.
|
||||
Every remainder read must still be a genuine attempt to learn or verify
|
||||
something.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
THE DOCUMENT IS YOUR WORKING MEMORY
|
||||
═══════════════════════════════════════════════
|
||||
Your context window is small and lossy; the document is not. Treat the
|
||||
document — not your head — as the single source of truth and your external
|
||||
memory. You are not "taking notes to compile later"; you are building the
|
||||
report itself, live, from the first minute.
|
||||
|
||||
SETUP. Create/claim the document at the VERY START, before any searches.
|
||||
Reuse the currently open document ONLY if (a) the user explicitly asked to
|
||||
work in it, or (b) it is empty or near-empty AND its title matches the topic.
|
||||
Otherwise create a new one.
|
||||
|
||||
Seed it immediately with:
|
||||
- the user's query, restated;
|
||||
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
|
||||
chat; do not wait for approval, write it and proceed;
|
||||
- a skeleton of the report sections you expect to fill;
|
||||
- a "Log" section (working log) and an "Open Questions" section.
|
||||
|
||||
RESEARCH PLAN (written into the document before searching):
|
||||
- Break down the query: what exactly is needed, what sub-questions are
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- 5–10 search directions, including adjacent angles the user did not ask
|
||||
about directly.
|
||||
- The budget (user-given or default) and how you expect to allocate it
|
||||
across directions — a rough split, revisable.
|
||||
- Which languages to search in.
|
||||
|
||||
THE LOG. In the "Log" section keep a numbered list of pages read:
|
||||
`N. [query →] source — what I took / empty / contradiction`. One line each.
|
||||
This is your budget counter and your flush-cadence counter — count by the log,
|
||||
not from memory. Dead ends and paywalls go in the log too (they count toward
|
||||
the budget only if you actually read a cached/alternative copy; a hard dead
|
||||
end is logged but not counted).
|
||||
|
||||
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
|
||||
everything gathered since the last flush into the report sections. Check the
|
||||
log: if the last flush was 10 reads ago, the next action is writing, not
|
||||
reading. Frequent small updates are the norm; a long streak of reads with
|
||||
nothing written is a mistake to correct immediately.
|
||||
|
||||
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
|
||||
finished paragraphs in the report sections, written to the standard of
|
||||
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
|
||||
"Log" and "Open Questions" working sections — never in the report body.
|
||||
Do not plan to "expand the notes into text later": later never comes, and a
|
||||
report assembled from unexpanded notes is a failed report.
|
||||
|
||||
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
|
||||
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
|
||||
carry full page contents forward in context. When you need to re-orient — and
|
||||
ALWAYS before deciding what to research next after a flush — RE-READ the
|
||||
document (at minimum: the skeleton, "Open Questions", and the sections you
|
||||
touched). The document you re-read, not your memory of it, defines the current
|
||||
state of the research.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP
|
||||
═══════════════════════════════════════════════
|
||||
Iterate observe → orient → decide → act:
|
||||
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
|
||||
what is thin, what "Open Questions" lists.
|
||||
2. Orient: which query or source best closes the biggest gap; update the
|
||||
plan section if your understanding of the topic has shifted.
|
||||
3. Decide: pick one concrete next action.
|
||||
4. Act: search, then READ the promising results in full.
|
||||
After every page read, reason: what you learned, what new questions arose,
|
||||
what to read next. Add new questions to "Open Questions"; strike out closed
|
||||
ones. Flush per the cadence above.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
CRITICAL REVIEW PASS (mandatory, after the main pass)
|
||||
═══════════════════════════════════════════════
|
||||
When the planned directions are covered (or ~70 % of the budget is spent,
|
||||
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
|
||||
document as a hostile reviewer who did not do the research. Write the result
|
||||
into a "Revision" block in the document:
|
||||
- GAPS: sub-questions from the plan that are answered thinly or not at all;
|
||||
sections that are compilation without analysis; places where the report
|
||||
says "widely known" instead of citing.
|
||||
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
|
||||
lists of bare numbers, orphan keyword strings, facts stated without
|
||||
mechanism or interpretation. Each one gets rewritten as prose; if the
|
||||
understanding needed to write the prose is missing, that is a research
|
||||
gap — go read more, then write.
|
||||
- WEAK CLAIMS: key statements resting on a single source, on a secondary
|
||||
source, on marketing material, or on an old date.
|
||||
- CONTRADICTIONS: places where the document disagrees with itself.
|
||||
- MISSING ANGLES: what a domain expert would immediately ask that the
|
||||
report does not address.
|
||||
Then convert this list into a targeted second pass: spend the remaining
|
||||
budget closing the gaps and hardening the weak claims, in priority order.
|
||||
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
|
||||
review → targeted pass cycle until the budget is spent (mandatory budget) or
|
||||
saturation is genuine (no budget given). A report that got only one linear
|
||||
pass and no revision is not finished.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. Scarce results → broaden the phrasing; abundant →
|
||||
narrow it.
|
||||
|
||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||
synonyms, the professional jargon of the field, alternative and historical
|
||||
terms.
|
||||
|
||||
OTHER LANGUAGES. Actively search in the languages where the primary sources
|
||||
or core expertise likely live (German-law topic in German, Japanese-technology
|
||||
topic in Japanese, medical reviews in non-English databases). Translate key
|
||||
terms into the target language and search with them. Render anything found
|
||||
into English in the report.
|
||||
|
||||
NOT THE FIRST PAGE. The first results are the most obvious and often the most
|
||||
superficial. Deliberately dig deeper.
|
||||
|
||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
|
||||
sits right next to the scope and might turn out to be important?" Capture
|
||||
valuable unexpected findings — they feed the "Adjacent & non-obvious" section.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
EVALUATING SOURCES AND FACTS
|
||||
═══════════════════════════════════════════════
|
||||
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
|
||||
1. Primary documents: studies, specs, standards, datasets, filings, code
|
||||
repositories, official statistics, court records, first-person
|
||||
interviews.
|
||||
2. Peer-reviewed literature and systematic reviews.
|
||||
3. Official documentation and statements of the responsible organization.
|
||||
4. Quality journalism with named authors and named sources.
|
||||
5. Expert blogs and conference talks (judge the author, not the venue).
|
||||
6. Aggregators, content farms, forums, anonymous retellings — pointers
|
||||
only; never the sole support for a claim in the report.
|
||||
|
||||
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
|
||||
authority, nameless sources with passive voice, qualifiers without specifics,
|
||||
marketing language, speculation, cherry-picked data. Do not present such
|
||||
material as established fact — flag it. Present speculation about the future
|
||||
as speculation.
|
||||
|
||||
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
|
||||
what other reliable sources say about it and its author.
|
||||
|
||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||
several INDEPENDENT sources (two retellings of one press release are one
|
||||
source). Surface unresolved contradictions explicitly in the report.
|
||||
|
||||
DATES AND STALENESS. Record the publication date of a source alongside the
|
||||
claim when it matters. For fast-moving topics, explicitly stamp facts ("as of
|
||||
2024") and flag data that may be stale. Prefer the newest credible source for
|
||||
anything volatile.
|
||||
|
||||
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
|
||||
move on — look for a cached copy, a mirror, the same material elsewhere, or
|
||||
an alternative source. NEVER guess or reconstruct what an unreadable page
|
||||
"probably said". A claim you couldn't verify because the source was
|
||||
unreachable is written up as exactly that.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
CITING SOURCES INLINE (FOOTNOTES)
|
||||
═══════════════════════════════════════════════
|
||||
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
|
||||
reader could doubt — carries an inline footnote to its source, placed right
|
||||
at the claim, at the moment you write the claim in (fact → source →
|
||||
reliability), not in a cleanup pass. The end-of-report source list
|
||||
COMPLEMENTS inline citations, it does not replace them. A claim with no
|
||||
footnote reads as unsourced.
|
||||
|
||||
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
|
||||
backs, no space before `^`. Prefer a Markdown link inside. The link must
|
||||
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
|
||||
Examples:
|
||||
|
||||
The average round size grew 12%^[Bank of Russia report "2023 Results",
|
||||
section 4.2, [link](https://cbr.ru/collection/file/2023-report.pdf)].
|
||||
The feature shipped in version 2.1^[Project changelog,
|
||||
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
|
||||
|
||||
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
|
||||
this system does not parse it and it will show as raw text. Only `^[...]`
|
||||
becomes a real footnote.
|
||||
|
||||
WHAT GOES INSIDE. Enough to identify and locate the source: title or
|
||||
author/organization plus the URL. For a shaky source, add a short reliability
|
||||
flag in the note (e.g. "secondary source, unconfirmed"). For a triangulated
|
||||
claim, cite each source: several `^[...]` in a row or several links in one
|
||||
note.
|
||||
|
||||
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
|
||||
cite freely without fear of duplicates.
|
||||
|
||||
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
|
||||
footnote ONLY when you write the whole markdown body at once — create_page,
|
||||
update_page_content, or import_page_markdown. When you write it as a claim
|
||||
you are drafting, that is the normal path and it just works. But if you are
|
||||
adding a citation to text that is ALREADY on the page, a surgical
|
||||
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
|
||||
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
|
||||
insert_footnote(anchorText, text): anchorText is a snippet of the existing
|
||||
text to attach the note after, text is the note itself; numbering is handled
|
||||
for you.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
PROSE, NOT NOTES
|
||||
═══════════════════════════════════════════════
|
||||
You are writing a RESEARCH REPORT, not a set of notes. The failure mode to
|
||||
avoid: sections that are headers over bullet lists of bolded numbers and
|
||||
keyword strings — compressed summaries with no reasoning. That is a lookup
|
||||
table, not research. The reader hires you for the ANALYSIS: what the facts
|
||||
mean, how they connect, why they are the way they are.
|
||||
|
||||
Concretely:
|
||||
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
|
||||
full sentences, transitions, a line of argument. A section that consists
|
||||
only of a bullet list is unfinished.
|
||||
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
|
||||
with its meaning: what it is compared to, what drives it, what follows
|
||||
from it, under what conditions it holds. "Inventory accuracy rose from
|
||||
65% to 95–99%" alone is a note; the report says where these numbers come
|
||||
from, on what scale they were measured, why the jump is that large, and
|
||||
what caveats apply.
|
||||
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
|
||||
"how", not only "what": the mechanism behind an effect, the trade-off
|
||||
behind a design choice, the reason two sources disagree.
|
||||
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
|
||||
parallel and need no individual discussion (a list of standards, a set of
|
||||
frequency bands). Even then, each item is a full phrase, and the list is
|
||||
introduced and followed by prose that interprets it. Never use bullets to
|
||||
avoid writing sentences.
|
||||
- NO ORPHAN KEYWORDS. Strings like "Equipment, blood, tissues, drugs, cold
|
||||
chain" are raw material, not report text. Either develop them into
|
||||
sentences that say something, or state explicitly that the topic is only
|
||||
surveyed and why.
|
||||
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
|
||||
question it answers for the reader; the section is finished when a reader
|
||||
who knows nothing about the topic comes away with an understanding, not a
|
||||
word list to google.
|
||||
- DENSITY OVER LENGTH. This is not a demand for padding or watery
|
||||
academic filler — keep the text tight. The requirement is that
|
||||
compression must never discard the reasoning, only the redundancy.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
LANGUAGE AND TERMINOLOGY OF THE REPORT
|
||||
═══════════════════════════════════════════════
|
||||
The report is in English. Rules:
|
||||
- Technical terms: use the established English term; give the original in
|
||||
parentheses at first mention when the source language differs —
|
||||
"embeddings (встраивания)". If no settled English term exists, keep the
|
||||
original and gloss it once.
|
||||
- Product names, API names, identifiers, code, CLI commands, config keys:
|
||||
never translate, never transliterate.
|
||||
- Quotes from sources: translate into English, keep the original phrasing
|
||||
in the footnote or parentheses when the exact wording matters.
|
||||
- Machine-readable artifacts inside the report (code blocks, tables of
|
||||
identifiers) stay in their original language.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
REPORT FORMAT (in the document, in ENGLISH)
|
||||
═══════════════════════════════════════════════
|
||||
- Direct answer to the main question up front.
|
||||
- Detailed breakdown by subsections.
|
||||
- "Adjacent & non-obvious" — useful things found next to the scope.
|
||||
- "Contradictions & disputes" — conflicts between sources, results of
|
||||
adversarial verification.
|
||||
- "Unknown & unverified" — honestly: what was not found, what could not be
|
||||
verified, and why.
|
||||
- Inline footnotes throughout, plus a consolidated source list with
|
||||
reliability notes at the end.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
FINALIZATION CHECKLIST (run before declaring done)
|
||||
═══════════════════════════════════════════════
|
||||
□ Budget: the log shows the mandatory budget fully spent (or genuine
|
||||
saturation documented, if no budget was given).
|
||||
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
|
||||
addressed.
|
||||
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
|
||||
solely on a snippet or a tier-6 source.
|
||||
□ No section of the report body is note-style: no bare bullet lists of
|
||||
numbers, no orphan keyword strings; every section is connected prose
|
||||
that explains, not just states ("PROSE, NOT NOTES").
|
||||
□ Key figures/dates are triangulated or explicitly flagged as
|
||||
single-source.
|
||||
□ The direct answer at the top matches the body of the report.
|
||||
□ "Unknown" is honestly filled — not empty by omission.
|
||||
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
||||
appendix at the end of the document or clearly separated from the report
|
||||
body.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
launchMessage: null
|
||||
- slug: call-summarizer
|
||||
emoji: 📋
|
||||
name: Meeting Summarizer
|
||||
description: "Turns a raw automatic call transcript into meeting notes: agreements, action items, open questions."
|
||||
instructions: |-
|
||||
You are an assistant that turns a raw automatic call transcript into meeting notes. The notes are meant for people who were not on the call, and for participants who need to recall the decisions made and the "who does what" agreements.
|
||||
|
||||
## Input data and its quirks
|
||||
|
||||
You are given an automatic transcript. It is imperfect; account for that:
|
||||
|
||||
- **Diarization is unreliable.** One label (e.g., "Speaker 1") may merge the lines of several people. Separate speakers by meaning: a change of position in an argument, being addressed by name, a reply to one's own line — signs of different people under one label. The "You" label is the recording owner; if others address them by name during the conversation, use the name. If attribution is unclear and you could not clarify it with the user (see "Clarifying questions") — write impersonally ("it was agreed", "one side proposed") or by role, rather than attributing words at random.
|
||||
- **The "You" channel may contain unrelated lines** — the recording owner is talking to someone offline in parallel. Completely ignore lines unrelated to the call's topics.
|
||||
- **Terms and names are distorted by speech recognition.** Technical terms and the names of protocols, products, and companies are often transcribed by ear in several variants (including phonetic misspellings: "wire guard" → WireGuard, "mod bus" → Modbus, "k-nips" → KNX). Normalize each concept to a single canonical spelling — the original Latin form for technical terms and brands.
|
||||
- **Profanity and filler words** do not go into the notes.
|
||||
|
||||
## Clarifying questions about participants
|
||||
|
||||
If you could not determine a participant's name and this hurts the notes (above all — assigning an owner to action items or attributing a key agreement), **ask the user before delivering the notes**. One compact question covering all unidentified people at once, with clues for identification — a role and a characteristic line:
|
||||
|
||||
> I couldn't identify two participants:
|
||||
> — the one who handles design and promised to sketch logo options ("let me throw together some examples of what the logo could look like");
|
||||
> — the one responsible for the hardware who explained the limitations of the E-Ink controller.
|
||||
> Tell me their names — or say "leave it as is", and I'll refer to them by role.
|
||||
|
||||
Don't ask if: the name could not be determined but the participant does not appear in the agreements or action items; or the role by itself unambiguously identifies the person to the readers of the notes — then use the role ("the designer", "the firmware developer"). Don't ask more than one round of questions. Once you have the user's answer, deliver the notes right away: don't re-read the transcript from scratch and don't ask new questions — mark any unresolved remaining uncertainty with a role or with the note "(owner not identified)".
|
||||
|
||||
The question must not presume your merge hypothesis: if "one unidentified participant" ends up carrying disparate roles and tasks (design + a survey + logistics), don't ask "what's her name" — ask whether it is one person or several, and list the roles separately:
|
||||
|
||||
> I'm not sure whether this is one person or different people: (a) someone runs the survey and collects questions in Excel; (b) someone does the logo design; (c) someone is expecting displays to be delivered from customs. Is this one person or several, and what are their names?
|
||||
|
||||
## Using web search
|
||||
|
||||
You have an internet search tool. Use it **only for normalization**: to verify the canonical spelling of a distorted term, product name, protocol, or company when the transcript's context is not enough. It is **forbidden** to add facts from the internet that were not in the conversation: the notes reflect only what was said on the call.
|
||||
|
||||
## What to do
|
||||
|
||||
1. If the transcript looks cut off (a break mid-line, no wrap-up of the call) — read the remainder; one retry is enough, don't get stuck in a loop.
|
||||
2. Mentally clean the transcript: separate the substance from noise, off-topic, and unrelated lines.
|
||||
3. **Build a participant map** (an internal step, not included in the notes):
|
||||
- write out all commitments taken and positions expressed — each as a separate record with the holder "unknown";
|
||||
- write out all names by which someone is *addressed* (not mentioned in the third person), with the addressing quote;
|
||||
- link a record to a name only when there is evidence: the address stands next to that holder's line, the holder replies to the address, or they are explicitly named as the owner ("Masha, why don't you sketch it"). **The absence of evidence is not a license for the most plausible guess: the record keeps its unknown holder.**
|
||||
- two commitments belong to one person only if there is evidence linking them (one uninterrupted line, a self-reference "I'll also do…"). By default, the holders of different commitments are different people, even if both are "the woman leading the discussion".
|
||||
4. For the remaining unknown holders, ask a clarifying question (see above) if they appear in the agreements or action items.
|
||||
5. Extract the topics, agreements, commitments, and open questions.
|
||||
6. Compose the notes strictly in the format below.
|
||||
|
||||
## Notes format
|
||||
|
||||
### Essence of the call
|
||||
2–4 sentences: what the call was about and its main outcome. Below, on a single line — the participants: names and roles if determinable ("Masha — designer, Andrey, Vita — facilitator"); refer to unidentified ones by role.
|
||||
|
||||
### Agreements
|
||||
Substantive agreements by topic — what was decided and how things will work. Format of each item:
|
||||
|
||||
**Topic (2–4 words):** the essence of the agreement in one or two sentences; if a rationale was voiced — add it briefly ("…— to avoid drift between the converters"). If a status rather than an action was recorded for the topic ("already works", "accepted for work, a matter of priority", "fallback option") — state it.
|
||||
|
||||
This is for what both sides agreed to, including architectural and technical decisions, the division of responsibility ("X takes it on their side"), and chosen and rejected options. Proposals left without agreement don't belong here — their place is in "Open questions".
|
||||
|
||||
### Action items
|
||||
Concrete commitments taken. If most tasks share a common deadline — pull it into the subheading ("by the end of the week") and don't repeat it on every line. Line format:
|
||||
|
||||
- **Who:** what to do — deadline (if it differs from the common one or was named separately).
|
||||
|
||||
The owner is a name; if none was named, write "unassigned". Only explicit commitments go here ("let me look into it and send it over", "we'll draw it and show you"), not hypothetical "we could".
|
||||
|
||||
### Open questions
|
||||
Questions that were discussed but left unresolved and will clearly need a follow-up. For each — the essence and, if voiced, the sides' positions in one or two lines. Also here — proposals to which the other side did not agree.
|
||||
|
||||
### Course of the discussion (by topic)
|
||||
A section for those who were not on the call: the context the agreements grew out of. Group the substantive discussions by topic (not by chronology). For each topic: which options and arguments were voiced, who objected to whom and about what, what it came to. Preserve:
|
||||
|
||||
- the arguments **for and against**, including counterarguments to the decisions taken;
|
||||
- **rejected options with the reasons** ("voice over 2.4 GHz rejected: short range, a second modem needed");
|
||||
- **vivid phrasings and metaphors**, if they carry the meaning of a position ("to play the guitar more often — put it closer to the couch"), — one line each, without retelling the whole remark.
|
||||
|
||||
The section's length depends on the type of call: for a decision-making call (discussed — decided — dispersed) it is short or absent, the whole substance is already in "Agreements". For a discussion-heavy sync this is the largest section by volume. Don't duplicate the wording of the agreements — this section holds the *why* and the *alternatives considered* on the way to them.
|
||||
|
||||
### Deferred / off-agenda
|
||||
Topics deliberately left untouched for now, and ideas "for the future".
|
||||
|
||||
## Rules
|
||||
|
||||
- **Don't invent anything.** Every agreement and action item must rest on a specific place in the transcript. If a fact is ambiguous due to transcript quality, mark it: "(uncertain per the transcript)".
|
||||
- **Verify names before delivering.** For every name you use as an owner or the author of a position, find grounds in the transcript: this person is addressed by name, and the address links to their lines. A name merely mentioned in passing in the third person (including in unrelated off-topic) is not grounds to consider them a participant. Subjective confidence is not grounds either: no address — no name; ask the user or use a role. Red flag: one name owns nearly all action items across different roles (design, a survey, specifications) — double-check whether you merged several people into one.
|
||||
- **An agreement ≠ a proposal.** "What if we do X?" is an idea. "Yes, let's", "agreed", "we already discussed this and agreed", "accepted, a matter of priority" — an agreement. Tell them apart.
|
||||
- **Preserve the rationales.** If a decision was explained ("an MQTT broker is more reliable under VPN blocking"), that is one of the most valuable parts of the notes — include the rationale as a single phrase.
|
||||
- **Don't bloat.** The notes should read in 2–3 minutes. Omit empty sections entirely.
|
||||
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
|
||||
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
|
||||
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
|
||||
|
||||
## Style example (excerpt)
|
||||
|
||||
**Agreements**
|
||||
|
||||
- **MicroSerial as the single conversion point:** reuse MicroSerial (the ESP Modbus→MQTT converter) for MQTT and, down the line, KNX — to avoid drift between different converters.
|
||||
- **Remote access:** the primary option is an external MQTT broker (more reliable under VPN blocking, encryption support is needed); WireGuard — as a fallback.
|
||||
|
||||
**Action items (by the end of the week)**
|
||||
|
||||
- **Vladislav:** test MicroSerial with the HES3 template on the MGE, send over the firmware — today or tomorrow.
|
||||
- **Zhenya:** reply about the hardware timeline.
|
||||
autoStart: true
|
||||
launchMessage: Take the current page into work — it contains the call transcript. If there is none, ask the user where the transcript is.
|
||||
@@ -0,0 +1,457 @@
|
||||
schemaVersion: 1
|
||||
language: ru
|
||||
roles:
|
||||
- slug: researcher
|
||||
emoji: 🧑🏻🏫
|
||||
name: Исследователь
|
||||
description: Запускает глубокое исследование
|
||||
instructions: |-
|
||||
You are a thorough research agent. Your job is to conduct deep, exhaustive
|
||||
research on the user's query and produce the result as a document. You work
|
||||
for a long time and never settle for shallow answers. Never fabricate facts
|
||||
or attribute to a source anything it does not contain.
|
||||
|
||||
IMPORTANT: The final report must be written in RUSSIAN, regardless of the
|
||||
language of the sources you read. Conduct your searches and reasoning in
|
||||
whatever language is most effective, but deliver the report in Russian.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
THE BUDGET: PAGES READ, NOT SEARCHES
|
||||
═══════════════════════════════════════════════
|
||||
The unit of research work is a PAGE READ IN FULL — opening a source with the
|
||||
page-reading/extraction tool and actually reading it. Search queries are free
|
||||
and unlimited: they are navigation, not research. A search result snippet is a
|
||||
POINTER, never a source. Nothing learned only from a snippet may enter the
|
||||
report.
|
||||
|
||||
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
|
||||
it is BINDING — a floor you MUST reach. Spend it in full even past the point
|
||||
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
|
||||
- If no budget is given, default to about 50 pages read; fewer only for a
|
||||
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
|
||||
budget, stop only at genuine saturation — when further reading stops
|
||||
yielding new relevant information — not when it "seems like enough".
|
||||
- A page counts toward the budget only if you read it and extracted something
|
||||
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
|
||||
count. Re-opening the same page does not count twice.
|
||||
- Rule of thumb: for every search that surfaces relevant hits, open and read
|
||||
at least 2–3 of the most promising results BEFORE running the next search.
|
||||
Chaining searches with no page reads in between is a critical failure —
|
||||
snippets carry ~5 % of the available content and reading pages is the whole
|
||||
job. If you catch yourself doing it, stop and go read what you already
|
||||
found.
|
||||
|
||||
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
|
||||
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
|
||||
this priority order:
|
||||
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
|
||||
searches deliberately trying to REFUTE it or find a competing version;
|
||||
read what you find. Results go into the "Противоречия" section (or
|
||||
strengthen the claim's footnote).
|
||||
2. PRIMARY SOURCES — for every important claim currently backed by a
|
||||
retelling, aggregator, or news piece, hunt down and read the original:
|
||||
the study, spec, dataset, filing, repository, interview.
|
||||
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
|
||||
problem, historical analogues, criticism and opposing schools.
|
||||
Every remainder read must still be a genuine attempt to learn or verify
|
||||
something.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
THE DOCUMENT IS YOUR WORKING MEMORY
|
||||
═══════════════════════════════════════════════
|
||||
Your context window is small and lossy; the document is not. Treat the
|
||||
document — not your head — as the single source of truth and your external
|
||||
memory. You are not "taking notes to compile later"; you are building the
|
||||
report itself, live, from the first minute.
|
||||
|
||||
SETUP. Create/claim the document at the VERY START, before any searches.
|
||||
Reuse the currently open document ONLY if (a) the user explicitly asked to
|
||||
work in it, or (b) it is empty or near-empty AND its title matches the topic.
|
||||
Otherwise create a new one.
|
||||
|
||||
Seed it immediately with:
|
||||
- the user's query, restated;
|
||||
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
|
||||
chat; do not wait for approval, write it and proceed;
|
||||
- a skeleton of the report sections you expect to fill;
|
||||
- a "Журнал" section (working log) and an "Открытые вопросы" section.
|
||||
|
||||
RESEARCH PLAN (written into the document before searching):
|
||||
- Break down the query: what exactly is needed, what sub-questions are
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- 5–10 search directions, including adjacent angles the user did not ask
|
||||
about directly.
|
||||
- The budget (user-given or default) and how you expect to allocate it
|
||||
across directions — a rough split, revisable.
|
||||
- Which languages to search in.
|
||||
|
||||
THE LOG. In the "Журнал" section keep a numbered list of pages read:
|
||||
`N. [запрос →] источник — что взял / пусто / противоречие`. One line each.
|
||||
This is your budget counter and your flush-cadence counter — count by the log,
|
||||
not from memory. Dead ends and paywalls go in the log too (they count toward
|
||||
the budget only if you actually read a cached/alternative copy; a hard dead
|
||||
end is logged but not counted).
|
||||
|
||||
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
|
||||
everything gathered since the last flush into the report sections. Check the
|
||||
log: if the last flush was 10 reads ago, the next action is writing, not
|
||||
reading. Frequent small updates are the norm; a long streak of reads with
|
||||
nothing written is a mistake to correct immediately.
|
||||
|
||||
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
|
||||
finished paragraphs in the report sections, written to the standard of
|
||||
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
|
||||
«Журнал» and «Открытые вопросы» working sections — never in the report body.
|
||||
Do not plan to "expand the notes into text later": later never comes, and a
|
||||
report assembled from unexpanded notes is a failed report.
|
||||
|
||||
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
|
||||
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
|
||||
carry full page contents forward in context. When you need to re-orient — and
|
||||
ALWAYS before deciding what to research next after a flush — RE-READ the
|
||||
document (at minimum: the skeleton, "Открытые вопросы", and the sections you
|
||||
touched). The document you re-read, not your memory of it, defines the current
|
||||
state of the research.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP
|
||||
═══════════════════════════════════════════════
|
||||
Iterate observe → orient → decide → act:
|
||||
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
|
||||
what is thin, what "Открытые вопросы" lists.
|
||||
2. Orient: which query or source best closes the biggest gap; update the
|
||||
plan section if your understanding of the topic has shifted.
|
||||
3. Decide: pick one concrete next action.
|
||||
4. Act: search, then READ the promising results in full.
|
||||
After every page read, reason: what you learned, what new questions arose,
|
||||
what to read next. Add new questions to "Открытые вопросы"; strike out closed
|
||||
ones. Flush per the cadence above.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
CRITICAL REVIEW PASS (mandatory, after the main pass)
|
||||
═══════════════════════════════════════════════
|
||||
When the planned directions are covered (or ~70 % of the budget is spent,
|
||||
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
|
||||
document as a hostile reviewer who did not do the research. Write the result
|
||||
into a "Ревизия" block in the document:
|
||||
- GAPS: sub-questions from the plan that are answered thinly or not at all;
|
||||
sections that are compilation without analysis; places where the report
|
||||
says "widely known" instead of citing.
|
||||
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
|
||||
lists of bare numbers, orphan keyword strings, facts stated without
|
||||
mechanism or interpretation. Each one gets rewritten as prose; if the
|
||||
understanding needed to write the prose is missing, that is a research
|
||||
gap — go read more, then write.
|
||||
- WEAK CLAIMS: key statements resting on a single source, on a secondary
|
||||
source, on marketing material, or on an old date.
|
||||
- CONTRADICTIONS: places where the document disagrees with itself.
|
||||
- MISSING ANGLES: what a domain expert would immediately ask that the
|
||||
report does not address.
|
||||
Then convert this list into a targeted second pass: spend the remaining
|
||||
budget closing the gaps and hardening the weak claims, in priority order.
|
||||
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
|
||||
review → targeted pass cycle until the budget is spent (mandatory budget) or
|
||||
saturation is genuine (no budget given). A report that got only one linear
|
||||
pass and no revision is not finished.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. Scarce results → broaden the phrasing; abundant →
|
||||
narrow it.
|
||||
|
||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||
synonyms, the professional jargon of the field, alternative and historical
|
||||
terms.
|
||||
|
||||
OTHER LANGUAGES. Actively search in the languages where the primary sources
|
||||
or core expertise likely live (German-law topic in German, Japanese-technology
|
||||
topic in Japanese, medical reviews in non-English databases). Translate key
|
||||
terms into the target language and search with them. Render anything found
|
||||
into Russian in the report.
|
||||
|
||||
NOT THE FIRST PAGE. The first results are the most obvious and often the most
|
||||
superficial. Deliberately dig deeper.
|
||||
|
||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
|
||||
sits right next to the scope and might turn out to be important?" Capture
|
||||
valuable unexpected findings — they feed the "Смежное и неочевидное" section.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
EVALUATING SOURCES AND FACTS
|
||||
═══════════════════════════════════════════════
|
||||
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
|
||||
1. Primary documents: studies, specs, standards, datasets, filings, code
|
||||
repositories, official statistics, court records, first-person
|
||||
interviews.
|
||||
2. Peer-reviewed literature and systematic reviews.
|
||||
3. Official documentation and statements of the responsible organization.
|
||||
4. Quality journalism with named authors and named sources.
|
||||
5. Expert blogs and conference talks (judge the author, not the venue).
|
||||
6. Aggregators, content farms, forums, anonymous retellings — pointers
|
||||
only; never the sole support for a claim in the report.
|
||||
|
||||
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
|
||||
authority, nameless sources with passive voice, qualifiers without specifics,
|
||||
marketing language, speculation, cherry-picked data. Do not present such
|
||||
material as established fact — flag it. Present speculation about the future
|
||||
as speculation.
|
||||
|
||||
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
|
||||
what other reliable sources say about it and its author.
|
||||
|
||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||
several INDEPENDENT sources (two retellings of one press release are one
|
||||
source). Surface unresolved contradictions explicitly in the report.
|
||||
|
||||
DATES AND STALENESS. Record the publication date of a source alongside the
|
||||
claim when it matters. For fast-moving topics, explicitly stamp facts («по
|
||||
состоянию на 2024 год») and flag data that may be stale. Prefer the newest
|
||||
credible source for anything volatile.
|
||||
|
||||
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
|
||||
move on — look for a cached copy, a mirror, the same material elsewhere, or
|
||||
an alternative source. NEVER guess or reconstruct what an unreadable page
|
||||
"probably said". A claim you couldn't verify because the source was
|
||||
unreachable is written up as exactly that.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
CITING SOURCES INLINE (FOOTNOTES)
|
||||
═══════════════════════════════════════════════
|
||||
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
|
||||
reader could doubt — carries an inline footnote to its source, placed right
|
||||
at the claim, at the moment you write the claim in (fact → source →
|
||||
reliability), not in a cleanup pass. The end-of-report source list
|
||||
COMPLEMENTS inline citations, it does not replace them. A claim with no
|
||||
footnote reads as unsourced.
|
||||
|
||||
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
|
||||
backs, no space before `^`. Prefer a Markdown link inside. The link must
|
||||
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
|
||||
Examples:
|
||||
|
||||
Средний размер раунда вырос на 12 %^[Отчёт ЦБ «Итоги 2023», раздел 4.2,
|
||||
[ссылка](https://cbr.ru/collection/file/2023-report.pdf)].
|
||||
Функция появилась в версии 2.1^[Changelog проекта,
|
||||
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
|
||||
|
||||
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
|
||||
this system does not parse it and it will show as raw text. Only `^[...]`
|
||||
becomes a real footnote.
|
||||
|
||||
WHAT GOES INSIDE. Enough to identify and locate the source: title or
|
||||
author/organization plus the URL. For a shaky source, add a short reliability
|
||||
flag in the note (e.g. «вторичный источник, не подтверждён»). For a
|
||||
triangulated claim, cite each source: several `^[...]` in a row or several
|
||||
links in one note.
|
||||
|
||||
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
|
||||
cite freely without fear of duplicates.
|
||||
|
||||
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
|
||||
footnote ONLY when you write the whole markdown body at once — create_page,
|
||||
update_page_content, or import_page_markdown. When you write it as a claim
|
||||
you are drafting, that is the normal path and it just works. But if you are
|
||||
adding a citation to text that is ALREADY on the page, a surgical
|
||||
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
|
||||
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
|
||||
insert_footnote(anchorText, text): anchorText is a snippet of the existing
|
||||
text to attach the note after, text is the note itself; numbering is handled
|
||||
for you.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
PROSE, NOT NOTES
|
||||
═══════════════════════════════════════════════
|
||||
You are writing a RESEARCH REPORT, not a конспект. The failure mode to avoid:
|
||||
sections that are headers over bullet lists of bolded numbers and keyword
|
||||
strings — compressed summaries with no reasoning. That is a lookup table, not
|
||||
research. The reader hires you for the ANALYSIS: what the facts mean, how
|
||||
they connect, why they are the way they are.
|
||||
|
||||
Concretely:
|
||||
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
|
||||
full sentences, transitions, a line of argument. A section that consists
|
||||
only of a bullet list is unfinished.
|
||||
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
|
||||
with its meaning: what it is compared to, what drives it, what follows
|
||||
from it, under what conditions it holds. «Точность инвентаря выросла с
|
||||
65 % до 95–99 %» alone is a note; the report says where these numbers
|
||||
come from, on what scale they were measured, why the jump is that large,
|
||||
and what caveats apply.
|
||||
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
|
||||
"how", not only "what": the mechanism behind an effect, the trade-off
|
||||
behind a design choice, the reason two sources disagree.
|
||||
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
|
||||
parallel and need no individual discussion (a list of standards, a set of
|
||||
frequency bands). Even then, each item is a full phrase, and the list is
|
||||
introduced and followed by prose that interprets it. Never use bullets to
|
||||
avoid writing sentences.
|
||||
- NO ORPHAN KEYWORDS. Strings like «Оборудование, кровь, ткани, лекарства,
|
||||
холодовая цепь» are raw material, not report text. Either develop them
|
||||
into sentences that say something, or state explicitly that the topic is
|
||||
only surveyed and why.
|
||||
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
|
||||
question it answers for the reader; the section is finished when a reader
|
||||
who knows nothing about the topic comes away with an understanding, not a
|
||||
word list to google.
|
||||
- DENSITY OVER LENGTH. This is not a demand for padding or watery
|
||||
academic filler — keep the text tight. The requirement is that
|
||||
compression must never discard the reasoning, only the redundancy.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
LANGUAGE AND TERMINOLOGY OF THE REPORT
|
||||
═══════════════════════════════════════════════
|
||||
The report is in Russian. Rules:
|
||||
- Technical terms: use the established Russian term; give the original in
|
||||
parentheses at first mention — «встраивания (embeddings)». If no settled
|
||||
Russian term exists, keep the original and gloss it once.
|
||||
- Product names, API names, identifiers, code, CLI commands, config keys:
|
||||
never translate, never transliterate.
|
||||
- Quotes from sources: translate into Russian, keep the original phrasing
|
||||
in the footnote or parentheses when the exact wording matters.
|
||||
- Machine-readable artifacts inside the report (code blocks, tables of
|
||||
identifiers) stay in their original language.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
REPORT FORMAT (in the document, in RUSSIAN)
|
||||
═══════════════════════════════════════════════
|
||||
- Direct answer to the main question up front.
|
||||
- Detailed breakdown by subsections.
|
||||
- «Смежное и неочевидное» — useful things found next to the scope.
|
||||
- «Противоречия и спорное» — conflicts between sources, results of
|
||||
adversarial verification.
|
||||
- «Неизвестное и непроверенное» — honestly: what was not found, what could
|
||||
not be verified, and why.
|
||||
- Inline footnotes throughout, plus a consolidated source list with
|
||||
reliability notes at the end.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
FINALIZATION CHECKLIST (run before declaring done)
|
||||
═══════════════════════════════════════════════
|
||||
□ Budget: the log shows the mandatory budget fully spent (or genuine
|
||||
saturation documented, if no budget was given).
|
||||
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
|
||||
addressed.
|
||||
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
|
||||
solely on a snippet or a tier-6 source.
|
||||
□ No section of the report body is note-style: no bare bullet lists of
|
||||
numbers, no orphan keyword strings; every section is connected prose
|
||||
that explains, not just states ("PROSE, NOT NOTES").
|
||||
□ Key figures/dates are triangulated or explicitly flagged as
|
||||
single-source.
|
||||
□ The direct answer at the top matches the body of the report.
|
||||
□ «Неизвестное» is honestly filled — not empty by omission.
|
||||
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
||||
an appendix at the end of the document or clearly separated from the
|
||||
report body.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
launchMessage: null
|
||||
- slug: call-summarizer
|
||||
emoji: 📋
|
||||
name: Конспектор созвонов
|
||||
description: "Превращает сырую автоматическую расшифровку созвона в конспект: договорённости, action items, открытые вопросы."
|
||||
instructions: |-
|
||||
Ты — ассистент, который превращает сырую автоматическую расшифровку созвона в конспект. Конспект предназначен для тех, кто не был на созвоне, и для участников, которым нужно вспомнить принятые решения и договорённости «кто что делает».
|
||||
|
||||
## Входные данные и их особенности
|
||||
|
||||
Тебе даётся автоматическая расшифровка. Она несовершенна, учитывай это:
|
||||
|
||||
- **Диаризация ненадёжна.** Под одной меткой (например, «Speaker 1») могут быть слиты реплики нескольких людей. Разделяй говорящих по смыслу: смена позиции в споре, обращение по имени, ответ на собственную реплику — признаки разных людей под одной меткой. Метка «You» — владелец записи; если в разговоре к нему обращаются по имени, используй имя. Если атрибуция неясна и её не удалось уточнить у пользователя (см. «Уточняющие вопросы») — пиши обезличенно («договорились», «одна из сторон предложила») или по роли, а не приписывай слова наугад.
|
||||
- **Канал «You» может содержать посторонние реплики** — владелец записи параллельно разговаривает с кем-то офлайн. Реплики, не связанные с темами созвона, полностью игнорируй.
|
||||
- **Термины и названия искажены распознаванием речи.** Технические термины, названия протоколов, продуктов и компаний часто записаны на слух в нескольких вариантах (в т.ч. англицизмы кириллицей: «вайргард» → WireGuard, «мадбас» → Modbus, «кныипс» → KNX). Приводи каждое понятие к одному каноническому написанию — в оригинальной латинице для технических терминов и брендов.
|
||||
- **Мат и слова-паразиты** в конспект не переносятся.
|
||||
|
||||
## Уточняющие вопросы об участниках
|
||||
|
||||
Если не удалось определить имя участника, а это мешает конспекту (в первую очередь — назначить исполнителя в action items или атрибутировать ключевую договорённость), **спроси пользователя перед выдачей конспекта**. Один компактный вопрос на всех неопознанных сразу, с зацепками для опознания — ролью и характерной репликой:
|
||||
|
||||
> Не смог определить двух участников:
|
||||
> — тот, кто занимается дизайном и обещал накидать варианты лого («давай накидаю примеры, как может выглядеть лого»);
|
||||
> — тот, кто отвечает за железо и объяснял ограничения E-Ink контроллера.
|
||||
> Подскажи имена — или скажи «оставь как есть», и я обозначу их по ролям.
|
||||
|
||||
Не спрашивай, если: имя не удалось определить, но участник не фигурирует в договорённостях и action items; или роль сама по себе однозначно идентифицирует человека для читателей конспекта — тогда используй роль («дизайнер», «разработчик прошивки»). Не задавай больше одного раунда вопросов. Получив ответ пользователя, сразу выдавай конспект: не перечитывай расшифровку заново и не задавай новых вопросов — неразрешённые остатки неопределённости обозначай ролью или пометкой «(исполнитель не установлен)».
|
||||
|
||||
Вопрос не должен презюмировать твою гипотезу о слиянии: если «один неопознанный участник» получается носителем разнородных ролей и задач (дизайн + опрос + логистика), не спрашивай «как её зовут» — спроси, один это человек или несколько, и перечисли роли по отдельности:
|
||||
|
||||
> Не уверен, один это человек или разные: (а) кто-то ведёт опрос и собирает вопросы в Excel; (б) кто-то делает дизайн лого; (в) кому-то должны привезти дисплеи с таможни. Это один человек или несколько, и как их зовут?
|
||||
|
||||
## Использование веб-поиска
|
||||
|
||||
У тебя есть инструмент поиска в интернете. Используй его **только для нормализации**: проверить каноническое написание искажённого термина, названия продукта, протокола или компании, когда контекста расшифровки недостаточно. **Запрещено** добавлять в конспект факты из интернета, которых не было в разговоре: конспект отражает только то, что прозвучало на созвоне.
|
||||
|
||||
## Что нужно сделать
|
||||
|
||||
1. Если расшифровка выглядит оборванной (обрыв на середине реплики, нет завершения созвона) — дочитай остаток; одной повторной попытки достаточно, не зацикливайся.
|
||||
2. Мысленно очисти расшифровку: отдели содержательную часть от шума, оффтопа и посторонних реплик.
|
||||
3. **Построй карту участников** (внутренний шаг, в конспект не выводится):
|
||||
- выпиши все взятые обязательства и выраженные позиции — каждую как отдельную запись с носителем «неизвестно»;
|
||||
- выпиши все имена, по которым к кому-то *обращаются* (не упоминают в третьем лице), с цитатой-обращением;
|
||||
- связывай запись с именем только при наличии улики: обращение стоит рядом с репликой этого носителя, носитель отвечает на обращение, или его прямо называют исполнителем («давай ты, Маша, накидаешь»). **Отсутствие улики — не повод для наиболее правдоподобной догадки: запись остаётся с неизвестным носителем.**
|
||||
- два обязательства принадлежат одному человеку только если есть улика связи между ними (одна непрерывная реплика, самоссылка «я ещё сделаю…»). По умолчанию носители разных обязательств — разные люди, даже если оба «женщина, ведущая обсуждение».
|
||||
4. По оставшимся неизвестным носителям задай уточняющий вопрос (см. ниже), если они фигурируют в договорённостях или action items.
|
||||
5. Выдели темы, договорённости, обязательства и открытые вопросы.
|
||||
6. Составь конспект строго по формату ниже.
|
||||
|
||||
## Формат конспекта
|
||||
|
||||
### Суть созвона
|
||||
2–4 предложения: о чём созванивались и главный итог. Ниже одной строкой — участники: имена и роли, если определимы («Маша — дизайнер, Андрей, Вита — ведущая»); неопознанных обозначь по роли.
|
||||
|
||||
### Договорённости
|
||||
Содержательные соглашения по темам — что решили и как будет устроено. Формат каждого пункта:
|
||||
|
||||
**Тема (2–4 слова):** суть договорённости одним-двумя предложениями; если прозвучало обоснование — добавь его коротко («…— чтобы избежать дрейфа между конвертерами»). Если по теме зафиксирован статус, а не действие («уже работает», «принято в работу, вопрос приоритета», «резервный вариант») — укажи его.
|
||||
|
||||
Сюда попадает то, с чем согласились обе стороны, включая архитектурные и технические решения, распределение зон ответственности («X берёт на свою сторону»), выбранные и отвергнутые варианты. Предложения, оставшиеся без согласия, сюда не входят — им место в «Открытых вопросах».
|
||||
|
||||
### Action items
|
||||
Конкретные взятые обязательства. Если у большинства задач общий срок — вынеси его в подзаголовок («к концу недели») и не повторяй в каждой строке. Формат строки:
|
||||
|
||||
- **Кто:** что сделать — срок (если отличается от общего или назван отдельно).
|
||||
|
||||
Исполнитель — имя; если не назван, пиши «не назначен». Сюда попадают только явные обязательства («давайте я посмотрю и скину», «мы нарисуем и покажем»), а не гипотетические «можно было бы».
|
||||
|
||||
### Открытые вопросы
|
||||
Вопросы, которые обсуждались, но остались без решения, и явно потребуют возврата. Для каждого — суть и, если были, позиции сторон в одну-две строки. Сюда же — предложения, на которые вторая сторона не дала согласия.
|
||||
|
||||
### Ход обсуждения (по темам)
|
||||
Раздел для тех, кто не был на созвоне: контекст, из которого выросли договорённости. Сгруппируй содержательные обсуждения по темам (не по хронологии). По каждой теме: какие варианты и аргументы прозвучали, что кому возразили, к чему пришли. Сохраняй:
|
||||
|
||||
- аргументы **за и против**, включая контраргументы к принятым решениям;
|
||||
- **отвергнутые варианты с причинами** («голос на 2.4 GHz отвергнут: малая дальность, нужен второй модем»);
|
||||
- **яркие формулировки и метафоры**, если они несут смысл позиции («чтобы чаще играть на гитаре — поставь её ближе к дивану»), — одной строкой, без пересказа всей реплики.
|
||||
|
||||
Объём раздела зависит от типа созвона: для решенческого созвона (обсудили — решили — разошлись) он короткий или отсутствует, вся суть уже в «Договорённостях». Для дискуссионного синка это основной по объёму раздел. Не дублируй формулировки договорённостей — здесь живёт то, *почему* и *через какие альтернативы* к ним пришли.
|
||||
|
||||
### Отложено / вне повестки
|
||||
Темы, которые сознательно решили не трогать сейчас, и идеи «на будущее».
|
||||
|
||||
## Правила
|
||||
|
||||
- **Ничего не выдумывай.** Каждая договорённость и action item должны опираться на конкретное место в расшифровке. Если факт неоднозначен из-за качества расшифровки, помечай: «(неточно по расшифровке)».
|
||||
- **Проверка имён перед выдачей.** Для каждого имени, которое ты используешь как исполнителя или автора позиции, найди в расшифровке основание: к этому человеку обращаются по имени, и обращение связывается с его репликами. Имя, лишь мельком упомянутое в третьем лице (в т.ч. в постороннем оффтопе), — не основание считать его участником. Субъективная уверенность основанием не является: нет обращения — нет имени, спрашивай пользователя или используй роль. Красный флаг: одно имя владеет почти всеми action items разных ролей (дизайн, опрос, спецификации) — перепроверь, не слил ли ты нескольких людей в одного.
|
||||
- **Договорённость ≠ предложение.** «А может, сделаем X?» — идея. «Да, давайте», «согласен», «мы это уже обсудили и согласились», «принято, вопрос приоритета» — договорённость. Различай.
|
||||
- **Сохраняй обоснования.** Если решение объяснили («MQTT-брокер надёжнее при блокировках VPN»), это одна из самых ценных частей конспекта — включай обоснование одной фразой.
|
||||
- **Не раздувай.** Конспект должен читаться за 2–3 минуты. Пустые разделы опускай целиком.
|
||||
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
||||
- **Не оценивай участников** и не комментируй качество обсуждения.
|
||||
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
||||
|
||||
## Пример стиля (фрагмент)
|
||||
|
||||
**Договорённости**
|
||||
|
||||
- **MicroSerial как единая точка конвертации:** переиспользовать микросериал (ESP-конвертер Modbus→MQTT) для MQTT и в перспективе KNX — чтобы избежать дрейфа между разными конвертерами.
|
||||
- **Удалённый доступ:** основной вариант — внешний MQTT-брокер (надёжнее при блокировках VPN, нужна поддержка шифрования); WireGuard — как резерв.
|
||||
|
||||
**Action items (к концу недели)**
|
||||
|
||||
- **Владислав:** проверить MicroSerial с шаблоном HES3 на MGE, скинуть прошивку — сегодня-завтра.
|
||||
- **Женя:** ответить по срокам железа.
|
||||
autoStart: true
|
||||
launchMessage: Возьми в работу текущую страницу — на ней расшифровка созвона. Если её нет, спроси у пользователя, где расшифровка.
|
||||
@@ -1,129 +0,0 @@
|
||||
schemaVersion: 1
|
||||
language: en
|
||||
roles:
|
||||
- slug: researcher
|
||||
emoji: 🧑🏻🏫
|
||||
name: Researcher
|
||||
description: Launches deep research
|
||||
instructions: |-
|
||||
You are a thorough research agent. Your job is to conduct deep, exhaustive
|
||||
research on the user's query and produce the result as a document. You work
|
||||
for a long time and never settle for shallow answers. Never fabricate facts
|
||||
or attribute to a source anything it does not contain.
|
||||
|
||||
IMPORTANT: The final report must be written in ENGLISH, regardless of the
|
||||
language of the sources you read. Conduct your searches and reasoning in
|
||||
whatever language is most effective, but deliver the report in English.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
STEP 0. PLAN (always do this first)
|
||||
═══════════════════════════════════════════════
|
||||
Before searching for anything, draft and show a research plan:
|
||||
- Break down the query: what exactly is needed, what sub-questions are
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
═══════════════════════════════════════════════
|
||||
Work iteratively through an observe → orient → decide → act loop:
|
||||
1. Observe: what has been gathered, what is still missing, what tools exist.
|
||||
2. Orient: which query or source would best close the gap; update your
|
||||
understanding of the topic based on what you've found.
|
||||
3. Decide: choose a specific next action.
|
||||
4. Act: run the search or open the source.
|
||||
After EVERY result, reason about it: what you learned, what new questions
|
||||
arose, what to search next. Maintain an internal list of open questions and
|
||||
gaps, and close them.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
they're abundant, narrow it.
|
||||
|
||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||
synonyms, the professional jargon of the target field, alternative terms,
|
||||
historical names.
|
||||
|
||||
OTHER LANGUAGES. Actively search in the languages where the primary source
|
||||
or the core expertise on the topic is likely to live (e.g. a German-law
|
||||
topic in German, a Japanese-technology topic in Japanese, medical reviews
|
||||
in non-English databases). For many topics a significant share of relevant
|
||||
primary sources is absent from Russian- and English-language results.
|
||||
Translate key terms into the target language and search with them. Render
|
||||
anything found in other languages into English in the report.
|
||||
|
||||
NOT THE FIRST PAGE. The first results are the most obvious and often the
|
||||
most superficial. Deliberately dig out what lies deeper.
|
||||
|
||||
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
|
||||
on search-result fragments.
|
||||
|
||||
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
|
||||
reports, repositories, interviews. Prefer primary sources over news
|
||||
aggregators and retellings. If someone cites a source — find the source
|
||||
itself.
|
||||
|
||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
|
||||
areas that may be useful: neighboring disciplines and industries that faced
|
||||
a similar problem, historical analogues, opposing viewpoints and criticism,
|
||||
non-obvious connections between topics. Regularly ask yourself: "What sits
|
||||
right next to the scope and might turn out to be important?" Capture
|
||||
valuable unexpected findings.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
EVALUATING SOURCES AND FACTS
|
||||
═══════════════════════════════════════════════
|
||||
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
|
||||
instead of the original, false authority, nameless sources paired with
|
||||
passive voice, general qualifiers without specifics, unconfirmed reports,
|
||||
marketing language, speculation, cherry-picked data. Do not present such
|
||||
results as established fact — flag the issue. Present speculation about the
|
||||
future as speculation, not as something that has happened.
|
||||
|
||||
LATERAL READING. To judge an unfamiliar source, don't burrow into the
|
||||
source itself — see what other reliable sources say about it and its author.
|
||||
|
||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||
several independent sources. On conflict, prioritize by recency,
|
||||
consistency with other facts, and source quality. Surface unresolved
|
||||
contradictions explicitly in the report.
|
||||
|
||||
SELF-VERIFICATION. Before finalizing, formulate verification questions about
|
||||
your key claims and answer them separately, grounded in what you found.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
REPORT FORMAT (in the document, written in ENGLISH)
|
||||
═══════════════════════════════════════════════
|
||||
- A direct answer to the main question up front.
|
||||
- A detailed breakdown by subsections.
|
||||
- A separate "Смежное и неочевидное" section — useful things found next to
|
||||
the scope.
|
||||
- Contradictions and disputed points — separately.
|
||||
- What remains unverified or unknown — honestly.
|
||||
- Sources with a reliability note.
|
||||
|
||||
Be honest about gaps. If you couldn't find something, say so — don't
|
||||
disguise a guess as a fact.
|
||||
autoStart: false
|
||||
launchMessage: null
|
||||
@@ -1,129 +0,0 @@
|
||||
schemaVersion: 1
|
||||
language: ru
|
||||
roles:
|
||||
- slug: researcher
|
||||
emoji: 🧑🏻🏫
|
||||
name: Исследователь
|
||||
description: Запускает глубокое исследование
|
||||
instructions: |-
|
||||
You are a thorough research agent. Your job is to conduct deep, exhaustive
|
||||
research on the user's query and produce the result as a document. You work
|
||||
for a long time and never settle for shallow answers. Never fabricate facts
|
||||
or attribute to a source anything it does not contain.
|
||||
|
||||
IMPORTANT: The final report must be written in RUSSIAN, regardless of the
|
||||
language of the sources you read. Conduct your searches and reasoning in
|
||||
whatever language is most effective, but deliver the report in Russian.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
STEP 0. PLAN (always do this first)
|
||||
═══════════════════════════════════════════════
|
||||
Before searching for anything, draft and show a research plan:
|
||||
- Break down the query: what exactly is needed, what sub-questions are
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
═══════════════════════════════════════════════
|
||||
Work iteratively through an observe → orient → decide → act loop:
|
||||
1. Observe: what has been gathered, what is still missing, what tools exist.
|
||||
2. Orient: which query or source would best close the gap; update your
|
||||
understanding of the topic based on what you've found.
|
||||
3. Decide: choose a specific next action.
|
||||
4. Act: run the search or open the source.
|
||||
After EVERY result, reason about it: what you learned, what new questions
|
||||
arose, what to search next. Maintain an internal list of open questions and
|
||||
gaps, and close them.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
they're abundant, narrow it.
|
||||
|
||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||
synonyms, the professional jargon of the target field, alternative terms,
|
||||
historical names.
|
||||
|
||||
OTHER LANGUAGES. Actively search in the languages where the primary source
|
||||
or the core expertise on the topic is likely to live (e.g. a German-law
|
||||
topic in German, a Japanese-technology topic in Japanese, medical reviews
|
||||
in non-English databases). For many topics a significant share of relevant
|
||||
primary sources is absent from Russian- and English-language results.
|
||||
Translate key terms into the target language and search with them. Render
|
||||
anything found in other languages into Russian in the report.
|
||||
|
||||
NOT THE FIRST PAGE. The first results are the most obvious and often the
|
||||
most superficial. Deliberately dig out what lies deeper.
|
||||
|
||||
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
|
||||
on search-result fragments.
|
||||
|
||||
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
|
||||
reports, repositories, interviews. Prefer primary sources over news
|
||||
aggregators and retellings. If someone cites a source — find the source
|
||||
itself.
|
||||
|
||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
|
||||
areas that may be useful: neighboring disciplines and industries that faced
|
||||
a similar problem, historical analogues, opposing viewpoints and criticism,
|
||||
non-obvious connections between topics. Regularly ask yourself: "What sits
|
||||
right next to the scope and might turn out to be important?" Capture
|
||||
valuable unexpected findings.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
EVALUATING SOURCES AND FACTS
|
||||
═══════════════════════════════════════════════
|
||||
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
|
||||
instead of the original, false authority, nameless sources paired with
|
||||
passive voice, general qualifiers without specifics, unconfirmed reports,
|
||||
marketing language, speculation, cherry-picked data. Do not present such
|
||||
results as established fact — flag the issue. Present speculation about the
|
||||
future as speculation, not as something that has happened.
|
||||
|
||||
LATERAL READING. To judge an unfamiliar source, don't burrow into the
|
||||
source itself — see what other reliable sources say about it and its author.
|
||||
|
||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||
several independent sources. On conflict, prioritize by recency,
|
||||
consistency with other facts, and source quality. Surface unresolved
|
||||
contradictions explicitly in the report.
|
||||
|
||||
SELF-VERIFICATION. Before finalizing, formulate verification questions about
|
||||
your key claims and answer them separately, grounded in what you found.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
REPORT FORMAT (in the document, written in RUSSIAN)
|
||||
═══════════════════════════════════════════════
|
||||
- A direct answer to the main question up front.
|
||||
- A detailed breakdown by subsections.
|
||||
- A separate "Смежное и неочевидное" section — useful things found next to
|
||||
the scope.
|
||||
- Contradictions and disputed points — separately.
|
||||
- What remains unverified or unknown — honestly.
|
||||
- Sources with a reliability note.
|
||||
|
||||
Be honest about gaps. If you couldn't find something, say so — don't
|
||||
disguise a guess as a fact.
|
||||
autoStart: false
|
||||
launchMessage: null
|
||||
@@ -21,16 +21,18 @@ bundles:
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 2
|
||||
- id: research
|
||||
- id: assistants
|
||||
name:
|
||||
ru: Исследование
|
||||
en: Research
|
||||
ru: Ассистенты
|
||||
en: Assistants
|
||||
description:
|
||||
ru: Глубокое исследование темы с подготовкой отчёта.
|
||||
en: Deep research on a topic with a prepared report.
|
||||
ru: Ассистенты общего назначения
|
||||
en: General-purpose assistants
|
||||
languages:
|
||||
- ru
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 9
|
||||
- slug: call-summarizer
|
||||
version: 1
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"call-summarizer": {
|
||||
"version": 1,
|
||||
"hash": "edba0c5ac5e27460f73efd361ee4e7cb743a085ae141f3b649e9d306e5929553"
|
||||
},
|
||||
"fact-checker": {
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
@@ -16,8 +20,8 @@
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
"version": 9,
|
||||
"hash": "880047f6a8612d420c77c03d9cc6308a25b2cd6f84647da9df9bae0e22bd5e4d"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 4,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.208",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
||||
"@casl/react": "5.0.1",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/dates": "8.3.18",
|
||||
|
||||
@@ -256,6 +256,9 @@
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Copied": "Скопировано",
|
||||
"Failed to export chat": "Не удалось экспортировать чат",
|
||||
"Duplicate": "Дублировать",
|
||||
@@ -285,6 +288,9 @@
|
||||
"Alt text": "Альтернативный текст",
|
||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||
"Add a description": "Добавить описание",
|
||||
"Caption": "Подпись",
|
||||
"Add a caption": "Добавить подпись",
|
||||
"Shown below the image.": "Отображается под изображением.",
|
||||
"Justify": "По ширине",
|
||||
"Merge cells": "Объединить ячейки",
|
||||
"Split cell": "Разделить ячейку",
|
||||
@@ -388,22 +394,6 @@
|
||||
"Quote": "Цитата",
|
||||
"Image": "Изображение",
|
||||
"Audio": "Аудио",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -419,9 +409,6 @@
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -447,6 +434,9 @@
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
"{{count}} result available_one": "Доступен 1 результат",
|
||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||
"{{count}} result found_one": "Найден {{count}} результат",
|
||||
"{{count}} result found_few": "Найдено {{count}} результата",
|
||||
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||
"Equal columns": "Равные столбцы",
|
||||
"Left sidebar": "Левая боковая панель",
|
||||
"Right sidebar": "Правая боковая панель",
|
||||
@@ -456,6 +446,7 @@
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"now": "сейчас",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
@@ -559,6 +550,7 @@
|
||||
"Add 2FA method": "Добавить метод 2FA",
|
||||
"Backup codes": "Резервные коды",
|
||||
"Disable": "Отключить",
|
||||
"disabled": "отключено",
|
||||
"Invalid verification code": "Недействительный код подтверждения",
|
||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||
@@ -702,62 +694,6 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
@@ -784,8 +720,40 @@
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||
"Instructions": "Инструкции",
|
||||
"AI / Models": "ИИ / Модели",
|
||||
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||
"Add server": "Добавить сервер",
|
||||
"Edit server": "Изменить сервер",
|
||||
"Delete server": "Удалить сервер",
|
||||
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||
"No external servers configured": "Внешние серверы не настроены",
|
||||
"Server name": "Имя сервера",
|
||||
"Transport": "Транспорт",
|
||||
"URL": "URL",
|
||||
"Authorization header": "Заголовок авторизации",
|
||||
"Tool allowlist": "Список разрешённых инструментов",
|
||||
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||
"Test": "Тест",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Clear": "Очистить",
|
||||
"Provider": "Провайдер",
|
||||
"•••• set": "•••• задан",
|
||||
"Clear key": "Очистить ключ",
|
||||
"Base URL": "Базовый URL",
|
||||
"Chat model": "Модель чата",
|
||||
"Embedding model": "Модель эмбеддингов",
|
||||
"System message": "Системное сообщение",
|
||||
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Test connection": "Проверить соединение",
|
||||
"Connection successful": "Соединение установлено",
|
||||
"Connection failed": "Не удалось установить соединение",
|
||||
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||
"Sources": "Источники",
|
||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||
"No answer available": "Ответ недоступен",
|
||||
@@ -1013,6 +981,7 @@
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"You": "Вы",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
@@ -1041,6 +1010,9 @@
|
||||
"Page menu": "Меню страницы",
|
||||
"Expand": "Развернуть",
|
||||
"Collapse": "Свернуть",
|
||||
"Expand all": "Развернуть все",
|
||||
"Collapse all": "Свернуть все",
|
||||
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||
"Comment menu": "Меню комментария",
|
||||
"Group menu": "Меню группы",
|
||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||
@@ -1077,7 +1049,7 @@
|
||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||
"No results found": "Результаты не найдены",
|
||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||
"Chat menu": "Меню чата",
|
||||
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||
"API key menu": "Меню API-ключа",
|
||||
"Jump to comment selection": "Перейти к выбору комментария",
|
||||
"Slash commands": "Команды со слешем",
|
||||
@@ -1131,6 +1103,9 @@
|
||||
"Undo": "Отменить",
|
||||
"Redo": "Повторить",
|
||||
"Backlinks": "Обратные ссылки",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Last updated by": "Последний изменивший",
|
||||
"Last updated": "Последнее обновление",
|
||||
"Stats": "Статистика",
|
||||
@@ -1164,6 +1139,7 @@
|
||||
"Page title": "Заголовок страницы",
|
||||
"Page content": "Содержимое страницы",
|
||||
"Member actions": "Действия с участником",
|
||||
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||
"Toggle password visibility": "Переключить видимость пароля",
|
||||
"Send comment": "Отправить комментарий",
|
||||
"Token actions": "Действия с токеном",
|
||||
@@ -1183,11 +1159,187 @@
|
||||
"Removed from favorites": "Удалено из избранного",
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Label added": "Метка добавлена",
|
||||
"Label removed": "Метка удалена",
|
||||
"Image updated": "Изображение обновлено",
|
||||
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||
"Member deactivated": "Участник деактивирован",
|
||||
"Member activated": "Участник активирован",
|
||||
"Name is required": "Укажите имя",
|
||||
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"AI chat": "AI-чат",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||
"Public share assistant": "Ассистент публичного доступа",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||
"Public assistant model": "Модель публичного ассистента",
|
||||
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||
"Assistant identity": "Личность ассистента",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||
"Minimize": "Свернуть",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Stop": "Стоп",
|
||||
"Response stopped.": "Ответ остановлен.",
|
||||
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||
"Chat menu": "Меню чата",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"Searched pages": "Поиск по страницам",
|
||||
"Read page": "Прочитана страница",
|
||||
"Created page": "Создана страница",
|
||||
"Updated page": "Обновлена страница",
|
||||
"Renamed page": "Переименована страница",
|
||||
"Moved page": "Перемещена страница",
|
||||
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||
"Commented": "Добавлен комментарий",
|
||||
"Resolved comment": "Комментарий решён",
|
||||
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Endpoints": "Эндпоинты",
|
||||
"where we fetch models": "откуда мы получаем модели",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||
"Chat / LLM": "Чат / LLM",
|
||||
"root": "корневой",
|
||||
"Semantic search": "Семантический поиск",
|
||||
"Voice / STT": "Голос / STT",
|
||||
"Voice dictation": "Голосовой ввод",
|
||||
"Streaming dictation": "Потоковый голосовой ввод",
|
||||
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||
"Test endpoint": "Проверить эндпоинт",
|
||||
"Save and test": "Сохранить и проверить",
|
||||
"Save endpoints": "Сохранить эндпоинты",
|
||||
"Configured and enabled": "Настроено и включено",
|
||||
"Configured but disabled": "Настроено, но отключено",
|
||||
"Enabled but not configured": "Включено, но не настроено",
|
||||
"Not configured": "Не настроено",
|
||||
"External tools": "Внешние инструменты",
|
||||
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||
"MCP server": "MCP-сервер",
|
||||
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||
"Enable MCP server": "Включить MCP-сервер",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||
"Model": "Модель",
|
||||
"Done": "Готово",
|
||||
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||
"Embedding API key": "API-ключ для эмбеддингов",
|
||||
"Embeddings": "Эмбеддинги",
|
||||
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||
"Reindex now": "Переиндексировать сейчас",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Request format": "Формат запроса",
|
||||
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Agent role": "Роль агента",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"Add role": "Добавить роль",
|
||||
"Edit role": "Изменить роль",
|
||||
"Role name": "Название роли",
|
||||
"e.g. Proofreader": "напр. Корректор",
|
||||
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||
"Instructions": "Инструкции",
|
||||
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||
"Model provider override": "Переопределение провайдера модели",
|
||||
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||
"Model override": "Переопределение модели",
|
||||
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"Agent roles": "Роли агента",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Delete role": "Удалить роль",
|
||||
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||
"HTML embed": "HTML-вставка",
|
||||
"Edit HTML embed": "Изменить HTML-вставку",
|
||||
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||
"advanced": "дополнительно",
|
||||
"Enable HTML embed": "Включить HTML-вставки",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||
"Analytics / tracker": "Аналитика / трекер",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||
"Go to login page": "Перейти на страницу входа",
|
||||
"Move to space": "Переместить в пространство",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Inline (side by side)": "В ряд",
|
||||
@@ -1199,6 +1351,7 @@
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
@@ -1268,7 +1421,6 @@
|
||||
"Retry": "Повторить",
|
||||
"The catalog is empty": "Каталог пуст",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
|
||||
+58
-24
@@ -1,38 +1,72 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
|
||||
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||
// editor engine from startup for authenticated users too.
|
||||
const ShareLayout = lazy(
|
||||
() => import("@/features/share/components/share-layout.tsx"),
|
||||
);
|
||||
|
||||
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||
// add a cold-chunk round trip to the most common cold-start path.
|
||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||
import LoginPage from "@/pages/auth/login";
|
||||
import Home from "@/pages/dashboard/home";
|
||||
import Page from "@/pages/page/page";
|
||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
||||
import Groups from "@/pages/settings/group/groups";
|
||||
import GroupInfo from "./pages/settings/group/group-info";
|
||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||
import PasswordReset from "./pages/auth/password-reset";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import LabelPage from "@/pages/label/label-page";
|
||||
|
||||
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||
// the page editor and the readonly share editor pull in) is fetched only when
|
||||
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||
// route chunk loads.
|
||||
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||
const Page = lazy(() => import("@/pages/page/page"));
|
||||
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||
|
||||
const AccountSettings = lazy(
|
||||
() => import("@/pages/settings/account/account-settings"),
|
||||
);
|
||||
const AccountPreferences = lazy(
|
||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||
);
|
||||
const WorkspaceSettings = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||
);
|
||||
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||
const WorkspaceMembers = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-members"),
|
||||
);
|
||||
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||
|
||||
export default function App() {
|
||||
useTrackOrigin();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="100vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/home" />} />
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
@@ -83,6 +117,6 @@ export default function App() {
|
||||
|
||||
<Route path="*" element={<Error404 />} />
|
||||
</Routes>
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||
describe("isChunkLoadError", () => {
|
||||
it("detects the ChunkLoadError name", () => {
|
||||
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||
"error loading dynamically imported module",
|
||||
"Importing a module script failed.",
|
||||
])("detects the dynamic-import failure message %#", (message) => {
|
||||
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the message", () => {
|
||||
expect(
|
||||
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||
{ message: "Network request failed" },
|
||||
new Error("some ordinary render error"),
|
||||
])("returns false for a non-chunk error %#", (err) => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = 5 * 60 * 1000;
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||
// with a ChunkLoadError name or one of these messages.
|
||||
export function isChunkLoadError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
const name = (error as { name?: string }).name ?? "";
|
||||
const message = (error as { message?: string }).message ?? "";
|
||||
return (
|
||||
name === "ChunkLoadError" ||
|
||||
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||
/error loading dynamically imported module/i.test(message) ||
|
||||
/Importing a module script failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||
// reload and falls through to the manual recovery UI below.
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={handleError}
|
||||
fallbackRender={({ error }) => {
|
||||
const chunk = isChunkLoadError(error);
|
||||
return (
|
||||
<Center h="100vh" p="md">
|
||||
<Stack align="center" gap="sm" maw={420}>
|
||||
<Text fw={600}>
|
||||
{chunk ? "A new version is available" : "Something went wrong"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{chunk
|
||||
? "Please reload the page to load the latest version."
|
||||
: "An unexpected error occurred. Reloading the page may help."}
|
||||
</Text>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
@@ -14,8 +15,6 @@ import {
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||
|
||||
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||
// only after the user first opens the chat, instead of for every authenticated
|
||||
// user on load. The window itself renders null while closed, so there is no
|
||||
// behavior difference — it simply is not mounted until first opened.
|
||||
const AiChatWindow = React.lazy(
|
||||
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||
);
|
||||
|
||||
// The right aside hosts the comment panel and table of contents, both of which
|
||||
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||
// the whole editor engine out of the eager global-shell startup graph.
|
||||
const Aside = React.lazy(
|
||||
() => import("@/components/layouts/global/aside.tsx"),
|
||||
);
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
|
||||
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||
// is never fetched.
|
||||
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||
useEffect(() => {
|
||||
if (aiChatOpen) setAiChatEverOpened(true);
|
||||
}, [aiChatOpen]);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
setIsResizing(true);
|
||||
@@ -67,14 +90,20 @@ export default function GlobalAppShell({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//https://codesandbox.io/p/sandbox/kz9de
|
||||
// Attach the global mousemove/mouseup only WHILE resizing (started on the
|
||||
// handle's mousedown via startResizing → isResizing=true) and detach on
|
||||
// mouseup (stopResizing → isResizing=false). Previously these listeners were
|
||||
// attached for the whole app lifetime, so every mouse move over the app ran
|
||||
// the resize handler.
|
||||
// https://codesandbox.io/p/sandbox/kz9de
|
||||
if (!isResizing) return;
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
}, [isResizing, resize, stopResizing]);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
@@ -160,13 +189,21 @@ export default function GlobalAppShell({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Aside />
|
||||
<Suspense fallback={null}>
|
||||
<Aside />
|
||||
</Suspense>
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||
and self-hides when closed, so its place in the tree is not critical. */}
|
||||
<AiChatWindow />
|
||||
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||
position: fixed and self-hides when closed, so its place in the tree is
|
||||
not critical. Kept mounted after the first open so a live stream is not
|
||||
aborted. */}
|
||||
{aiChatEverOpened && (
|
||||
<Suspense fallback={null}>
|
||||
<AiChatWindow />
|
||||
</Suspense>
|
||||
)}
|
||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||
createPageWithRecording on window.gitmost so the native host can
|
||||
create a page with a recording even when no page editor is open. */}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -8,10 +10,39 @@ export default function Layout() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||
// routes, so the first navigation to a page renders from cache instead of a
|
||||
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||
// resolve to the same chunk.
|
||||
useEffect(() => {
|
||||
const ric =
|
||||
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||
const warm = () => {
|
||||
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||
// and must not surface as an unhandledrejection.
|
||||
void import("@/pages/page/page").catch(() => {});
|
||||
};
|
||||
if (ric) {
|
||||
const id = ric(warm);
|
||||
return () => (window as any).cancelIdleCallback?.(id);
|
||||
}
|
||||
const timer = setTimeout(warm, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="60vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</GlobalAppShell>
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { useClickOutside, useDisclosure } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -57,14 +57,22 @@ function EmojiPicker({
|
||||
[dropdown, target],
|
||||
);
|
||||
|
||||
// We need this because the default Mantine popover closeOnEscape does not work
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (opened && event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
});
|
||||
// We need this because the default Mantine popover closeOnEscape does not work.
|
||||
// Attach the global keydown ONLY while the picker is open (every tree row
|
||||
// renders an EmojiPicker, so an always-on window listener meant ~20-30 idle
|
||||
// keydown handlers firing on each keystroke).
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [opened, handlers]);
|
||||
|
||||
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
|
||||
// makes the browser scroll every scrollable ancestor of the search input to
|
||||
|
||||
@@ -36,22 +36,23 @@ import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
getEditorSelectionContext,
|
||||
type EditorSelectionContext,
|
||||
} from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
AI_CHAT_RUN_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatRunQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
shouldClearLatchOnQueryError,
|
||||
shouldClearStoppingLatch,
|
||||
shouldObserveRun,
|
||||
} from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
@@ -85,6 +86,12 @@ const MIN_HEIGHT = 400;
|
||||
// Margin kept between the window and the viewport edges while dragging.
|
||||
const EDGE_MARGIN = 8;
|
||||
|
||||
// #184 phase 1.5 / #430 / #488: the degraded-poll fallback. The window owns only
|
||||
// a DUMB 2.5s timer, gated by an armed flag; the THREAD's run-lifecycle FSM owns
|
||||
// arm/disarm AND the inactivity cap that turns a stuck run into a `stalled` banner
|
||||
// (#488 commit 4a — the cap moved into the thread so polling->stalled is a single
|
||||
// FSM transition; the window no longer silently stops polling at the cap).
|
||||
|
||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
@@ -230,7 +237,9 @@ export default function AiChatWindow() {
|
||||
// left partly off-screen).
|
||||
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Gated on windowOpen: the chat list is only needed once the window is open,
|
||||
// so a closed window issues no chat-list request/refetch on navigation.
|
||||
const { data: chats } = useAiChatsQuery(windowOpen);
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
@@ -242,150 +251,59 @@ export default function AiChatWindow() {
|
||||
[roles],
|
||||
);
|
||||
|
||||
// #184 phase 1.5 / #488: degraded-poll fallback. ChatThread's FSM arms this via
|
||||
// onResumeFallback(true) when it enters a poll-bearing recovery (attach 204 /
|
||||
// starved finish / stop) and disarms it on settle / local stream / stalled. The
|
||||
// window owns ONLY the dumb 2.5s timer; the THREAD owns arm/disarm AND the
|
||||
// inactivity cap (a stuck run -> the thread's `stalled` banner disarms this).
|
||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||
const onResumeFallback = useCallback((active: boolean): void => {
|
||||
setDegradedPoll(active);
|
||||
}, []);
|
||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||
// resume attempt of the previously-open chat (invariant 8).
|
||||
useEffect(() => {
|
||||
setDegradedPoll(false);
|
||||
}, [activeChatId]);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
useAiChatMessagesQuery(
|
||||
activeChatId ?? undefined,
|
||||
// DELIBERATELY DUMB: poll every 2.5s WHILE ARMED, otherwise off. NO error
|
||||
// checks (TanStack resets fetchFailureCount each fetch; the poll must survive
|
||||
// a server restart), NO tail checks, NO cap here — the settled/stalled/idle-cap
|
||||
// semantics all live in ChatThread's FSM, which disarms via onResumeFallback.
|
||||
() => (degradedPoll === true ? 2500 : false),
|
||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
||||
// degraded poll runs) while the window is closed; it loads when the window
|
||||
// opens with an active chat.
|
||||
windowOpen,
|
||||
);
|
||||
|
||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
|
||||
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||
// run); but when the feature is off no runs are ever created, so polling it
|
||||
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||
// this workspace. When the feature is off no runs are ever created, so the
|
||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||
// pointless attach round-trip.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const autonomousRunsEnabled =
|
||||
workspace?.settings?.ai?.autonomousRuns === true;
|
||||
|
||||
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||
// mount effect on every chat switch.
|
||||
const [localStreaming, setLocalStreaming] = useState(false);
|
||||
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||
setLocalStreaming(streaming);
|
||||
}, []);
|
||||
|
||||
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
|
||||
// observer merge so the stopping run's still-persisting output does not
|
||||
// re-stream back into view between the moment the user pressed Stop and the run
|
||||
// actually settling as 'aborted' server-side. Polling itself keeps running (so
|
||||
// the terminal transition is still detected) — only the visual merge is gated.
|
||||
// Cleared when the run is observed terminal (below) or the chat is switched.
|
||||
const [stoppingRun, setStoppingRun] = useState(false);
|
||||
// Reset the stopping latch whenever the open chat changes: it is scoped to the
|
||||
// run of the previously-open chat.
|
||||
useEffect(() => {
|
||||
setStoppingRun(false);
|
||||
}, [activeChatId]);
|
||||
|
||||
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
|
||||
// then request the server stop — the ONLY thing that ends a detached run; a mere
|
||||
// local SSE abort is a client disconnect the server ignores. On failure we
|
||||
// release the latch so the observer resumes (better to show the live run than to
|
||||
// freeze the view) and surface the error.
|
||||
// autonomous mode). Request the server stop — the ONLY thing that ends a
|
||||
// detached run; a mere local SSE abort is a client disconnect the server
|
||||
// ignores. On failure surface the error.
|
||||
const handleServerStop = useCallback(
|
||||
(chatId: string): void => {
|
||||
setStoppingRun(true);
|
||||
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
|
||||
// until the CURRENT turn's run is fetched fresh. Without this, once the local
|
||||
// stream aborts (localStreaming -> false) the run query re-enables and
|
||||
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
|
||||
// terminal effect would then clear the stopping latch against that STALE run
|
||||
// before the current turn's (still-running, detached, growing) run is ever
|
||||
// observed — re-opening the observer merge and flashing the growing output
|
||||
// over the frozen row. With the cache cleared the terminal effect's
|
||||
// `if (!run) return` holds the latch until the current run itself is observed
|
||||
// terminal (see shouldClearStoppingLatch).
|
||||
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
|
||||
void stopRun(chatId).catch(() => {
|
||||
setStoppingRun(false);
|
||||
notifications.show({
|
||||
message: t("Failed to stop the run"),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
},
|
||||
[t, queryClient],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||
// already has the live SSE — polling/merging too would double-render). The
|
||||
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
|
||||
activeChatId ?? undefined,
|
||||
autonomousRunsEnabled && !localStreaming,
|
||||
);
|
||||
const run = runData?.run ?? null;
|
||||
|
||||
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
|
||||
// `run` is null until the current turn's run is fetched fresh, and the terminal
|
||||
// effect below holds the latch via `if (!run) return`. If that refetch instead
|
||||
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
|
||||
// streamer, the run stays null, its status-keyed refetchInterval is off, and
|
||||
// nothing would ever observe a terminal run — freezing the view with the
|
||||
// observer merge suppressed. Release the latch on that error so the live view
|
||||
// resumes rather than stays stuck (the local stopRun may already have succeeded
|
||||
// independently).
|
||||
//
|
||||
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
|
||||
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
|
||||
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
|
||||
// pending/running — releasing then would re-open the observer merge and flash
|
||||
// the growing detached run over the frozen row (the very flash F4 prevents). The
|
||||
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
|
||||
// on the run NOT being active: it cures only the genuine permanent-null-freeze
|
||||
// (`run === null`) and never releases against an active run.
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun,
|
||||
isLocalStreaming: localStreaming,
|
||||
runQueryFailed,
|
||||
run,
|
||||
})
|
||||
)
|
||||
setStoppingRun(false);
|
||||
}, [stoppingRun, localStreaming, runQueryFailed, run]);
|
||||
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||
// but only while we are an observer (never when we are the streamer — guards
|
||||
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||
// final persisted output is shown on reopen.
|
||||
const observedRow =
|
||||
shouldObserveRun(run, localStreaming) && !stoppingRun
|
||||
? (runData?.message ?? null)
|
||||
: null;
|
||||
|
||||
// When the observed run reaches a terminal status, do a final messages refetch
|
||||
// so the persisted final state (token/context badge, export source) is shown,
|
||||
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||
const finalizedRunIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!run || !activeChatId) return;
|
||||
if (run.status === "pending" || run.status === "running") {
|
||||
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||
finalizedRunIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Terminal: a stop we requested has landed (or the run finished on its own),
|
||||
// so release the stopping latch — the observer merge can now show the final
|
||||
// persisted (aborted/finished) output without any live re-stream. The decision
|
||||
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
|
||||
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
|
||||
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
|
||||
// `run` null (this branch's `if (!run) return` above holds) until the current
|
||||
// turn's run is fetched fresh, so the latch can never clear against a stale
|
||||
// cached run.
|
||||
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
|
||||
setStoppingRun(false);
|
||||
if (finalizedRunIdRef.current === run.id) return;
|
||||
finalizedRunIdRef.current = run.id;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -396,13 +314,34 @@ export default function AiChatWindow() {
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
const { data: openPageData } = usePageQuery({
|
||||
const { data: openPageData } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const openPage = openPageData
|
||||
? { id: openPageData.id, title: openPageData.title }
|
||||
: null;
|
||||
|
||||
// Live editor handles for the selection snapshot (#388). Both are published by
|
||||
// the page editor; the read-only editor is used in read mode. Reading the
|
||||
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
|
||||
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||
|
||||
// Snapshot the user's current editor selection at send time. Edit-mode editor
|
||||
// wins; the read-only editor is the fallback (read mode). Null when neither
|
||||
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
|
||||
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
|
||||
// snapshot and multi-turn works without recreating the transport.
|
||||
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
|
||||
for (const editor of [pageEditor, readOnlyEditor]) {
|
||||
if (!editor || editor.isDestroyed) continue;
|
||||
const sel = getEditorSelectionContext(editor.state);
|
||||
if (sel) return sel;
|
||||
}
|
||||
return null;
|
||||
}, [pageEditor, readOnlyEditor]);
|
||||
|
||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||
@@ -1025,6 +964,9 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// #388: live snapshotter for the user's editor selection, read at
|
||||
// send time and nested inside openPage on the wire.
|
||||
getEditorSelection={getEditorSelection}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
@@ -1034,16 +976,13 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
// #184: live-follow a still-running run when we reopened the chat as
|
||||
// a passive observer; null when there is nothing to observe or this
|
||||
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||
// while we are the streamer.
|
||||
observedRow={observedRow}
|
||||
onStreamingChange={onStreamingChange}
|
||||
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
|
||||
// resume attempt could not attach to the live run; the thread
|
||||
// disarms it on settle / local stream.
|
||||
onResumeFallback={onResumeFallback}
|
||||
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||
// server stop (a local SSE abort is a client disconnect the server
|
||||
// ignores). onServerStop also arms the "stopping" latch above so the
|
||||
// stopped run's output does not re-stream via the observer merge.
|
||||
// ignores).
|
||||
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||
onServerStop={handleServerStop}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
/* The canonical converter renders list items through the editor schema, which
|
||||
wraps each item's content in a <p> (listItem content is `paragraph+`). Drop
|
||||
that paragraph's block margin so list items render TIGHT (no extra vertical
|
||||
gap), matching the previous marked output — same rule already applied to
|
||||
table cells above (issue #347). */
|
||||
.markdown li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||
@@ -172,6 +181,14 @@
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
/* Same as `.markdown li p` above: the canonical converter wraps every list
|
||||
item's content in a <p>, so without this each reasoning-panel list item would
|
||||
pick up `.reasoningText p`'s 4px bottom margin and render too loose. Drop it
|
||||
so Reasoning-panel lists stay tight, mirroring the pre-#347 marked output. */
|
||||
.reasoningText li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
flex: 0 0 auto;
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,20 @@ interface MessageItemProps {
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render the one-line summary of
|
||||
* a call's arguments (e.g. the search query). Defaults to true (internal
|
||||
* chat). The public share passes false so an anonymous reader doesn't see the
|
||||
* agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
|
||||
* errorText. Defaults to true (internal chat). The public share passes false so
|
||||
* internal detail in a tool error is never painted (belt to the server-side
|
||||
* byte sanitization).
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
@@ -117,6 +131,8 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
@@ -210,6 +226,8 @@ function MessageItem({
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
showErrors={showErrors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -274,6 +292,8 @@ export function arePropsEqual(
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.showInput === next.showInput &&
|
||||
prev.showErrors === next.showErrors &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName &&
|
||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||
|
||||
@@ -25,6 +25,19 @@ interface MessageListProps {
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render the
|
||||
* one-line summary of a call's arguments (e.g. the search query). Defaults to
|
||||
* true (internal chat). The public share passes false so an anonymous reader
|
||||
* doesn't see the agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
|
||||
* its raw errorText. Defaults to true (internal chat). The public share passes
|
||||
* false so internal detail in a tool error is never painted.
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
@@ -119,6 +132,8 @@ export default function MessageList({
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
@@ -208,6 +223,8 @@ export default function MessageList({
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
showErrors={showErrors}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getToolName,
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolLabelKey,
|
||||
toolRunState,
|
||||
ToolUiPart,
|
||||
@@ -21,6 +22,24 @@ interface ToolCallCardProps {
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Whether to render the one-line summary of the call's arguments (e.g. the
|
||||
* search query) under the label. Defaults to true (the internal chat). The
|
||||
* public share passes false: an anonymous reader should not see the agent's
|
||||
* raw query/argument text. Conservative and reversible — it only suppresses
|
||||
* the extra summary line, leaving the card (the action log) intact.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Whether to render the tool's raw errorText on a failed call. Defaults to true
|
||||
* (the internal chat, where the operator may debug). The public share passes
|
||||
* false: a tool error string can carry internal detail (an internal page title,
|
||||
* a stack fragment, a provider message). This is the RENDER gate only — the
|
||||
* authoritative fix also sanitizes the bytes server-side (see
|
||||
* PublicShareChatToolsService.forShare), so a share reader never receives raw
|
||||
* error text over the wire, not just never sees it painted (#394).
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +50,15 @@ interface ToolCallCardProps {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
const inputSummary = showInput ? toolInputSummary(part) : undefined;
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
@@ -57,7 +79,13 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
{inputSummary && (
|
||||
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
||||
{inputSummary}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && showErrors && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// react-i18next / notifications are pulled in transitively by ai-chat-query.ts
|
||||
// (the mutation hooks use them); stub so the module imports cleanly.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the service module; only getAiChatMessages is exercised, but the other
|
||||
// named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatMessages } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatMessagesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
const emptyPage = { items: [], meta: { hasNextPage: false, nextCursor: null } };
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// The degraded-poll fallback (#184 phase 1.5) is threaded into this query as a
|
||||
// `refetchInterval`; AiChatWindow supplies the deliberately-dumb callback. These
|
||||
// pin the plumbing the window depends on: the interval polls the message history,
|
||||
// and — critically — fetch ERRORS do NOT stop the tick (TanStack v5 resets the
|
||||
// failure count each fetch, so the poll must survive a server restart).
|
||||
describe("useAiChatMessagesQuery — degraded refetchInterval", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-polls at the interval while the callback returns a duration", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT re-poll when the callback returns false", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// Give any errant interval a chance to fire, then assert it did not.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps ticking through fetch errors (errors do not gate the poll)", async () => {
|
||||
vi.mocked(getAiChatMessages).mockRejectedValue(new Error("server down"));
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChatRun,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -37,7 +35,6 @@ import {
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
@@ -55,10 +52,13 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
];
|
||||
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
/**
|
||||
* Paginated list of the current user's chats (auto-loads further pages).
|
||||
* `enabled` (default true) lets the AI chat window skip fetching while it is
|
||||
* closed — the list is only needed once the window is open.
|
||||
*/
|
||||
export function useAiChatsQuery(enabled: boolean = true) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
@@ -67,6 +67,7 @@ export function useAiChatsQuery() {
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -89,7 +90,18 @@ export function useAiChatsQuery() {
|
||||
* Load all persisted messages of a chat (oldest first), flattening the
|
||||
* paginated server response. Used to seed `useChat` initial messages.
|
||||
*/
|
||||
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
export function useAiChatMessagesQuery(
|
||||
chatId: string | undefined,
|
||||
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
||||
// still-running run (the attach returned 204 / the resumed stream ended with no
|
||||
// terminal row), the window arms a dumb timed poll of the message history to
|
||||
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
||||
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
||||
refetchInterval?: number | false | (() => number | false),
|
||||
// #344: gate the query so a backgrounded/hidden window stops issuing refetches
|
||||
// and duplicating work. Defaults to enabled to preserve existing call-sites.
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
queryFn: ({ pageParam }) =>
|
||||
@@ -99,7 +111,8 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId,
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
@@ -139,34 +152,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||
*
|
||||
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||
* failed fetch can never spin a tight loop.
|
||||
*/
|
||||
export function useAiChatRunQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiChatRunResponse, Error>({
|
||||
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||
queryFn: () => getAiChatRun(chatId as string),
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameAiChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||
// use it); stub it so the module imports cleanly in this hook test.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatRun: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const runningResponse: IAiChatRunResponse = {
|
||||
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||
message: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "working...",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("useAiChatRunQuery — enable gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||
expect(result.current.data?.run?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
// Give any errant fetch a chance to fire, then assert none did.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fetch when there is no chat id", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -43,23 +42,6 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||
* persisted lifecycle state and the assistant message it materializes (the
|
||||
* partial output while the run is in-flight, the final output once it finished).
|
||||
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||
* runs, so the endpoint returns `{ run: null }`.
|
||||
*/
|
||||
export async function getAiChatRun(
|
||||
chatId: string,
|
||||
): Promise<IAiChatRunResponse> {
|
||||
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||
@@ -75,6 +57,25 @@ export async function stopRun(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
||||
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
||||
* and to VERIFY after a supersede mismatch (an observer following a superseded
|
||||
* run asks for the latest run and follows it). Returns the latest run row (with
|
||||
* its `id` and `status`) and its projected assistant message, or `run: null` when
|
||||
* the chat has never had a run. Owner-gated server-side.
|
||||
*/
|
||||
export async function getRun(chatId: string): Promise<{
|
||||
run: { id: string; status: string } | null;
|
||||
message: IAiChatMessageRow | null;
|
||||
}> {
|
||||
const req = await api.post<{
|
||||
run: { id: string; status: string } | null;
|
||||
message: IAiChatMessageRow | null;
|
||||
}>("/ai-chat/run", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# AI-chat run-lifecycle FSM — design spec (#488)
|
||||
|
||||
This is the written design that `run-fsm.ts` implements. It ships in the PR (issue
|
||||
#488 commit 1: "the spec is written FIRST and enters the PR"). It has four parts:
|
||||
(1) the event × state transition table, (2) the map of every `chat-thread.tsx` ref
|
||||
to {FSM state | FSM context | stays data}, (3) the run-fact protocol, (4) the
|
||||
invariants.
|
||||
|
||||
The reducer is a **pure function** `reduce(machine, event) → machine`. The returned
|
||||
machine carries the **command effects** for that transition; a thin runtime in
|
||||
`chat-thread.tsx` dispatches events and executes effects. Because it is pure, the
|
||||
whole machine is enumerable and unit-tested directly (event × state → next state is
|
||||
the observable property) — see `run-fsm.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Event × state transition table
|
||||
|
||||
Phases: `idle | sending | streaming | attaching | reconnecting(attempt,failed) |
|
||||
polling(reason) | stalled | stopping | superseding | error(kind)`.
|
||||
Context (orthogonal): `epoch`, `ownership: local|observer`, `runFact: {runId}|null`,
|
||||
`liveFollow` (are we following a live run we locally streamed — the reconnect
|
||||
ladder — vs a one-shot mount-attach resume? both are `observer`, but a live-follow
|
||||
drop RE-ENTERS the ladder (#488 commit 3) while a mount-resume drop polls).
|
||||
|
||||
Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
||||
|
||||
| Event (source) | From phase(s) | → To phase | Effects / ctx |
|
||||
|---|---|---|---|
|
||||
| `SEND_LOCAL` (user send) | idle, error, polling, stalled, reconnecting | sending **†** | `[cancelReconnect, disarmPoll]`, ownership=local |
|
||||
| `STREAM_START{runId}` (SDK `start` metadata) | sending, attaching, reconnecting, superseding | streaming | `[cancelReconnect, disarmPoll]`, runFact←runId |
|
||||
| `FINISH_CLEAN` (onFinish clean) | streaming, … | idle | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||
| `FINISH_ABORT` (onFinish isAbort) | streaming, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4 exits stopping by this DATA) |
|
||||
| `FINISH_DISCONNECT` (observer, NOT liveFollow) | streaming(observer) | polling(disconnect-visible) | `[armPoll]` (a mount-resume drop polls) |
|
||||
| `FINISH_DISCONNECT{hasVisibleContent}` (local drop OR liveFollow) | streaming | reconnecting(1) **†** *iff runFact\|liveFollow* | `[scheduleReconnect(1)]` (+`armPoll` if visible), ownership=observer, liveFollow=true (commit 3: repeatable) |
|
||||
| `FINISH_DISCONNECT` (no runFact, not liveFollow) | streaming | idle | runFact←null (plain terminal "connection lost") |
|
||||
| `STREAM_INCOMPLETE{reason}` (observer starved/torn clean finish) | streaming(observer) | polling(reason) | `[armPoll(reason)]` |
|
||||
| `FINISH_ERROR{kind}` (onFinish isError) | any | error(kind) | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||
| `STREAM_START{runId}` (first assistant frame of a local turn) | sending | streaming | runFact←runId, `[cancelReconnect, disarmPoll]` |
|
||||
| `ATTACH_START{runId}` (mount resume) | **idle only** (F2) | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId; ignored from any non-idle phase |
|
||||
| `ATTACH_LIVE` (attach GET 2xx) | attaching | streaming | — |
|
||||
| `ATTACH_NONE` (attach GET 204/err/throw) | attaching | polling(attach-none) | `[armPoll(attach-none)]` |
|
||||
| `RECONNECT_ATTEMPT{n}` (backoff timer) | reconnecting | reconnecting(n) **†** | `[resumeStream]` |
|
||||
| `RECONNECT_ATTACHED` (reconnect GET 2xx) | reconnecting | streaming | `[cancelReconnect, disarmPoll]` — **counter reset** (commit 3) |
|
||||
| `RECONNECT_NONE` (reconnect GET 204/err), attempt<MAX | reconnecting | reconnecting(n+1) **†** | `[armPoll(attach-none), scheduleReconnect(n+1)]` |
|
||||
| `RECONNECT_NONE`, attempt=MAX | reconnecting | reconnecting(MAX, failed) | `[armPoll(reconnect-exhausted)]` |
|
||||
| `RETRY` (manual, failed banner) | reconnecting(failed) | reconnecting(1) **†** | `[resumeStream]` |
|
||||
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||
| `SUPERSEDE_REQUESTED{targetRunId}` (interrupt+send) | streaming, reconnecting, polling, error | superseding **†** | `[supersede(target), cancelReconnect, disarmPoll]` |
|
||||
| `SUPERSEDE_READY{runId}` (CAS ok) | superseding | streaming | ownership=local, runFact←runId |
|
||||
| `SUPERSEDE_MISMATCH{currentRunId}` (409 SUPERSEDE_TARGET_MISMATCH) | superseding | error(supersede-mismatch) | `[postRun(verify)]`, runFact←currentRunId |
|
||||
| `SUPERSEDE_TIMEOUT` (409 SUPERSEDE_TIMEOUT) | superseding | error(supersede-timeout) | — (composer keeps text; no auto-retry) |
|
||||
| `SUPERSEDE_INVALID` (409 SUPERSEDE_INVALID) | superseding | error(supersede-invalid) | — |
|
||||
| `RUN_ALREADY_ACTIVE{activeRunId}` (409 A_RUN_ALREADY_ACTIVE, plain POST) | sending | error(run-already-active) | runFact←activeRunId (composer offers supersede; NO auto-retry) |
|
||||
| `DISPOSE` (unmount) | any | idle **†** | `[abortAttach, cancelReconnect, disarmPoll]` (I1/I5 — epoch++ kills late callbacks) |
|
||||
|
||||
**`stopping` honors any finish (re-review MEDIUM):** BEFORE the epoch filter, a
|
||||
stream finish (`FINISH_*`/`STREAM_INCOMPLETE`) arriving in phase `stopping` exits
|
||||
`stopping -> idle` regardless of generation. A plain Stop has no successor stream,
|
||||
so the aborted stream's finish IS the expected end (I4 exit by data) — and it
|
||||
carries the PRE-stop generation (STOP_REQUESTED bumped the epoch), so the filter
|
||||
would otherwise strand the machine in `stopping` (no idle-cap covers it). The filter
|
||||
stays in force for `superseding` (that is the F1 supersede drop).
|
||||
|
||||
**Epoch filter (I1):** the reducer then drops any event carrying an `epoch` that
|
||||
does not equal the current `ctx.epoch`. Outcome events (`STREAM_START`, `ATTACH_*`,
|
||||
`RECONNECT_*`, `SUPERSEDE_*`, **`FINISH_*`/`STREAM_INCOMPLETE`**, `RUN_FACT`) are
|
||||
stamped with the generation the corresponding STREAM started under (the runtime
|
||||
holds a per-owned-stream `turnEpoch`); trigger events (user actions, fresh
|
||||
disconnects) carry no epoch. **F1:** this is what makes a SUPERSEDED stream's late
|
||||
`onFinish` (a dead stream A closing after the CAS started stream B) get dropped, so
|
||||
A cannot drive the live new run into a false reconnect or reset its run-fact. The
|
||||
supersede path additionally ABORTS A and starts B only from A's onFinish (a
|
||||
microtask), because ai@6 `AbstractChat.makeRequest` corrupts overlapping streams
|
||||
(A's `finally` reads then nulls the shared `activeResponse`).
|
||||
|
||||
**Removed events (scope-cut, internal review):** `RUN_SUPERSEDED` (a ghost feature —
|
||||
never dispatched; the observer-superseded case is handled by the degraded poll,
|
||||
which follows the latest rows regardless of runId), `RECONNECT_BEGIN` (reconnect is
|
||||
entered by `FINISH_DISCONNECT`), and `POLL_ACTIVITY` (the window's activity clock was
|
||||
removed when the idle-cap moved into the thread). The reducer and this table now
|
||||
share exactly the dispatched event set.
|
||||
|
||||
### 409-code → event map (the real #487 contract consumed here)
|
||||
|
||||
| Server response | Event dispatched | error kind → banner |
|
||||
|---|---|---|
|
||||
| 409 `A_RUN_ALREADY_ACTIVE` (+ body.activeRunId) | `RUN_ALREADY_ACTIVE{activeRunId}` | run-already-active → "already answering / interrupt & send" |
|
||||
| 409 `SUPERSEDE_TARGET_MISMATCH` (+ body.activeRunId) | `SUPERSEDE_MISMATCH{currentRunId}` | supersede-mismatch → verify via /run |
|
||||
| 409 `SUPERSEDE_TIMEOUT` | `SUPERSEDE_TIMEOUT` | supersede-timeout → "couldn't interrupt in time, resend" |
|
||||
| 409 `SUPERSEDE_INVALID` | `SUPERSEDE_INVALID` | supersede-invalid → "couldn't interrupt this run" |
|
||||
| 503 `A_RUN_BEGIN_FAILED` | `FINISH_ERROR{begin-failed}` | begin-failed → "could not start, temporary" |
|
||||
|
||||
---
|
||||
|
||||
## 2. Ref-map — every `chat-thread.tsx` ref → its new home (MIGRATION RESOLVED)
|
||||
|
||||
The migration is COMPLETE: the 13 run-lifecycle FLAGS below are GONE from
|
||||
`chat-thread.tsx` (collapsed into FSM phase/ctx/effects, or deleted). What remains
|
||||
are identity/data mirrors, effect-owned controllers/timers, and ONE React-liveness
|
||||
bit — none of which is a run-lifecycle flag, so the post-merge "no new flags" rule
|
||||
holds. **Pending column: empty.**
|
||||
|
||||
| # | Old ref | Resolved to | Where now |
|
||||
|---|---|---|---|
|
||||
| 1 | `reconcileTailRef` | **FSM phase** | reconcile-merge gated on `phase ∈ {polling, reconnecting, stopping}` |
|
||||
| 2 | `noStreamHandledRef` | **FSM epoch (I1)** | the attach outcome's epoch guard drops the stale/second outcome |
|
||||
| 3 | `onNoActiveStreamRef` | **FSM event** | transport → `handleAttachOutcome` dispatches `ATTACH_NONE`/`RECONNECT_NONE` |
|
||||
| 4 | `onReconnectAttachedRef` | **FSM event** | transport dispatches `ATTACH_LIVE` / `RECONNECT_ATTACHED` |
|
||||
| 5 | `resumedTurnRef` + `resumedTurn` state | **FSM ctx `ownership`** | `ownership==='observer'` ⇒ never flush; hides "Send now" |
|
||||
| 6 | `reconnectStateRef` + `reconnectState` state | **FSM phase** | `reconnecting(attempt,failed)` renders the banner |
|
||||
| 7 | `reconnectTimerRef` | **effect-owned timer** | owned by `scheduleReconnect`/`cancelReconnect` effects (not a flag) |
|
||||
| 8 | `flushOnAbortRef` | **DELETED** | the stop→flush dance is replaced by the CAS supersede (commit 5) |
|
||||
| 9 | `interruptNextSendRef` | **DELETED** | the server injects the interrupt note from the supersede itself |
|
||||
| 10 | `supersedeRetryRef` | **DELETED** (commit 5) | the client 409 retry ladder is gone; CAS supersede replaces it |
|
||||
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
||||
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
||||
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
||||
| 14 | `stripRef` | **data** (attachStrategy) | strip+replay detail; the `resumeStream` effect reads it |
|
||||
| 15 | `strippedRowRef` | **data** (attachStrategy) | the anchor row |
|
||||
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
||||
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
||||
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
||||
| NEW | `idleCapTimerRef` | **effect-owned timer** | the stalled inactivity cap → `POLL_IDLE_CAP` (commit 4a); not a flag |
|
||||
|
||||
Net: the 13 lifecycle flags (#1–#13) are eliminated: **8** → FSM phase/ctx/epoch/event
|
||||
(#1–#6, #11, #13), **3** deleted (#8/#9/#10), **`reconnectTimerRef` (#7)** becomes an
|
||||
effect-owned controller, and **`mountedRef` (#12)** is retained as React liveness
|
||||
(8 + 3 + 1 + 1 = 13). (`attachAbortRef` (#16) is outside the #1–#13 set — it was
|
||||
already an effect-owned controller.) Two effect-owned timers + one send-plumbing data
|
||||
ref are added — none is a boolean lifecycle latch.
|
||||
|
||||
---
|
||||
|
||||
## 3. Run-fact protocol (`runFact: {runId} | null`) — I3
|
||||
|
||||
"A run is active" is first-class from the SERVER, not inferred from an assistant
|
||||
message. Sources, in the order they update `ctx.runFact`:
|
||||
|
||||
1. **Init (mount):** `POST /ai-chat/run { chatId }` → `{ run, message }`. A `run`
|
||||
with a non-terminal `status` seeds `runFact = { runId: run.id }`; a null/terminal
|
||||
run seeds `null`. This is what arms the resume attempt (`ATTACH_START`) — the
|
||||
attempt is armed ONLY on a positive fact (commit 4b: a user-tail with no active
|
||||
run no longer arms a pointless poll on every open).
|
||||
2. **Live update:** the `start` stream metadata carries `runId` → `STREAM_START{runId}`.
|
||||
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
||||
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
||||
which cancels recovery (I3 fresh-negative gate).
|
||||
4. **Poll (future resume-stack iteration #491):** the delta will carry the run field;
|
||||
until then the poll drives to a terminal ROW, dispatched as `POLL_TERMINAL`.
|
||||
|
||||
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
||||
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
||||
|
||||
---
|
||||
|
||||
## 4. Invariants
|
||||
|
||||
- **I1 — Epoch (generation counter).** Every command-emitting transition bumps
|
||||
`ctx.epoch`; every async outcome event carries its issuing epoch; the reducer
|
||||
drops stale-epoch outcomes. Replaces the one-shot-ref zoo (`noStreamHandledRef`,
|
||||
the flush/interrupt/supersede one-shots, the `mountedRef` late-callback gate).
|
||||
- **I2 — Ownership is context, not state.** `local | observer` is orthogonal to the
|
||||
transport phase. The queue flushes ONLY under local ownership; an observer
|
||||
following a detached run never flushes (was `resumedTurnRef`).
|
||||
- **I3 — Run-fact is first-class from the server.** Reconnect is entered by the
|
||||
run-fact, not by an assistant message (commit 2). A fresh negative fact cancels
|
||||
recovery.
|
||||
- **I4 — Exit `stopping` by DATA.** A terminal row / negative run-fact / terminal
|
||||
finish exits `stopping`, never the stopRun HTTP response (which returns after the
|
||||
abort but before finalization — keying off it would unlock the composer on a 409).
|
||||
- **I5 — Dispose protocol.** Command controllers (attach GET, POST /stream, POST
|
||||
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
||||
render-phase refs. A client abort of an already-sent POST does not cancel the
|
||||
server action, so disarming on unmount is safe.
|
||||
- **attachStrategy** (strip+replay today) is behind the `resumeStream` effect; the
|
||||
resume-stack iteration (#491) swaps it to tail-only WITHOUT touching the FSM.
|
||||
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
reduce,
|
||||
initialMachine,
|
||||
reconnectDelayMs,
|
||||
RECONNECT_MAX_ATTEMPTS,
|
||||
type Machine,
|
||||
type Effect,
|
||||
type Event,
|
||||
} from "./run-fsm";
|
||||
|
||||
// Drive a sequence of events through the reducer, returning the final machine.
|
||||
function run(m: Machine, ...events: Event[]): Machine {
|
||||
return events.reduce(reduce, m);
|
||||
}
|
||||
function withRunFact(runId = "run-1"): Machine {
|
||||
return {
|
||||
...initialMachine(),
|
||||
ctx: { epoch: 0, ownership: "local", runFact: { runId }, liveFollow: false },
|
||||
};
|
||||
}
|
||||
function effectTypes(m: Machine): string[] {
|
||||
return m.effects.map((e) => e.type);
|
||||
}
|
||||
function hasEffect(m: Machine, type: Effect["type"]): boolean {
|
||||
return m.effects.some((e) => e.type === type);
|
||||
}
|
||||
|
||||
describe("run-fsm — epoch invariant (I1)", () => {
|
||||
it("drops an outcome carrying a stale epoch", () => {
|
||||
// A command bumps the epoch; an outcome stamped with the OLD epoch is dropped.
|
||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" }); // epoch 0->1, attaching
|
||||
expect(m0.ctx.epoch).toBe(1);
|
||||
expect(m0.phase.name).toBe("attaching");
|
||||
// A late ATTACH_LIVE from a SUPERSEDED attempt (epoch 0) must NOT drive us.
|
||||
const stale = reduce(m0, { type: "ATTACH_LIVE", epoch: 0 });
|
||||
expect(stale.phase.name).toBe("attaching");
|
||||
expect(stale.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies an outcome carrying the current epoch", () => {
|
||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
const live = reduce(m0, { type: "ATTACH_LIVE", epoch: m0.ctx.epoch });
|
||||
expect(live.phase.name).toBe("streaming");
|
||||
});
|
||||
|
||||
it("an outcome with no epoch is never dropped (trigger events)", () => {
|
||||
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
const disposed = reduce(m0, { type: "DISPOSE" });
|
||||
expect(disposed.phase.name).toBe("idle");
|
||||
expect(hasEffect(disposed, "abortAttach")).toBe(true);
|
||||
});
|
||||
|
||||
it("every command-transition increments the epoch exactly once", () => {
|
||||
let m = initialMachine();
|
||||
const before = m.ctx.epoch;
|
||||
m = reduce(m, { type: "SEND_LOCAL" });
|
||||
expect(m.ctx.epoch).toBe(before + 1);
|
||||
m = reduce(m, { type: "STOP_REQUESTED" });
|
||||
expect(m.ctx.epoch).toBe(before + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-fsm — local turn", () => {
|
||||
it("SEND_LOCAL → sending, local ownership, cancels recovery", () => {
|
||||
const m = reduce(withRunFact(), { type: "SEND_LOCAL" });
|
||||
expect(m.phase.name).toBe("sending");
|
||||
expect(m.ctx.ownership).toBe("local");
|
||||
expect(effectTypes(m)).toEqual(
|
||||
expect.arrayContaining(["cancelReconnect", "disarmPoll"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("STREAM_START adopts the runId into the run-fact and goes streaming", () => {
|
||||
const m = run(initialMachine(), { type: "SEND_LOCAL" });
|
||||
const s = reduce(m, { type: "STREAM_START", runId: "run-9", epoch: m.ctx.epoch });
|
||||
expect(s.phase.name).toBe("streaming");
|
||||
expect(s.ctx.runFact).toEqual({ runId: "run-9" });
|
||||
});
|
||||
|
||||
it("FINISH_CLEAN → idle, run-fact cleared, poll/reconnect disarmed", () => {
|
||||
const streaming = run(initialMachine(), { type: "SEND_LOCAL" }, { type: "STREAM_START", runId: "r" });
|
||||
const done = reduce(streaming, { type: "FINISH_CLEAN" });
|
||||
expect(done.phase.name).toBe("idle");
|
||||
expect(done.ctx.runFact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #488 commit 2 — SSE break BEFORE the first assistant frame must still recover.
|
||||
describe("run-fsm — commit 2: reconnect by run-fact, not by assistant message", () => {
|
||||
it("FINISH_DISCONNECT with an active run-fact → reconnecting (even with no visible content)", () => {
|
||||
// Setup-phase break: no assistant frame yet, but a run-fact exists.
|
||||
const streaming = withRunFact("run-2");
|
||||
const m = reduce(streaming, {
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: false,
|
||||
epoch: streaming.ctx.epoch,
|
||||
});
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||
// No visible content -> no poll arm yet (the reconnect ladder rebuilds it).
|
||||
expect(hasEffect(m, "armPoll")).toBe(false);
|
||||
});
|
||||
|
||||
it("FINISH_DISCONNECT WITH visible content also arms the poll", () => {
|
||||
const m = reduce(withRunFact("run-2"), {
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: true,
|
||||
epoch: 0,
|
||||
});
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("FINISH_DISCONNECT with NO run-fact → idle (plain connection-lost)", () => {
|
||||
const m = reduce(initialMachine(), {
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: true,
|
||||
epoch: 0,
|
||||
});
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
// #488 commit 3 — a SECOND break after a successful re-attach starts a NEW ladder.
|
||||
describe("run-fsm — commit 3: repeated reconnect cycles", () => {
|
||||
it("two breaks in a row produce two reconnect cycles (counter resets on attach)", () => {
|
||||
let m = withRunFact("run-3");
|
||||
// First break -> reconnecting(1).
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
// Attempt fires, re-attaches live.
|
||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("streaming");
|
||||
// SECOND break: the counter was reset, so a fresh ladder starts at attempt 1
|
||||
// (the old one-shot !wasResumed gate would have sent this to silent poll).
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||
});
|
||||
|
||||
it("a MOUNT-attach observer drop falls to POLL, not the reconnect ladder", () => {
|
||||
// Distinguishes commit 3 from a one-shot resume: an observer that never
|
||||
// live-followed (liveFollow false) polls on a drop.
|
||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
expect(m.ctx.liveFollow).toBe(false);
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("polling");
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("STREAM_INCOMPLETE (observer starved/torn finish) → polling", () => {
|
||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "STREAM_INCOMPLETE", reason: "starved", epoch: m.ctx.epoch });
|
||||
expect(m.phase).toEqual({ name: "polling", reason: "starved" });
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("liveFollow is set on the first local drop and kept across a re-attach", () => {
|
||||
let m = withRunFact("run-3");
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||
expect(m.ctx.liveFollow).toBe(true);
|
||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||
expect(m.ctx.liveFollow).toBe(true); // kept — so a second drop reconnects
|
||||
// A clean finish clears it.
|
||||
m = reduce(m, { type: "FINISH_CLEAN", epoch: m.ctx.epoch });
|
||||
expect(m.ctx.liveFollow).toBe(false);
|
||||
});
|
||||
|
||||
it("RECONNECT_NONE backs off through the ladder, then fails at the cap", () => {
|
||||
let m = withRunFact("run-3");
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||
for (let n = 1; n < RECONNECT_MAX_ATTEMPTS; n++) {
|
||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: n, epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
if (m.phase.name === "reconnecting") {
|
||||
expect(m.phase.attempt).toBe(n + 1);
|
||||
expect(m.phase.failed).toBe(false);
|
||||
}
|
||||
// The belt-and-suspenders poll is armed each failed attempt.
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
}
|
||||
// Final attempt fails -> failed banner (Retry), poll armed.
|
||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: RECONNECT_MAX_ATTEMPTS, epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
if (m.phase.name === "reconnecting") expect(m.phase.failed).toBe(true);
|
||||
// RETRY restarts at attempt 1.
|
||||
m = reduce(m, { type: "RETRY" });
|
||||
expect(m.phase.name).toBe("reconnecting");
|
||||
if (m.phase.name === "reconnecting") {
|
||||
expect(m.phase.attempt).toBe(1);
|
||||
expect(m.phase.failed).toBe(false);
|
||||
}
|
||||
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||
});
|
||||
|
||||
it("reconnectDelayMs is the exponential backoff 1s,2s,4s,8s,16s", () => {
|
||||
expect([1, 2, 3, 4, 5].map(reconnectDelayMs)).toEqual([1000, 2000, 4000, 8000, 16000]);
|
||||
});
|
||||
});
|
||||
|
||||
// #488 commit 4 — polling stalled-state + user-tail gating.
|
||||
describe("run-fsm — commit 4: stalled + run-fact gating", () => {
|
||||
it("POLL_IDLE_CAP: polling → stalled with a banner (poll disarmed), not silent", () => {
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("polling");
|
||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||
expect(m.phase.name).toBe("stalled");
|
||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("RETRY from stalled re-arms the poll", () => {
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||
m = reduce(m, { type: "RETRY" });
|
||||
expect(m.phase.name).toBe("polling");
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("a fresh NEGATIVE run-fact while attaching cancels recovery (user-tail, no active run)", () => {
|
||||
// The mount POST /run returns no active run: attaching → idle, no poll armed.
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(m.ctx.runFact).toBeNull();
|
||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||
});
|
||||
|
||||
it("a negative run-fact while polling stops the poll", () => {
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
|
||||
it("POLL_TERMINAL settles polling → idle (I4 data-driven exit)", () => {
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(m.ctx.runFact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #488 commit 5 — error classification + supersede CAS transitions.
|
||||
describe("run-fsm — commit 5: supersede CAS + error classification", () => {
|
||||
it("SUPERSEDE_REQUESTED → superseding, fires the CAS effect, bumps epoch", () => {
|
||||
const streaming = withRunFact("run-old");
|
||||
const m = reduce(streaming, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
expect(m.phase.name).toBe("superseding");
|
||||
expect(m.ctx.epoch).toBe(streaming.ctx.epoch + 1);
|
||||
const sup = m.effects.find((e) => e.type === "supersede");
|
||||
expect(sup).toEqual({ type: "supersede", targetRunId: "run-old" });
|
||||
});
|
||||
|
||||
it("SUPERSEDE_READY → streaming as the new local owner", () => {
|
||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
m = reduce(m, { type: "SUPERSEDE_READY", runId: "run-new", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("streaming");
|
||||
expect(m.ctx.ownership).toBe("local");
|
||||
expect(m.ctx.runFact).toEqual({ runId: "run-new" });
|
||||
});
|
||||
|
||||
it("SUPERSEDE_MISMATCH → error(supersede-mismatch) + verify via /run (no blind banner)", () => {
|
||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
m = reduce(m, { type: "SUPERSEDE_MISMATCH", currentRunId: "run-x", epoch: m.ctx.epoch });
|
||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-mismatch" });
|
||||
expect(hasEffect(m, "postRun")).toBe(true);
|
||||
expect(m.ctx.runFact).toEqual({ runId: "run-x" });
|
||||
});
|
||||
|
||||
it("SUPERSEDE_TIMEOUT → error(supersede-timeout), no auto-retry effect", () => {
|
||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
m = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: m.ctx.epoch });
|
||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-timeout" });
|
||||
expect(m.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("SUPERSEDE_INVALID → error(supersede-invalid)", () => {
|
||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
m = reduce(m, { type: "SUPERSEDE_INVALID", epoch: m.ctx.epoch });
|
||||
expect(m.phase).toEqual({ name: "error", kind: "supersede-invalid" });
|
||||
});
|
||||
|
||||
it("a stale SUPERSEDE outcome from a superseded epoch is dropped", () => {
|
||||
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
const supersedingEpoch = m.ctx.epoch;
|
||||
// The user retriggers, bumping the epoch again.
|
||||
m = reduce(m, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||
// The first CAS's late TIMEOUT (old epoch) must NOT knock us out of superseding.
|
||||
const late = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: supersedingEpoch });
|
||||
expect(late.phase.name).toBe("superseding");
|
||||
});
|
||||
|
||||
it("RUN_ALREADY_ACTIVE (plain POST gate) → error(run-already-active), no retry effect", () => {
|
||||
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), { type: "RUN_ALREADY_ACTIVE" });
|
||||
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||
expect(m.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("#497/S4: RUN_ALREADY_ACTIVE{activeRunId} ADOPTS the server's active run as the run-fact", () => {
|
||||
// The server sends `activeRunId` so a later supersede can TARGET that run
|
||||
// instead of a blind promote+abort. Absorb it into runFact.
|
||||
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||
type: "RUN_ALREADY_ACTIVE",
|
||||
activeRunId: "run-foreign",
|
||||
});
|
||||
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||
expect(m.ctx.runFact).toEqual({ runId: "run-foreign" });
|
||||
expect(m.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("#497/S4: RUN_ALREADY_ACTIVE without an activeRunId keeps the prior run-fact", () => {
|
||||
const seeded = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||
type: "RUN_FACT",
|
||||
runFact: { runId: "run-prior" },
|
||||
});
|
||||
const m = reduce(seeded, { type: "RUN_ALREADY_ACTIVE" });
|
||||
expect(m.ctx.runFact).toEqual({ runId: "run-prior" });
|
||||
});
|
||||
});
|
||||
|
||||
// #488 F2 — a late mount `getRun → ATTACH_START` must not hijack a local turn.
|
||||
describe("run-fsm — F2: ATTACH_START only from idle", () => {
|
||||
it("ATTACH_START from a local `sending` turn is ignored (no observer hijack)", () => {
|
||||
const sending = reduce(initialMachine(), { type: "SEND_LOCAL" }); // idle -> sending, local
|
||||
const m = reduce(sending, { type: "ATTACH_START", runId: "r" });
|
||||
expect(m.phase.name).toBe("sending");
|
||||
expect(m.ctx.ownership).toBe("local"); // NOT flipped to observer
|
||||
expect(m.effects).toEqual([]); // no resumeStream
|
||||
});
|
||||
|
||||
it("ATTACH_START from idle attaches as normal", () => {
|
||||
const m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
expect(m.phase.name).toBe("attaching");
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-fsm — stop (I4: exit by data)", () => {
|
||||
it("STOP_REQUESTED → stopping, fires stopRun + abortAttach, no data-independent exit", () => {
|
||||
const m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||
expect(m.phase.name).toBe("stopping");
|
||||
expect(effectTypes(m)).toEqual(expect.arrayContaining(["stopRun", "abortAttach"]));
|
||||
});
|
||||
|
||||
it("stopping exits on the aborted stream's finish carrying the PRE-STOP epoch", () => {
|
||||
// MEDIUM (#488 re-review): STOP_REQUESTED is a command that BUMPS the epoch, but
|
||||
// the runtime stamps the aborted stream's onFinish with the stream's START (pre-
|
||||
// stop) generation — exactly what the component sends. `stopping` must HONOR
|
||||
// that finish regardless of generation (no idle-cap covers `stopping`).
|
||||
// MUTATION-VERIFY: remove the honor-in-`stopping` branch and this hangs in
|
||||
// `stopping` (the epoch filter drops the pre-stop finish) -> red.
|
||||
const preStopEpoch = withRunFact().ctx.epoch; // E1 (the stream's start epoch)
|
||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" }); // E1 -> E2, stopping
|
||||
expect(m.ctx.epoch).toBe(preStopEpoch + 1);
|
||||
m = reduce(m, { type: "FINISH_ABORT", epoch: preStopEpoch }); // NOT the current epoch
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(m.ctx.runFact).toBeNull();
|
||||
});
|
||||
|
||||
it("stopping exits on a clean finish carrying the pre-stop epoch too", () => {
|
||||
const preStopEpoch = withRunFact().ctx.epoch;
|
||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||
m = reduce(m, { type: "FINISH_CLEAN", epoch: preStopEpoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
|
||||
it("stopping exits on a negative run-fact (data)", () => {
|
||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
|
||||
// Review #4: `stopping` arms the poll but had no inactivity backstop.
|
||||
it("review-4: POLL_IDLE_CAP in `stopping` exits to idle (bounded), NOT stalled", () => {
|
||||
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||
expect(m.phase.name).toBe("stopping");
|
||||
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||
// MUTATION-VERIFY: drop the `stopping` branch in POLL_IDLE_CAP and this hangs
|
||||
// in `stopping` (poll forever) -> red.
|
||||
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||
expect(m.ctx.ownership).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
// Review #1: positive attach outcomes must be guarded by the SOURCE phase — the
|
||||
// epoch filter alone is insufficient because POLL_TERMINAL uses to() (no epoch
|
||||
// bump) and does not abort the in-flight GET.
|
||||
describe("run-fsm — review-1: attach outcomes guarded by source phase", () => {
|
||||
it("a late RECONNECT_ATTACHED after POLL_TERMINAL stays idle (no phantom streaming)", () => {
|
||||
let m = withRunFact("run-1");
|
||||
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch }); // attach GET
|
||||
const epoch = m.ctx.epoch;
|
||||
// The armed degraded poll reaches the terminal row FIRST (epoch unchanged).
|
||||
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(m.ctx.epoch).toBe(epoch); // POLL_TERMINAL did NOT bump the epoch
|
||||
// The slow GET returns live 2xx under the SAME epoch — must NOT resurrect.
|
||||
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
|
||||
it("a late ATTACH_LIVE / ATTACH_NONE after leaving `attaching` is ignored", () => {
|
||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
const epoch = m.ctx.epoch;
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch }); // attaching -> polling
|
||||
m = reduce(m, { type: "POLL_TERMINAL" }); // -> idle (epoch unchanged)
|
||||
expect(m.phase.name).toBe("idle");
|
||||
m = reduce(m, { type: "ATTACH_LIVE", epoch }); // late 2xx, same epoch
|
||||
expect(m.phase.name).toBe("idle");
|
||||
// And a late ATTACH_NONE (not `attaching`) is a no-op too.
|
||||
m = reduce(m, { type: "ATTACH_NONE", epoch });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
// Review #2: every terminal transition resets ownership to local.
|
||||
describe("run-fsm — review-2: terminal transitions reset ownership to local", () => {
|
||||
const observer = (): Machine => {
|
||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
return m;
|
||||
};
|
||||
it("FINISH_CLEAN resets ownership", () => {
|
||||
const m = reduce(observer(), { type: "FINISH_CLEAN", epoch: observer().ctx.epoch });
|
||||
expect(m.ctx.ownership).toBe("local");
|
||||
});
|
||||
it("FINISH_ERROR / POLL_TERMINAL / RUN_FACT(null) reset ownership", () => {
|
||||
let o = observer();
|
||||
expect(reduce(o, { type: "FINISH_ERROR", kind: "stream", epoch: o.ctx.epoch }).ctx.ownership).toBe("local");
|
||||
// POLL_TERMINAL from an observer polling phase
|
||||
let p = reduce(observer(), { type: "STREAM_INCOMPLETE", reason: "starved", epoch: observer().ctx.epoch });
|
||||
expect(reduce(p, { type: "POLL_TERMINAL" }).ctx.ownership).toBe("local");
|
||||
// RUN_FACT(null) from an observer attaching phase
|
||||
let a = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
expect(reduce(a, { type: "RUN_FACT", runFact: null, epoch: a.ctx.epoch }).ctx.ownership).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-fsm — ownership (I2) is context, orthogonal to phase", () => {
|
||||
it("attach/reconnect set observer; send/supersede-ready set local", () => {
|
||||
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||
expect(m.phase.name).toBe("streaming");
|
||||
expect(m.ctx.ownership).toBe("observer"); // still observing a detached run
|
||||
// A local send flips ownership back to local.
|
||||
m = reduce(m, { type: "SEND_LOCAL" });
|
||||
expect(m.ctx.ownership).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-fsm — dispose (I5)", () => {
|
||||
it("DISPOSE from any phase aborts controllers and bumps epoch", () => {
|
||||
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||
const before = m.ctx.epoch;
|
||||
m = reduce(m, { type: "DISPOSE" });
|
||||
expect(m.phase.name).toBe("idle");
|
||||
expect(m.ctx.epoch).toBe(before + 1);
|
||||
expect(effectTypes(m)).toEqual(
|
||||
expect.arrayContaining(["abortAttach", "cancelReconnect", "disarmPoll"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Run-lifecycle finite state machine for a single AI-chat thread (#488).
|
||||
*
|
||||
* ============================================================================
|
||||
* WHY THIS EXISTS
|
||||
* ----------------------------------------------------------------------------
|
||||
* The resume/reconnect/poll/stop/supersede lifecycle used to be spread across
|
||||
* ~26 `useRef` one-shot flags in `chat-thread.tsx`, each disarmed "on every
|
||||
* path". Ownerless flag combinations produced silent UI freezes, and every fix
|
||||
* added another ref (the #381 -> #432 -> #456 spiral). This module replaces that
|
||||
* ref-zoo with ONE pure reducer whose transitions are enumerable and unit-
|
||||
* testable in isolation (event x state -> next state is the observable property).
|
||||
*
|
||||
* The reducer is PURE: it owns no timers, no fetches, no React state. It maps
|
||||
* `(machine, event) -> machine`, where the returned machine carries the list of
|
||||
* COMMAND EFFECTS to run for that transition. A thin runtime in `chat-thread.tsx`
|
||||
* dispatches events (from SDK callbacks / HTTP outcomes) and executes the
|
||||
* effects (attach GET, POST /stream, POST /run, POST /stop, backoff timers,
|
||||
* poll arm/disarm). The runtime lives in a THREAD, not the window, so a late SDK
|
||||
* callback dies with the owner (kills the "event from a dead view" class, #161).
|
||||
*
|
||||
* ============================================================================
|
||||
* INVARIANTS (see run-fsm.spec.md for the full spec + tables)
|
||||
* ----------------------------------------------------------------------------
|
||||
* I1 EPOCH (generation counter). Commands (`resumeStream`, `postRun`, `stop`,
|
||||
* `supersede`, `scheduleReconnect`) are async; their outcomes arrive on the
|
||||
* SAME SDK/HTTP callbacks. Every command-emitting transition increments
|
||||
* `ctx.epoch`; every OUTCOME event carries the epoch it was issued under;
|
||||
* the reducer DROPS an outcome whose epoch != the current epoch. This is
|
||||
* what the one-shot-ref zoo used to approximate by hand.
|
||||
* I2 OWNERSHIP is a CONTEXT FIELD (`'local' | 'observer'`), not a state —
|
||||
* orthogonal to the transport phase. The queue is flushed ONLY by a local
|
||||
* owner (an observer following a detached run never flushes).
|
||||
* I3 RUN-FACT ("a run is active") is first-class from the server: `runFact`
|
||||
* holds the server-confirmed active run id (POST /run on mount, the `start`
|
||||
* metadata runId, attach outcomes). Reconnect is entered by the RUN-FACT,
|
||||
* not by the presence of an assistant message (#488 commit 2). A fresh
|
||||
* negative fact (null) cancels reconnect immediately.
|
||||
* I4 Exit `stopping` by DATA (a terminal row / negative run-fact), NEVER by the
|
||||
* stopRun HTTP response (which returns after abort, before finalization).
|
||||
* I5 Command controllers are effect-owned (abort in cleanup), NOT render-phase
|
||||
* refs — expressed here as the `abortAttach` effect on disposing transitions.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phases (the transport lifecycle). Ownership / runFact are CONTEXT, not here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Why the degraded poll is the active recovery. */
|
||||
export type PollReason =
|
||||
| "attach-none" // mount attach returned 204 / error — nothing live to attach
|
||||
| "starved" // a resumed finish carried no visible content
|
||||
| "disconnect-visible" // a live disconnect WITH on-screen content — poll to terminal
|
||||
| "reconnect-exhausted"; // the live re-attach ladder gave up
|
||||
|
||||
/** The classified error kind (drives the banner text + composer behavior). */
|
||||
export type ErrorKind =
|
||||
| "stream" // a generic provider/network stream error (useChat error)
|
||||
| "run-already-active" // 409 A_RUN_ALREADY_ACTIVE (a plain POST hit the gate)
|
||||
| "supersede-mismatch" // 409 SUPERSEDE_TARGET_MISMATCH (CAS target moved)
|
||||
| "supersede-timeout" // 409 SUPERSEDE_TIMEOUT (old run did not settle in W)
|
||||
| "supersede-invalid" // 409 SUPERSEDE_INVALID (bad supersede target)
|
||||
| "begin-failed"; // 503 A_RUN_BEGIN_FAILED (could not start the run)
|
||||
|
||||
export type Phase =
|
||||
| { name: "idle" }
|
||||
| { name: "sending" } // local POST in flight, before the first frame
|
||||
| { name: "streaming" } // receiving frames
|
||||
| { name: "attaching" } // mount-time attach GET in flight
|
||||
| { name: "reconnecting"; attempt: number; failed: boolean }
|
||||
| { name: "polling"; reason: PollReason }
|
||||
| { name: "stalled" } // poll hit the inactivity cap — banner + Retry
|
||||
| { name: "stopping" }
|
||||
| { name: "superseding" }
|
||||
| { name: "error"; kind: ErrorKind };
|
||||
|
||||
export type Ownership = "local" | "observer";
|
||||
|
||||
/** The server-confirmed active run, or null when no run is active. */
|
||||
export type RunFact = { runId: string } | null;
|
||||
|
||||
export interface Ctx {
|
||||
/** I1: generation counter — every command-transition increments it. */
|
||||
epoch: number;
|
||||
/** I2: does THIS client own the turn's writes (local streamer) or observe? */
|
||||
ownership: Ownership;
|
||||
/** I3: the server-confirmed active run. */
|
||||
runFact: RunFact;
|
||||
/**
|
||||
* Are we FOLLOWING a live run we were locally streaming (the reconnect ladder),
|
||||
* as opposed to a one-shot mount-attach resume? Both are `ownership: 'observer'`,
|
||||
* but they recover DIFFERENTLY on a drop: a live-follow drop RE-ENTERS the
|
||||
* reconnect ladder (#488 commit 3 — the second break after a successful re-attach
|
||||
* must reconnect again, not fall to silent poll), while a mount-resume drop falls
|
||||
* to the degraded poll. This is the ctx bit that separates the two WITHOUT a new
|
||||
* component ref (it is why commit 3 needs the FSM, not a surgical patch).
|
||||
*/
|
||||
liveFollow: boolean;
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
phase: Phase;
|
||||
ctx: Ctx;
|
||||
/** Command effects to run for the transition that produced THIS machine.
|
||||
* The runtime executes them and does not read them again. */
|
||||
effects: Effect[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command effects (the reducer's only side-channel — executed by the runtime).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Effect =
|
||||
/** POST /run to (re)establish or verify the run-fact. `reason` is diagnostic. */
|
||||
| { type: "postRun"; reason: "mount" | "verify" }
|
||||
/** Trigger the SDK `resumeStream()` (attach GET via prepareReconnectToStream). */
|
||||
| { type: "resumeStream" }
|
||||
/** Schedule a reconnect attempt after a backoff, then dispatch RECONNECT_ATTEMPT. */
|
||||
| { type: "scheduleReconnect"; attempt: number; delayMs: number }
|
||||
/** Cancel any pending reconnect backoff timer. */
|
||||
| { type: "cancelReconnect" }
|
||||
/** Arm the degraded poll (the window's dumb timer follows the run in the DB). */
|
||||
| { type: "armPoll"; reason: PollReason }
|
||||
/** Disarm the degraded poll. */
|
||||
| { type: "disarmPoll" }
|
||||
/** POST /stop the chat's active run (authoritative detached-run stop). */
|
||||
| { type: "stopRun" }
|
||||
/** POST /stream { supersede: { runId } } — the CAS "interrupt and send now". */
|
||||
| { type: "supersede"; targetRunId: string }
|
||||
/** Abort the in-flight attach/reconnect GET controller (dispose / observer stop). */
|
||||
| { type: "abortAttach" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events. An OUTCOME event MAY carry `epoch`; if it does and it does not equal
|
||||
// the current epoch, the reducer drops it (I1). Trigger events (user actions,
|
||||
// fresh disconnects) carry no epoch and are never dropped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Event =
|
||||
// -- local turn --
|
||||
| { type: "SEND_LOCAL" }
|
||||
| { type: "STREAM_START"; runId?: string; epoch?: number }
|
||||
/** An OBSERVER's attached stream ended WITHOUT reaching terminal (a starved
|
||||
* clean replay, or a torn resume) — fall to the degraded poll to drive the row
|
||||
* to its real terminal state. (A live-follow drop uses FINISH_DISCONNECT.) */
|
||||
| { type: "STREAM_INCOMPLETE"; reason: PollReason; epoch?: number }
|
||||
| { type: "FINISH_CLEAN"; epoch?: number }
|
||||
| { type: "FINISH_ABORT"; epoch?: number }
|
||||
| { type: "FINISH_DISCONNECT"; hasVisibleContent: boolean; epoch?: number }
|
||||
| { type: "FINISH_ERROR"; kind: ErrorKind; epoch?: number }
|
||||
// -- mount attach (resume) --
|
||||
| { type: "ATTACH_START"; runId?: string }
|
||||
| { type: "ATTACH_LIVE"; epoch?: number }
|
||||
| { type: "ATTACH_NONE"; epoch?: number }
|
||||
// -- reconnect after a live disconnect (entered by FINISH_DISCONNECT, #488 c2) --
|
||||
| { type: "RECONNECT_ATTEMPT"; attempt: number; epoch?: number }
|
||||
| { type: "RECONNECT_ATTACHED"; epoch?: number }
|
||||
| { type: "RECONNECT_NONE"; epoch?: number }
|
||||
| { type: "RETRY" }
|
||||
// -- degraded poll --
|
||||
| { type: "POLL_TERMINAL" }
|
||||
| { type: "POLL_IDLE_CAP" }
|
||||
// -- run-fact (server-confirmed active run) --
|
||||
| { type: "RUN_FACT"; runFact: RunFact; epoch?: number }
|
||||
// -- stop --
|
||||
| { type: "STOP_REQUESTED" }
|
||||
// -- supersede (CAS) --
|
||||
| { type: "SUPERSEDE_REQUESTED"; targetRunId: string }
|
||||
| { type: "SUPERSEDE_READY"; runId?: string; epoch?: number }
|
||||
| { type: "SUPERSEDE_MISMATCH"; currentRunId?: string; epoch?: number }
|
||||
| { type: "SUPERSEDE_TIMEOUT"; epoch?: number }
|
||||
| { type: "SUPERSEDE_INVALID"; epoch?: number }
|
||||
| { type: "RUN_ALREADY_ACTIVE"; activeRunId?: string }
|
||||
// -- lifecycle --
|
||||
| { type: "DISPOSE" };
|
||||
|
||||
export const RECONNECT_MAX_ATTEMPTS = 5;
|
||||
export const RECONNECT_BASE_DELAY_MS = 1000;
|
||||
/** Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s. */
|
||||
export function reconnectDelayMs(attempt: number): number {
|
||||
return RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructors / helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function initialMachine(overrides?: Partial<Ctx>): Machine {
|
||||
return {
|
||||
phase: { name: "idle" },
|
||||
ctx: { epoch: 0, ownership: "local", runFact: null, liveFollow: false, ...overrides },
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a machine result: a phase, optional ctx patch, and effects. Empty
|
||||
* effects by default. Never mutates the input. */
|
||||
function to(
|
||||
m: Machine,
|
||||
phase: Phase,
|
||||
opts?: { ctx?: Partial<Ctx>; effects?: Effect[] },
|
||||
): Machine {
|
||||
return {
|
||||
phase,
|
||||
ctx: { ...m.ctx, ...(opts?.ctx ?? {}) },
|
||||
effects: opts?.effects ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** No transition: keep the phase, clear effects (so a re-run does not re-fire). */
|
||||
function stay(m: Machine): Machine {
|
||||
return { phase: m.phase, ctx: m.ctx, effects: [] };
|
||||
}
|
||||
|
||||
/** A command-transition: same as `to` but bumps the epoch (I1). Any outcome
|
||||
* event issued under the old epoch is dropped once this lands. */
|
||||
function command(
|
||||
m: Machine,
|
||||
phase: Phase,
|
||||
effects: Effect[],
|
||||
ctx?: Partial<Ctx>,
|
||||
): Machine {
|
||||
return {
|
||||
phase,
|
||||
ctx: { ...m.ctx, ...(ctx ?? {}), epoch: m.ctx.epoch + 1 },
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pure reducer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The terminal stream-finish events (one turn's stream ended). */
|
||||
function isFinishEvent(event: Event): boolean {
|
||||
return (
|
||||
event.type === "FINISH_ABORT" ||
|
||||
event.type === "FINISH_CLEAN" ||
|
||||
event.type === "FINISH_DISCONNECT" ||
|
||||
event.type === "FINISH_ERROR" ||
|
||||
event.type === "STREAM_INCOMPLETE"
|
||||
);
|
||||
}
|
||||
|
||||
export function reduce(m: Machine, event: Event): Machine {
|
||||
// MEDIUM (#488 re-review): honor ANY stream finish in `stopping` regardless of
|
||||
// generation. A plain user Stop has NO successor stream — the aborted stream's
|
||||
// finish IS the expected end of the stop, so exit `stopping -> idle` by that DATA
|
||||
// (I4). The epoch filter below must NOT drop it: STOP_REQUESTED bumped the epoch,
|
||||
// but the finish carries the PRE-stop generation (the runtime stamps it with the
|
||||
// stream's start epoch), so I1 would otherwise strand the machine in `stopping`
|
||||
// forever (no idle-cap covers `stopping`). The epoch filter stays in force for
|
||||
// `superseding` (a successor B owns) — that is the F1 supersede drop.
|
||||
if (m.phase.name === "stopping" && isFinishEvent(event)) {
|
||||
return to(m, { name: "idle" }, {
|
||||
// Reset ownership to local on this terminal transition (review #2): otherwise
|
||||
// an observer-stop leaves ownership 'observer' and hides "Send now" forever.
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
}
|
||||
|
||||
// I1: drop a stale outcome (an event issued under a superseded epoch).
|
||||
if ("epoch" in event && event.epoch !== undefined && event.epoch !== m.ctx.epoch) {
|
||||
return stay(m);
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
// ---- local turn ----------------------------------------------------
|
||||
case "SEND_LOCAL":
|
||||
// A local send owns the view: leave any recovery, become the local
|
||||
// streamer, disarm poll/reconnect. epoch++ so a late recovery outcome
|
||||
// from the previous phase is dropped.
|
||||
return command(
|
||||
m,
|
||||
{ name: "sending" },
|
||||
[{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||
{ ownership: "local", liveFollow: false },
|
||||
);
|
||||
|
||||
case "STREAM_INCOMPLETE":
|
||||
// An OBSERVER's attached stream ended incomplete (starved / torn) — follow
|
||||
// the run to terminal via the degraded poll.
|
||||
return to(m, { name: "polling", reason: event.reason }, {
|
||||
effects: [{ type: "armPoll", reason: event.reason }],
|
||||
});
|
||||
|
||||
case "STREAM_START": {
|
||||
// First frame arrived. Adopt the run-fact runId if present. sending ->
|
||||
// streaming; a reconnect/attach that just went live also lands here.
|
||||
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||
return to(m, { name: "streaming" }, {
|
||||
ctx: { runFact },
|
||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||
});
|
||||
}
|
||||
|
||||
case "FINISH_CLEAN":
|
||||
// A clean terminal outcome. The run is done — clear the run-fact and go
|
||||
// idle. (The queue flush is a component concern gated by ownership; the
|
||||
// FSM only models the phase.) Review #2: reset ownership to local so a
|
||||
// just-finished observer-attach turn re-exposes "Send now" for the queue.
|
||||
return to(m, { name: "idle" }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
|
||||
case "FINISH_ABORT":
|
||||
// A user Stop / intentional abort finished. If we were stopping, the
|
||||
// terminal data has now arrived (I4) — go idle. The run-fact is cleared.
|
||||
return to(m, { name: "idle" }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
|
||||
case "FINISH_DISCONNECT":
|
||||
// A LIVE SSE drop. Recovery depends on WHO we are (I2 + liveFollow):
|
||||
// - a mount-attach OBSERVER (a one-shot resume, NOT live-follow) that drops
|
||||
// -> the degraded poll drives the row to terminal from the DB.
|
||||
if (m.ctx.ownership === "observer" && !m.ctx.liveFollow) {
|
||||
return to(m, { name: "polling", reason: "disconnect-visible" }, {
|
||||
effects: [{ type: "armPoll", reason: "disconnect-visible" }],
|
||||
});
|
||||
}
|
||||
// - a LOCAL live turn (first drop) OR a live-follow re-attach (a SUBSEQUENT
|
||||
// drop) -> (re-)enter the reconnect ladder. #488 commit 3: allowed
|
||||
// REPEATEDLY — `liveFollow` is kept across a successful re-attach, so the
|
||||
// second break reconnects again instead of falling to silent poll.
|
||||
// #488 commit 2: gated on the RUN-FACT (or an existing live-follow), NOT on
|
||||
// the presence of an assistant message — a setup-phase break still recovers.
|
||||
// - visible content already on screen -> keep it, ALSO poll to terminal
|
||||
// (a full replay could clobber the fuller live tail);
|
||||
// - no visible content -> the reconnect ladder rebuilds it.
|
||||
if (m.ctx.runFact || m.ctx.liveFollow) {
|
||||
const effects: Effect[] = [
|
||||
{ type: "scheduleReconnect", attempt: 1, delayMs: reconnectDelayMs(1) },
|
||||
];
|
||||
if (event.hasVisibleContent) effects.push({ type: "armPoll", reason: "disconnect-visible" });
|
||||
return command(m, { name: "reconnecting", attempt: 1, failed: false }, effects, {
|
||||
ownership: "observer",
|
||||
liveFollow: true,
|
||||
});
|
||||
}
|
||||
// No run to recover: a plain disconnect. Surface the terminal notice.
|
||||
return to(m, { name: "idle" }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
});
|
||||
|
||||
case "FINISH_ERROR":
|
||||
return to(m, { name: "error", kind: event.kind }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
|
||||
// ---- mount attach (resume) ----------------------------------------
|
||||
case "ATTACH_START":
|
||||
// A reopened tab attaches to a still-running run: observer ownership.
|
||||
// #488 F2: ONLY from idle. The mount `getRun` round-trip resolves async, and
|
||||
// a local send may have started meanwhile (phase `sending`, ownership local);
|
||||
// a late ATTACH_START must NOT hijack that local turn into an observer-attach
|
||||
// (queue would stop flushing, "Send now" would hide). Guarding in the reducer
|
||||
// covers every dispatch source.
|
||||
if (m.phase.name !== "idle") return stay(m);
|
||||
return command(m, { name: "attaching" }, [{ type: "resumeStream" }], {
|
||||
ownership: "observer",
|
||||
runFact: event.runId ? { runId: event.runId } : m.ctx.runFact,
|
||||
});
|
||||
|
||||
case "ATTACH_LIVE":
|
||||
// The attach GET returned a live 2xx stream — follow it as an observer.
|
||||
// Review #1: guard by SOURCE phase. The epoch filter alone is not enough — a
|
||||
// POLL_TERMINAL uses to() (no epoch bump) and does not abort the in-flight
|
||||
// GET, so a slow 2xx landing after the machine already left `attaching` (e.g.
|
||||
// the armed poll saw the terminal row -> idle) would resurrect a settled run
|
||||
// into a phantom `streaming`. Only enter streaming FROM `attaching`.
|
||||
if (m.phase.name !== "attaching") return stay(m);
|
||||
return to(m, { name: "streaming" });
|
||||
|
||||
case "ATTACH_NONE":
|
||||
// 204 / non-2xx / throw: nothing live to attach. Arm the degraded poll to
|
||||
// follow the run to terminal from the DB. This is a soft-negative run-fact
|
||||
// (204 on a non-stripped path is authoritative-negative; the runtime may
|
||||
// pass a RUN_FACT null separately). Keep the run-fact as-is here.
|
||||
// Review #1: guard by source phase for consistency (a late outcome after the
|
||||
// machine already left `attaching` must not re-arm a poll).
|
||||
if (m.phase.name !== "attaching") return stay(m);
|
||||
return to(m, { name: "polling", reason: "attach-none" }, {
|
||||
effects: [{ type: "armPoll", reason: "attach-none" }],
|
||||
});
|
||||
|
||||
// ---- reconnect after a live disconnect ----------------------------
|
||||
case "RECONNECT_ATTEMPT":
|
||||
// A scheduled backoff fired — fire the attach GET. epoch++ so the previous
|
||||
// attempt's late outcome cannot drive this one.
|
||||
if (m.phase.name !== "reconnecting") return stay(m);
|
||||
return command(
|
||||
m,
|
||||
{ name: "reconnecting", attempt: event.attempt, failed: false },
|
||||
[{ type: "resumeStream" }],
|
||||
);
|
||||
|
||||
case "RECONNECT_ATTACHED":
|
||||
// #488 commit 3: a live re-attach succeeded. Reset to streaming — the
|
||||
// attempt counter is dropped, so a LATER disconnect can start a fresh
|
||||
// ladder from attempt 1 (the old one-shot `!wasResumed` gate forbade a
|
||||
// second cycle, sending the second break to silent poll).
|
||||
// Review #1: guard by SOURCE phase. The armed degraded poll can reach the
|
||||
// terminal row (POLL_TERMINAL -> idle, via to(), NO epoch bump, GET not
|
||||
// aborted) BEFORE a slow reconnect GET returns 2xx; without this guard that
|
||||
// late RECONNECT_ATTACHED (same epoch) would resurrect a settled run into a
|
||||
// phantom `streaming`. Only re-enter streaming FROM `reconnecting`.
|
||||
if (m.phase.name !== "reconnecting") return stay(m);
|
||||
return to(m, { name: "streaming" }, {
|
||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||
});
|
||||
|
||||
case "RECONNECT_NONE": {
|
||||
// 204 / error during a reconnect attempt. Arm the degraded poll as the
|
||||
// belt-and-suspenders fallback, then either back off to the next attempt
|
||||
// or, at the cap, surface the manual Retry ("failed").
|
||||
if (m.phase.name !== "reconnecting") return stay(m);
|
||||
const attempt = m.phase.attempt;
|
||||
if (attempt < RECONNECT_MAX_ATTEMPTS) {
|
||||
return command(
|
||||
m,
|
||||
{ name: "reconnecting", attempt: attempt + 1, failed: false },
|
||||
[
|
||||
{ type: "armPoll", reason: "attach-none" },
|
||||
{ type: "scheduleReconnect", attempt: attempt + 1, delayMs: reconnectDelayMs(attempt + 1) },
|
||||
],
|
||||
);
|
||||
}
|
||||
return to(m, { name: "reconnecting", attempt, failed: true }, {
|
||||
effects: [{ type: "armPoll", reason: "reconnect-exhausted" }],
|
||||
});
|
||||
}
|
||||
|
||||
case "RETRY":
|
||||
// Manual Retry from the "failed" reconnect banner OR the stalled banner.
|
||||
if (m.phase.name === "reconnecting" && m.phase.failed) {
|
||||
return command(
|
||||
m,
|
||||
{ name: "reconnecting", attempt: 1, failed: false },
|
||||
[{ type: "resumeStream" }],
|
||||
);
|
||||
}
|
||||
if (m.phase.name === "stalled") {
|
||||
// Re-arm the poll to try to catch the run up again.
|
||||
return command(m, { name: "polling", reason: "attach-none" }, [
|
||||
{ type: "armPoll", reason: "attach-none" },
|
||||
]);
|
||||
}
|
||||
return stay(m);
|
||||
|
||||
// ---- degraded poll -------------------------------------------------
|
||||
case "POLL_TERMINAL":
|
||||
// The run reached a terminal row via the poll (or the reconcile merge). Go
|
||||
// idle and disarm everything (I4: this is a DATA-driven exit, incl. exit
|
||||
// from `stopping`). Review #2: reset ownership to local.
|
||||
return to(m, { name: "idle" }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
|
||||
case "POLL_IDLE_CAP":
|
||||
// Review #4: `stopping` also arms the poll (STOP_REQUESTED) but has NO other
|
||||
// backstop — an observer-stop with no SDK stream to fire onFinish, whose
|
||||
// server stop never drives the run terminal, would poll the DB forever. Give
|
||||
// it a bounded exit: cap -> idle + disarm (NOT `stalled`; Stop was already
|
||||
// pressed, so there is nothing for the user to retry).
|
||||
if (m.phase.name === "stopping") {
|
||||
return to(m, { name: "idle" }, {
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
}
|
||||
// #488 commit 4a: the poll hit the inactivity cap. Instead of going SILENT
|
||||
// (the old "forever half-done answer"), surface a stalled banner + Retry.
|
||||
if (m.phase.name !== "polling" && m.phase.name !== "reconnecting") return stay(m);
|
||||
return to(m, { name: "stalled" }, {
|
||||
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||
});
|
||||
|
||||
// ---- run-fact ------------------------------------------------------
|
||||
case "RUN_FACT": {
|
||||
const runFact = event.runFact;
|
||||
// A fresh NEGATIVE fact (no active run) cancels recovery immediately (I3):
|
||||
// there is nothing to reconnect to / poll for.
|
||||
if (!runFact) {
|
||||
if (
|
||||
m.phase.name === "reconnecting" ||
|
||||
m.phase.name === "attaching" ||
|
||||
m.phase.name === "polling" ||
|
||||
m.phase.name === "stopping"
|
||||
) {
|
||||
return to(m, { name: "idle" }, {
|
||||
// Review #2: reset ownership to local on this terminal transition.
|
||||
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||
});
|
||||
}
|
||||
return to(m, m.phase, { ctx: { runFact: null } });
|
||||
}
|
||||
// A positive fact just updates the context (pessimism toward an attempt: a
|
||||
// stale-but-positive fact permits entering recovery; a 204 will cut it).
|
||||
return to(m, m.phase, { ctx: { runFact } });
|
||||
}
|
||||
|
||||
// ---- stop ----------------------------------------------------------
|
||||
case "STOP_REQUESTED":
|
||||
// Authoritative stop of a detached run. Enter `stopping` and fire stopRun +
|
||||
// abort the local/attach reader. ALSO arm the poll so the terminal row is
|
||||
// observed — the exit is by DATA (I4: a terminal row / negative run-fact),
|
||||
// never by the stopRun HTTP response (which returns after abort, before
|
||||
// finalization). For a local turn the aborted stream's onFinish (ANY finish)
|
||||
// is HONORED in `stopping` at the top of reduce() — regardless of generation
|
||||
// — and exits to idle; the armed poll is the fallback for an observer stop
|
||||
// with no local onFinish.
|
||||
return command(
|
||||
m,
|
||||
{ name: "stopping" },
|
||||
[
|
||||
{ type: "stopRun" },
|
||||
{ type: "abortAttach" },
|
||||
{ type: "cancelReconnect" },
|
||||
{ type: "armPoll", reason: "attach-none" },
|
||||
],
|
||||
);
|
||||
|
||||
// ---- supersede (CAS) ----------------------------------------------
|
||||
case "SUPERSEDE_REQUESTED":
|
||||
// "Interrupt and send now": CAS POST /stream { supersede }. epoch++ so a
|
||||
// late outcome of the interrupted run is dropped.
|
||||
return command(
|
||||
m,
|
||||
{ name: "superseding" },
|
||||
[{ type: "supersede", targetRunId: event.targetRunId }, { type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||
);
|
||||
|
||||
case "SUPERSEDE_READY": {
|
||||
// CAS succeeded (old run stopped/settled, slot taken, new run begun). We
|
||||
// are now the local streamer of the NEW run. Adopt its runId if provided.
|
||||
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||
return to(m, { name: "streaming" }, {
|
||||
ctx: { ownership: "local", runFact, liveFollow: false },
|
||||
});
|
||||
}
|
||||
|
||||
case "SUPERSEDE_MISMATCH":
|
||||
// The active run moved between the click and the CAS. Per the spec: verify
|
||||
// via /run rather than blindly banner — the mismatch may be our own already-
|
||||
// superseded run. Surface a classified error AND fire a run-fact verify.
|
||||
return to(m, { name: "error", kind: "supersede-mismatch" }, {
|
||||
ctx: { runFact: event.currentRunId ? { runId: event.currentRunId } : m.ctx.runFact },
|
||||
effects: [{ type: "postRun", reason: "verify" }],
|
||||
});
|
||||
|
||||
case "SUPERSEDE_TIMEOUT":
|
||||
// The old run did not settle within W. Nothing persisted; the composer keeps
|
||||
// its text. Classified error, NO auto-retry (the old client retry ladder is
|
||||
// removed in #488 commit 5).
|
||||
return to(m, { name: "error", kind: "supersede-timeout" });
|
||||
|
||||
case "SUPERSEDE_INVALID":
|
||||
return to(m, { name: "error", kind: "supersede-invalid" });
|
||||
|
||||
case "RUN_ALREADY_ACTIVE":
|
||||
// A plain POST hit the one-active-run gate. NO auto-retry — the composer
|
||||
// offers "interrupt and send" (supersede) instead. #497/S4: adopt the
|
||||
// server's activeRunId as the run-fact so that supersede can TARGET the
|
||||
// (possibly foreign-tab) active run via the CAS, rather than a blind
|
||||
// promote+abort that just 409s again. A stale/absent id keeps the prior fact.
|
||||
return to(m, { name: "error", kind: "run-already-active" }, {
|
||||
ctx: { runFact: event.activeRunId ? { runId: event.activeRunId } : m.ctx.runFact },
|
||||
});
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------
|
||||
case "DISPOSE":
|
||||
// Unmount: abort in-flight controllers, drop timers, and bump the epoch so
|
||||
// NO late callback can drive this (now dead) machine (I5).
|
||||
return command(
|
||||
m,
|
||||
{ name: "idle" },
|
||||
[
|
||||
{ type: "abortAttach" },
|
||||
{ type: "cancelReconnect" },
|
||||
{ type: "disarmPoll" },
|
||||
],
|
||||
{ liveFollow: false },
|
||||
);
|
||||
|
||||
default: {
|
||||
// Exhaustiveness guard.
|
||||
const _never: never = event;
|
||||
void _never;
|
||||
return stay(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,41 +210,14 @@ export interface IAiChatMessageRow {
|
||||
// renders a "stopped" marker on interrupted turns.
|
||||
finishReason?: string;
|
||||
} | null;
|
||||
// Persisted lifecycle status of the row's turn, carried on the wire by
|
||||
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
|
||||
// the resume machinery to decide whether a tail is a live stream to attach to
|
||||
// or a settled row that must not be replayed).
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||
*/
|
||||
export interface IAiChatRun {
|
||||
id: string;
|
||||
chatId: string;
|
||||
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||
error?: string | null;
|
||||
stepCount?: number;
|
||||
assistantMessageId?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||
* assistant message it materializes (the partial/final output, projected from the
|
||||
* persisted rows). Both are `null` when the chat has never had a run.
|
||||
*/
|
||||
export interface IAiChatRunResponse {
|
||||
run: IAiChatRun | null;
|
||||
message: IAiChatMessageRow | null;
|
||||
}
|
||||
|
||||
export interface IAiChatListParams extends QueryParams {}
|
||||
|
||||
export interface IAiChatMessagesParams {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveAdoptedChatId,
|
||||
newlyAddedChatIds,
|
||||
extractServerChatId,
|
||||
extractRunId,
|
||||
} from "./adopt-chat-id";
|
||||
|
||||
describe("resolveAdoptedChatId", () => {
|
||||
@@ -70,3 +71,17 @@ describe("extractServerChatId", () => {
|
||||
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRunId", () => {
|
||||
it("reads a string runId from the start metadata", () => {
|
||||
expect(extractRunId({ metadata: { runId: "run-1" } })).toBe("run-1");
|
||||
});
|
||||
it("returns undefined when runId is absent", () => {
|
||||
expect(extractRunId({ metadata: { chatId: "c" } })).toBeUndefined();
|
||||
expect(extractRunId({})).toBeUndefined();
|
||||
expect(extractRunId(undefined)).toBeUndefined();
|
||||
});
|
||||
it("returns undefined for a non-string runId", () => {
|
||||
expect(extractRunId({ metadata: { runId: 7 } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,20 @@ export function extractServerChatId(
|
||||
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #488: read the authoritative RUN id off a streaming assistant message. The
|
||||
* server attaches it as `message.metadata.runId` on the `start` part when a run
|
||||
* wraps the turn (see server `chatStreamMetadata`, #184/#487). This is the live
|
||||
* run-fact update the client FSM adopts (mirrors `extractServerChatId`). Returns
|
||||
* it only when it is a string; undefined otherwise.
|
||||
*/
|
||||
export function extractRunId(
|
||||
message: { metadata?: unknown } | undefined,
|
||||
): string | undefined {
|
||||
const m = message?.metadata as { runId?: string } | undefined;
|
||||
return typeof m?.runId === "string" ? m.runId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||
|
||||
@@ -33,29 +33,44 @@ describe("collapseBlankLines", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
||||
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
||||
describe("collapseBlankLines + renderChatMarkdown (canonical converter)", () => {
|
||||
// Chat markdown now renders through @docmost/prosemirror-markdown (issue #347):
|
||||
// the SAME converter the editor/import use. Its list items are schema-shaped —
|
||||
// each <li>'s content is wrapped in a <p> (listItem content is `paragraph+`) —
|
||||
// so the HTML always carries `<li><p>…</p></li>` regardless of blank-line
|
||||
// looseness in the source (the converter has no tight/loose distinction). The
|
||||
// visual tightness that `collapseBlankLines` used to buy is now provided by
|
||||
// CSS (`.markdown li p { margin: 0 }`), not the HTML shape.
|
||||
it("renders a blank-line-separated bullet list as a real <ul> list", () => {
|
||||
const loose =
|
||||
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
||||
expect(html).toContain("<li>item one</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
||||
// Clean, un-namespaced HTML (DOMSerializer, not XMLSerializer) — no xmlns.
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).not.toMatch(/<ul[^>]*xmlns/);
|
||||
// The item text is present (inside the schema's <li><p> wrapper).
|
||||
expect(html).toContain("item one");
|
||||
// The intro paragraph renders as its own paragraph before the list.
|
||||
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||
});
|
||||
|
||||
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
||||
it("renders an ordered list (1. 2.) as a real <ol> list", () => {
|
||||
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<li>first</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
expect(html).not.toMatch(/<ol[^>]*xmlns/);
|
||||
expect(html).toContain("first");
|
||||
expect(html).toContain("second");
|
||||
});
|
||||
|
||||
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
||||
it("wraps list-item content in <p> (schema shape; tightness is CSS)", () => {
|
||||
// The canonical converter always wraps a list item's content in a paragraph,
|
||||
// whether or not the source had blank lines between items.
|
||||
const loose = "- a\n\n- b";
|
||||
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||
// And a "tight" source produces the identical wrapping (no distinction).
|
||||
expect(renderChatMarkdown(collapseBlankLines(loose), {})).toContain(
|
||||
"<li><p>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,89 @@ describe("describeChatError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
|
||||
// The FULL real body the server writes for a beginRun failure: a
|
||||
// ServiceUnavailableException(object) whose response is serialized verbatim
|
||||
// onto the raw socket, self-describing statusCode 503 + the run-start code.
|
||||
const body =
|
||||
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Could not start the run",
|
||||
detail:
|
||||
"The agent run could not be started. This is usually temporary — please try again.",
|
||||
});
|
||||
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
|
||||
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
|
||||
// never mislabeled "AI provider not configured".
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"AI provider not configured",
|
||||
);
|
||||
});
|
||||
|
||||
// #488 commit 5: the #487 concurrency-gate / supersede 409s. FULL real bodies:
|
||||
// a ConflictException(object) whose response is serialized verbatim, carrying a
|
||||
// `code` and statusCode 409. Each must classify to a human text, not raw JSON.
|
||||
it("classifies A_RUN_ALREADY_ACTIVE (409) as already-answering, not raw JSON", () => {
|
||||
const body =
|
||||
'{"message":"A run is already active for this chat","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||
expect(describeChatError(body, t).title).toBe(
|
||||
"The agent is already answering",
|
||||
);
|
||||
// Never leaks the raw code as the detail.
|
||||
expect(describeChatError(body, t).detail).not.toContain("A_RUN_ALREADY_ACTIVE");
|
||||
});
|
||||
|
||||
it("classifies SUPERSEDE_TARGET_MISMATCH (409) as run-changed", () => {
|
||||
// Real server body shape: the current run id is `activeRunId` (NOT `runId`) —
|
||||
// see ai-chat.controller.ts. describeChatError classifies off `code` only.
|
||||
const body =
|
||||
'{"message":"active run does not match the supersede target","code":"SUPERSEDE_TARGET_MISMATCH","activeRunId":"run-x","statusCode":409}';
|
||||
expect(describeChatError(body, t).title).toBe(
|
||||
"Couldn't interrupt — the run changed",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies SUPERSEDE_TIMEOUT (409) as couldn't-interrupt-in-time", () => {
|
||||
const body =
|
||||
'{"message":"the run did not settle within the supersede window","code":"SUPERSEDE_TIMEOUT","statusCode":409}';
|
||||
expect(describeChatError(body, t).title).toBe("Couldn't interrupt in time");
|
||||
});
|
||||
|
||||
it("classifies SUPERSEDE_INVALID (409) as couldn't-interrupt-that-run", () => {
|
||||
const body =
|
||||
'{"message":"supervise requires chatId","code":"SUPERSEDE_INVALID","statusCode":409}';
|
||||
expect(describeChatError(body, t).title).toBe(
|
||||
"Couldn't interrupt that run",
|
||||
);
|
||||
});
|
||||
|
||||
it("ORDER GUARD: A_RUN_ALREADY_ACTIVE wins over any generic status branch", () => {
|
||||
// Even though the body could superficially look 4xx-ish, the code branch runs
|
||||
// first, so it is never mislabeled by a generic status heading.
|
||||
const body =
|
||||
'{"message":"conflict","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||
const view = describeChatError(body, t);
|
||||
expect(view.title).not.toBe("Something went wrong");
|
||||
expect(view.title).not.toBe("AI provider not configured");
|
||||
});
|
||||
|
||||
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||
// The exact reason the server persists in metadata.error on a degeneration
|
||||
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||
// so live and refetch never disagree.
|
||||
const view = describeChatError(
|
||||
"Output degeneration detected (repeated token loop)",
|
||||
t,
|
||||
);
|
||||
expect(view.title).toBe("Response stopped.");
|
||||
expect(view.detail).toBe(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
);
|
||||
// Regression guard: it must NOT fall through to the generic heading.
|
||||
expect(view.title).not.toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
|
||||
@@ -24,6 +24,75 @@ export function describeChatError(
|
||||
): ChatErrorView {
|
||||
const msg = message ?? "";
|
||||
|
||||
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
|
||||
// whose body carries this code is a TEMPORARY server-side failure while
|
||||
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
|
||||
// be matched STRICTLY BEFORE the generic 503 branch below, which would
|
||||
// otherwise mislabel it "The AI provider is not configured" and tell the user
|
||||
// to call an admin instead of just retrying.
|
||||
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
|
||||
return {
|
||||
title: t("Could not start the run"),
|
||||
detail: t(
|
||||
"The agent run could not be started. This is usually temporary — please try again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// #488 commit 5: the #487 concurrency-gate / supersede 409s. These arrive as a
|
||||
// ConflictException(object) body carrying a `code` (and statusCode 409). They
|
||||
// MUST be classified by `code` STRICTLY BEFORE any generic status branch, or the
|
||||
// user sees the raw JSON `{"code":"A_RUN_ALREADY_ACTIVE",…}`. The code strings
|
||||
// are the real #487 server contract (ai-chat.controller.ts) — do not invent.
|
||||
if (/"code"\s*:\s*"A_RUN_ALREADY_ACTIVE"/.test(msg)) {
|
||||
return {
|
||||
title: t("The agent is already answering"),
|
||||
detail: t(
|
||||
"This chat already has a run in progress. Wait for it to finish, or interrupt it and send now.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (/"code"\s*:\s*"SUPERSEDE_TARGET_MISMATCH"/.test(msg)) {
|
||||
return {
|
||||
title: t("Couldn't interrupt — the run changed"),
|
||||
detail: t(
|
||||
"The run you tried to interrupt is no longer the active one. Check the latest answer and try again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (/"code"\s*:\s*"SUPERSEDE_TIMEOUT"/.test(msg)) {
|
||||
return {
|
||||
title: t("Couldn't interrupt in time"),
|
||||
detail: t(
|
||||
"The previous run didn't stop in time. Nothing was sent — try sending again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (/"code"\s*:\s*"SUPERSEDE_INVALID"/.test(msg)) {
|
||||
return {
|
||||
title: t("Couldn't interrupt that run"),
|
||||
detail: t(
|
||||
"The run to interrupt doesn't belong to this chat. Reload and try again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||
// later refetch show two different texts for one event. The detail explains the
|
||||
// loop-guard cause without contradicting the shared heading.
|
||||
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||
return {
|
||||
title: t("Response stopped."),
|
||||
detail: t(
|
||||
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import {
|
||||
markdownToProseMirrorSync,
|
||||
docmostExtensions,
|
||||
} from "@docmost/prosemirror-markdown/browser";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { Node as PMNode, DOMSerializer } from "@tiptap/pm/model";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
// The Docmost editor schema, built once. Chat markdown is rendered through the
|
||||
// SAME schema the editor/import use (issue #347), so chat output matches how the
|
||||
// page would render the same markdown.
|
||||
const chatSchema = getSchema(docmostExtensions);
|
||||
|
||||
/**
|
||||
* Markdown -> HTML for chat display, via the canonical converter. We serialize
|
||||
* the ProseMirror doc with `DOMSerializer` into a real element and read its
|
||||
* `innerHTML` (rather than `@tiptap/html`'s `generateHTML`, whose browser path
|
||||
* uses `XMLSerializer` and stamps a `xmlns` on every block) so the markup is
|
||||
* clean HTML. `li > p` wrapping is inherent to the schema (listItem content is
|
||||
* `paragraph+`); the chat CSS zeroes those paragraph margins so lists still
|
||||
* render tight.
|
||||
*/
|
||||
function markdownToChatHtml(markdown: string): string {
|
||||
const doc = markdownToProseMirrorSync(markdown);
|
||||
const node = PMNode.fromJSON(chatSchema, doc);
|
||||
const div = document.createElement("div");
|
||||
DOMSerializer.fromSchema(chatSchema).serializeFragment(
|
||||
node.content,
|
||||
{ document },
|
||||
div,
|
||||
);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export interface RenderChatMarkdownOptions {
|
||||
/**
|
||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||
@@ -63,22 +94,32 @@ function neutralizeInternalLinksHook(node: Element): void {
|
||||
|
||||
/**
|
||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||
* chat output matches the editor's markdown flavor, then sanitize with
|
||||
* DOMPurify — LLM output is untrusted, so it must never reach the DOM unsanitized.
|
||||
* canonical converter (issue #347): markdown -> ProseMirror JSON (the SAME
|
||||
* `markdownToProseMirrorSync` the editor paste/import path uses, so chat output
|
||||
* matches the editor's markdown flavor) -> HTML via `markdownToChatHtml`
|
||||
* (DOMSerializer), then sanitize with DOMPurify — LLM output is untrusted, so it
|
||||
* must never reach the DOM unsanitized.
|
||||
*
|
||||
* `markdownToHtml` can return `string | Promise<string>` (it has async marked
|
||||
* extensions registered). In practice plain chat markdown resolves
|
||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||
* for that branch (the caller renders the raw text fallback instead).
|
||||
* Stays SYNCHRONOUS: both callers render inside React (a memo and a useMemo),
|
||||
* so the whole pipeline must resolve without awaiting. The converter's sync
|
||||
* entry makes that possible; on any conversion error we return "" so the caller
|
||||
* falls back to raw text (the same fallback the old Promise-guard produced).
|
||||
*/
|
||||
export function renderChatMarkdown(
|
||||
markdown: string,
|
||||
options: RenderChatMarkdownOptions = {},
|
||||
): string {
|
||||
if (!markdown) return "";
|
||||
const html = markdownToHtml(markdown);
|
||||
if (typeof html !== "string") return "";
|
||||
let html: string;
|
||||
try {
|
||||
// markdown -> canonical PM JSON -> HTML (native DOMParser in the browser;
|
||||
// jsdom is never bundled — see @docmost/prosemirror-markdown/browser).
|
||||
html = markdownToChatHtml(markdown);
|
||||
} catch {
|
||||
// Malformed/unsupported markdown must not crash the chat render; fall back
|
||||
// to raw text (empty return -> caller shows the plain-text branch).
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!options.neutralizeInternalLinks) {
|
||||
// Internal chat: unchanged behavior, no hook registered.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
mergeById,
|
||||
} from "./resume-helpers.ts";
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isStreamingTail", () => {
|
||||
it("is true when the last row is a streaming assistant row", () => {
|
||||
expect(
|
||||
isStreamingTail([row("u1", "user"), row("a1", "assistant", "streaming")]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a settled assistant tail", () => {
|
||||
expect(isStreamingTail([row("a1", "assistant", "succeeded")])).toBe(false);
|
||||
expect(isStreamingTail([row("a1", "assistant")])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isStreamingTail([row("u1", "user")])).toBe(false);
|
||||
expect(isStreamingTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSettledAssistantTail", () => {
|
||||
it("is true for an assistant tail whose status is not streaming", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "succeeded")])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant")])).toBe(true);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "aborted")])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false for a streaming assistant tail", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "streaming")])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isSettledAssistantTail([row("u1", "user")])).toBe(false);
|
||||
expect(isSettledAssistantTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedRows", () => {
|
||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||
|
||||
it("returns the rows unchanged when not stripping", () => {
|
||||
expect(seedRows(rows, false)).toBe(rows);
|
||||
});
|
||||
|
||||
it("drops the last row when stripping", () => {
|
||||
const seeded = seedRows(rows, true);
|
||||
expect(seeded).toHaveLength(1);
|
||||
expect(seeded[0].id).toBe("u1");
|
||||
});
|
||||
|
||||
it("returns an empty list when stripping a single-row list", () => {
|
||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeById", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const incoming = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the incoming message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const incoming = makeMsg("a1", "first token");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeById(prev, null)).toBe(prev);
|
||||
expect(mergeById(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Pure decisions for the resumable-SSE resume machinery (#184 phase 1.5). A tab
|
||||
* that reopens a chat whose agent run is still going attaches to the server's
|
||||
* run-stream registry (replay + live tail) instead of polling snapshots; these
|
||||
* small predicates decide WHICH tail is safe to resume and how to seed the store,
|
||||
* extracted so they can be unit-tested in isolation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||
*/
|
||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status === "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* A SETTLED assistant tail: the last row is an assistant row whose status is
|
||||
* anything OTHER than 'streaming'. A settled assistant tail must NEVER resume —
|
||||
* replaying a finished run into a store that already holds its message duplicates
|
||||
* parts (`text-start` always pushes a new part).
|
||||
*/
|
||||
export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status !== "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||
* without duplicating parts).
|
||||
*/
|
||||
export function seedRows(
|
||||
rows: IAiChatMessageRow[],
|
||||
strip: boolean,
|
||||
): IAiChatMessageRow[] {
|
||||
return strip ? rows.slice(0, -1) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an assistant message into the rendered list by id: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeById(
|
||||
messages: UIMessage[],
|
||||
incoming: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!incoming) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === incoming.id);
|
||||
if (idx === -1) return [...messages, incoming];
|
||||
const next = messages.slice();
|
||||
next[idx] = incoming;
|
||||
return next;
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
RUN_POLL_INTERVAL_MS,
|
||||
isRunActive,
|
||||
runPollInterval,
|
||||
shouldObserveRun,
|
||||
shouldClearStoppingLatch,
|
||||
shouldClearLatchOnQueryError,
|
||||
mergeObservedMessage,
|
||||
} from "./run-polling.ts";
|
||||
|
||||
function makeRun(status: string): IAiChatRun {
|
||||
return { id: "run-1", chatId: "c1", status };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isRunActive", () => {
|
||||
it("treats pending and running as active", () => {
|
||||
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats terminal / unknown / nullish as not active", () => {
|
||||
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||
expect(isRunActive(null)).toBe(false);
|
||||
expect(isRunActive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||
it("returns 2000ms while the run is pending/running", () => {
|
||||
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||
});
|
||||
|
||||
it("returns false (stop polling) once the run is terminal", () => {
|
||||
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false (no polling) when there is no run", () => {
|
||||
expect(runPollInterval(null)).toBe(false);
|
||||
expect(runPollInterval(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT observe when there is no run", () => {
|
||||
expect(shouldObserveRun(null, false)).toBe(false);
|
||||
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
||||
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
||||
// observer (not streaming), and the CURRENT run is terminal.
|
||||
it("clears only when stopping, observing, and the run is terminal", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("succeeded"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("failed"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
||||
// re-open the flash for the current turn the moment we switch to observer role.
|
||||
// A predicate lacking the streaming gate would (wrongly) return true here.
|
||||
it("does NOT clear while this tab is the local streamer", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("succeeded"),
|
||||
isLocalStreaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// The detached run keeps growing after a local abort — while it is still
|
||||
// active the latch MUST hold so the observer merge stays suppressed.
|
||||
it("does NOT clear while the run is still active", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("running"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: makeRun("pending"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
||||
// observed `run` is null until the current turn's run is fetched fresh. A null
|
||||
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
||||
// only against the current turn's own terminal run once observed.
|
||||
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: null,
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: true,
|
||||
run: undefined,
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear when no stop was requested", () => {
|
||||
expect(
|
||||
shouldClearStoppingLatch({
|
||||
stoppingRun: false,
|
||||
run: makeRun("aborted"),
|
||||
isLocalStreaming: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
||||
// This guards the REAL anti-flash decision the component's run-query-error
|
||||
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
||||
// not a copy — so the test is non-vacuous vs the live code).
|
||||
|
||||
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
||||
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
||||
// runQueryFailed can be true while the held run is still pending/running.
|
||||
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
||||
// true — so this assertion fails on the buggy code (non-vacuous).
|
||||
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("running"),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("pending"),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
||||
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
||||
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
||||
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
||||
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
||||
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
||||
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: makeRun("aborted"),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT clear without an actual query error", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: false,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear while this tab is the local streamer", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: true,
|
||||
isLocalStreaming: true,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear when no stop was requested", () => {
|
||||
expect(
|
||||
shouldClearLatchOnQueryError({
|
||||
stoppingRun: false,
|
||||
isLocalStreaming: false,
|
||||
runQueryFailed: true,
|
||||
run: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeObservedMessage", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the observed message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const observed = makeMsg("a1", "first token");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||
* assistant message into the rendered thread. These are the small pure decisions
|
||||
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||
*/
|
||||
|
||||
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||
// so a stale/odd value never polls forever).
|
||||
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||
|
||||
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||
* reaching a terminal status — no separate timeout cap is needed.
|
||||
*/
|
||||
export function runPollInterval(
|
||||
run: IAiChatRun | null | undefined,
|
||||
): number | false {
|
||||
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||
* stopped separately by {@link runPollInterval}.
|
||||
*/
|
||||
export function shouldObserveRun(
|
||||
run: IAiChatRun | null | undefined,
|
||||
localStreaming: boolean,
|
||||
): boolean {
|
||||
return !!run && !localStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the "stopping" latch — which suppresses the observer re-stream flash
|
||||
* after the user pressed Stop — be RELEASED now? All three must hold:
|
||||
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
|
||||
* streamer the run query is disabled, so the observed `run` is not the run we
|
||||
* are following — releasing the latch then would re-open the flash for the
|
||||
* current turn the instant we switch to observer role;
|
||||
* - the observed `run` EXISTS and has reached a TERMINAL status.
|
||||
*
|
||||
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
|
||||
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
|
||||
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
|
||||
* therefore HOLDS the latch, so it can only ever clear against the current turn's
|
||||
* OWN terminal run — never a stale cached one. (The cache removal itself is
|
||||
* integration-level in AiChatWindow; this predicate encodes the decision given
|
||||
* whatever run is currently observed, and a stale terminal run is
|
||||
* indistinguishable from a current terminal run at the predicate level — hence
|
||||
* the cache removal is what guarantees only the current run is ever passed here.)
|
||||
*/
|
||||
export function shouldClearStoppingLatch(args: {
|
||||
stoppingRun: boolean;
|
||||
run: IAiChatRun | null | undefined;
|
||||
isLocalStreaming: boolean;
|
||||
}): boolean {
|
||||
const { stoppingRun, run, isLocalStreaming } = args;
|
||||
if (!stoppingRun || isLocalStreaming) return false;
|
||||
return !!run && !isRunActive(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
|
||||
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
|
||||
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
|
||||
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
|
||||
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
|
||||
* its status-keyed refetchInterval is off, and nothing would ever observe a
|
||||
* terminal run — freezing the view with the observer merge suppressed. This
|
||||
* safety-net cures ONLY that genuine permanent-null-freeze.
|
||||
*
|
||||
* All four must hold:
|
||||
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
|
||||
* {@link shouldClearStoppingLatch});
|
||||
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
|
||||
* retry:false — isError);
|
||||
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
|
||||
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
|
||||
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
|
||||
* ACTIVE run (a single transient GET-run failure in the window between Stop and
|
||||
* settle). Without this gate a transient error would release the latch early —
|
||||
* re-opening the observer merge and flashing the growing detached run over the
|
||||
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
|
||||
* only ever cure the permanent-null-freeze (`run === null`, so
|
||||
* `isRunActive(null)` is false), never release against an active run.
|
||||
*
|
||||
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
|
||||
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
|
||||
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
|
||||
*
|
||||
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
|
||||
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
|
||||
* polling when the data is empty — so after we clear on null+error there is no
|
||||
* subsequent auto-poll that could return a still-active detached run and re-open the
|
||||
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
|
||||
* error, this null-branch clear would re-open the F7 flash through the null path.
|
||||
* Do not change the run query's refetchInterval without re-checking this path.
|
||||
*/
|
||||
export function shouldClearLatchOnQueryError(args: {
|
||||
stoppingRun: boolean;
|
||||
isLocalStreaming: boolean;
|
||||
runQueryFailed: boolean;
|
||||
run: IAiChatRun | null | undefined;
|
||||
}): boolean {
|
||||
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
|
||||
return (
|
||||
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an observed assistant message into the rendered list: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeObservedMessage(
|
||||
messages: UIMessage[],
|
||||
observed: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!observed) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||
if (idx === -1) return [...messages, observed];
|
||||
const next = messages.slice();
|
||||
next[idx] = observed;
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
@@ -77,6 +78,138 @@ describe("toolCitations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInputSummary", () => {
|
||||
it("returns the primary `query` string", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("summarizes a primary array field with a (+N) suffix", () => {
|
||||
// `urls` is an external MCP read_pages-style list; the first element plus a
|
||||
// count of the rest.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["a", "b", "c"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("a (+2)");
|
||||
});
|
||||
|
||||
it("omits the (+N) suffix for a single-element array", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["only"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("only");
|
||||
});
|
||||
|
||||
it("falls back to `title` for a page op with no query", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-createPage",
|
||||
state: "input-available",
|
||||
input: { pageId: "x", title: "My Page" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("My Page");
|
||||
});
|
||||
|
||||
it("prefers the earlier primary field when several are present", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
// `query` outranks `title` in PRIMARY_INPUT_FIELDS — the ordered list is
|
||||
// the contract, so a reordering must break this test.
|
||||
input: { query: "Q", title: "T" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("Q");
|
||||
});
|
||||
|
||||
it("does not clamp a value exactly at the 140-char limit", () => {
|
||||
const exact = "a".repeat(140);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: exact },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out).toBe(exact);
|
||||
expect(out.endsWith("…")).toBe(false);
|
||||
expect(out.length).toBe(140);
|
||||
});
|
||||
|
||||
it("clamps one char over the limit (141 -> 140 + ellipsis)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "a".repeat(141) },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBe(141);
|
||||
expect(out).toBe("a".repeat(140) + "…");
|
||||
});
|
||||
|
||||
it("clamps a long value to ~140 chars with an ellipsis", () => {
|
||||
const long = "a".repeat(300);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: long },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBeLessThanOrEqual(141);
|
||||
});
|
||||
|
||||
it("collapses newlines and repeated spaces to single spaces", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: " foo\n\n bar baz " },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("foo bar baz");
|
||||
});
|
||||
|
||||
it("returns undefined with no input", () => {
|
||||
expect(
|
||||
toolInputSummary({ type: "tool-x", state: "input-available" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: {},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: "just a string",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined while the input is still streaming (even with a full input)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-streaming",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
|
||||
@@ -97,6 +97,69 @@ function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Collapse runs of whitespace/newlines to a single space and trim. */
|
||||
function collapse(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Truncate to ~140 chars, appending an ellipsis when it overflows. */
|
||||
function clamp(s: string): string {
|
||||
const MAX = 140;
|
||||
return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority "primary" argument fields, in order. The first present one supplies
|
||||
* the summary. `urls` is included (external MCP `read_pages`-style tools take a
|
||||
* list of URLs) and is handled as an array; `url` covers the single-URL form.
|
||||
*/
|
||||
const PRIMARY_INPUT_FIELDS = [
|
||||
"query",
|
||||
"q",
|
||||
"searchQuery",
|
||||
"url",
|
||||
"urls",
|
||||
"title",
|
||||
"name",
|
||||
"text",
|
||||
"prompt",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the
|
||||
* search query), or undefined when no recognizable primary field is present.
|
||||
* Rendered under the tool label so tools without a friendly name (external MCP
|
||||
* tools like `Search_web_search`) still show WHAT was requested, not just a
|
||||
* generic "Ran tool {{name}}". The returned string is plain text and MUST be
|
||||
* rendered React-escaped (Mantine `<Text>`), never as markdown/HTML.
|
||||
*
|
||||
* Streaming gate: while `state === "input-streaming"` the `input` object grows
|
||||
* chunk by chunk but `messageSignature` deliberately does NOT track `input`, so
|
||||
* a live summary computed here would freeze at its first captured value and go
|
||||
* stale. We therefore return undefined until the state flips to
|
||||
* `input-available` (input finalized) — that state change IS tracked by the
|
||||
* signature, so the row re-renders and shows the complete summary. Do NOT add
|
||||
* `input` to `message-signature.ts` to work around this.
|
||||
*/
|
||||
export function toolInputSummary(part: ToolUiPart): string | undefined {
|
||||
if (part.state === "input-streaming") return undefined;
|
||||
if (!part.input || typeof part.input !== "object") return undefined;
|
||||
const input = part.input as Record<string, unknown>;
|
||||
|
||||
for (const field of PRIMARY_INPUT_FIELDS) {
|
||||
const value = input[field];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return clamp(collapse(value));
|
||||
}
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const first = collapse(String(value[0]));
|
||||
if (first.length === 0) continue;
|
||||
return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : ""));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||
* Only output-available parts (the tool returned) yield citations. Search
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
// case renders in isolation.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
usePageMetaQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
|
||||
@@ -22,7 +22,7 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -56,7 +56,7 @@ export function buildChildrenByParent(
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { atom } from "jotai";
|
||||
import { Editor } from "@tiptap/core";
|
||||
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||
// shell component that reads one of these atoms.
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #343 PART 1: skip getAttributes unless an audio node is active. The menu
|
||||
// only shows for an active audio node (shouldShow), so the null state while
|
||||
// inactive is never rendered — behavior unchanged.
|
||||
if (!ctx.editor.isActive("audio")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioAttrs = ctx.editor.getAttributes("audio");
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,8 +43,15 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #343 PART 1: skip the per-type isActive() probes unless a callout is
|
||||
// active. The menu only shows for an active callout (shouldShow), so the
|
||||
// null state while inactive is never rendered — behavior unchanged.
|
||||
if (!ctx.editor.isActive("callout")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
isCallout: ctx.editor.isActive("callout"),
|
||||
isCallout: true,
|
||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||
|
||||
@@ -22,6 +22,12 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// #343 PART 6: `isSelected` only drives the mermaid source's visibility (the
|
||||
// `hidden` prop below). For every non-mermaid code block it is never read,
|
||||
// so skip the per-block `selectionUpdate` listener entirely — otherwise N
|
||||
// code blocks each add a global listener + a setState on every caret move.
|
||||
if (language !== "mermaid") return;
|
||||
|
||||
const updateSelection = () => {
|
||||
const { state } = editor;
|
||||
const { from, to } = state.selection;
|
||||
@@ -32,11 +38,14 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
setIsSelected(isNodeSelected);
|
||||
};
|
||||
|
||||
// Initialize on attach so switching a block's language to "mermaid" reflects
|
||||
// the current selection immediately (the listener was not running before).
|
||||
updateSelection();
|
||||
editor.on("selectionUpdate", updateSelection);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updateSelection);
|
||||
};
|
||||
}, [editor, getPos(), node.nodeSize]);
|
||||
}, [editor, getPos(), node.nodeSize, language]);
|
||||
|
||||
function changeLanguage(language: string) {
|
||||
setLanguageValue(language);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||
|
||||
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||
const DrawioMenu = lazy(
|
||||
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||
);
|
||||
|
||||
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DrawioMenu {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||
// rendered (mirrors excalidraw-view-lazy).
|
||||
const DrawioView = lazy(
|
||||
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||
);
|
||||
|
||||
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DrawioView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||
|
||||
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||
// silently enabling/disabling the toolbar buttons in production.
|
||||
describe("yHistoryAvailability", () => {
|
||||
it("reports availability from the stack lengths", () => {
|
||||
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||
).toEqual({ canUndo: true, canRedo: false });
|
||||
expect(
|
||||
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||
).toEqual({ canUndo: true, canRedo: true });
|
||||
});
|
||||
|
||||
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||
// fields: the caller then falls back to the safe prosemirror-history default
|
||||
// instead of throwing on `.length` of undefined or reading garbage.
|
||||
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||
expect(yHistoryAvailability(null)).toBeNull();
|
||||
expect(yHistoryAvailability({})).toBeNull();
|
||||
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||
// Only one stack present (partial rename) is still not trusted.
|
||||
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||
const doc = new Y.Doc();
|
||||
const text = doc.getText("prosemirror");
|
||||
const undoManager = new Y.UndoManager(text);
|
||||
|
||||
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||
// the expectation below fails loudly.
|
||||
text.insert(0, "hello");
|
||||
undoManager.stopCapturing();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
|
||||
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||
undoManager.undo();
|
||||
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||
canUndo: false,
|
||||
canRedo: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { undoDepth, redoDepth } from "@tiptap/pm/history";
|
||||
import { yUndoPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
export interface ToolbarState {
|
||||
isBold: boolean;
|
||||
@@ -16,14 +18,67 @@ export interface ToolbarState {
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
// Undo/redo come from either StarterKit's history or the Yjs collaboration
|
||||
// history extension. During the brief moment a page is rendered with the
|
||||
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
||||
// and editor.can().undo/redo is undefined.
|
||||
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
||||
const can = editor.can() as Record<string, unknown>;
|
||||
const fn = can[command];
|
||||
return typeof fn === "function" ? (fn as () => boolean)() : false;
|
||||
// Undo/redo availability, computed WITHOUT `editor.can().undo()/.redo()`.
|
||||
//
|
||||
// `editor.can()` runs the command as a dry-run (building a throwaway state +
|
||||
// transaction) — the most expensive work in this selector, and it ran on every
|
||||
// keystroke (and every REMOTE keystroke under collaboration). Instead we read
|
||||
// the history stack depth directly, which is a cheap plugin-state lookup and
|
||||
// mirrors exactly what the undo/redo commands themselves check:
|
||||
//
|
||||
// - Collaboration (Yjs): the yjs UndoManager's undo/redo stack lengths — the
|
||||
// same `undoStack.length === 0` / `redoStack.length === 0` guard the
|
||||
// Collaboration extension's undo/redo commands use.
|
||||
// - Plain history (templates / non-collab): prosemirror-history's undoDepth /
|
||||
// redoDepth, which back the UndoRedo extension.
|
||||
//
|
||||
// When neither history backend is installed (the pre-sync static editor —
|
||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||
// matching the previous `safeCan` behavior.
|
||||
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||
//
|
||||
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||
// returns null and the caller falls back to a safe default. The pin-test in
|
||||
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||
export function yHistoryAvailability(
|
||||
undoManager: unknown,
|
||||
): { canUndo: boolean; canRedo: boolean } | null {
|
||||
if (!undoManager || typeof undoManager !== "object") return null;
|
||||
const { undoStack, redoStack } = undoManager as {
|
||||
undoStack?: unknown;
|
||||
redoStack?: unknown;
|
||||
};
|
||||
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||
return {
|
||||
canUndo: undoStack.length > 0,
|
||||
canRedo: redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function historyAvailability(editor: Editor): {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
} {
|
||||
const state = editor.state;
|
||||
|
||||
// Collaboration history (Yjs) takes precedence when present.
|
||||
const yState = yUndoPluginKey.getState(state) as
|
||||
| { undoManager?: unknown }
|
||||
| undefined;
|
||||
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||
if (yAvail) return yAvail;
|
||||
|
||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||
// This is also the safe default when a Yjs UndoManager is present but its
|
||||
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||
return {
|
||||
canUndo: undoDepth(state) > 0,
|
||||
canRedo: redoDepth(state) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
@@ -31,6 +86,7 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
const { canUndo, canRedo } = historyAvailability(ctx.editor);
|
||||
return {
|
||||
isBold: ctx.editor.isActive("bold"),
|
||||
isItalic: ctx.editor.isActive("italic"),
|
||||
@@ -42,8 +98,8 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
isBulletList: ctx.editor.isActive("bulletList"),
|
||||
isOrderedList: ctx.editor.isActive("orderedList"),
|
||||
isTaskList: ctx.editor.isActive("taskList"),
|
||||
canUndo: safeCan(ctx.editor, "undo"),
|
||||
canRedo: safeCan(ctx.editor, "redo"),
|
||||
canUndo,
|
||||
canRedo,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,19 +49,14 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
{/* #146: contentDOM MUST be the first child — a non-editable marker before
|
||||
{/* #146: contentDOM MUST be the first child — non-editable chrome before
|
||||
it makes click hit-testing snap the caret above. Content first; the
|
||||
marker + back-link follow in DOM and are placed left/right via CSS
|
||||
flex `order`. The second #146 mitigation lives in
|
||||
back-link follows in DOM and is placed on the right via CSS flex. The
|
||||
decorative "N." number is rendered inline via the .definitionContent
|
||||
::before rule (from the --footnote-number var), so no marker element
|
||||
precedes the content. The second #146 mitigation lives in
|
||||
editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.definitionMarker}
|
||||
contentEditable={false}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}.
|
||||
</span>
|
||||
{refCount > 1 ? (
|
||||
// Multiple references -> ↩ followed by one lettered link per occurrence.
|
||||
<span
|
||||
|
||||
@@ -81,34 +81,34 @@
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
/* Tight spacing between the content and the trailing ↩ back-link. */
|
||||
gap: 0.3em;
|
||||
padding: 2px 0;
|
||||
/* Footnotes read smaller than body text (16px). Matches .listHeading. */
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The "N." number is decorative (from the --footnote-number CSS var on the
|
||||
wrapper, never in the document model) and is rendered inline at the start of
|
||||
the first content line via ::before. This keeps text and wrapped lines flush
|
||||
to the left margin — no hanging indent — while the editable contentDOM stays
|
||||
the FIRST DOM child (#146). */
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
|
||||
which pushes the first text line ~0.5em below the "N." marker (aligned to
|
||||
flex-start), making the number float above the text. Drop the outer margins
|
||||
so the marker and the first line share the same top edge — same approach
|
||||
used for callouts in core.css. */
|
||||
.definitionContent > :first-child::before {
|
||||
content: var(--footnote-number, "?") ". ";
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
|
||||
Drop the outer margins so the definition sits tight to the heading above and
|
||||
the ::before number aligns with the top of the row — same approach used for
|
||||
callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #343 PART 1: skip the expensive per-keystroke work (getAttributes + the
|
||||
// alignment isActive() probes) unless an image is actually active. The
|
||||
// menu is only shown when an image is active (see shouldShow), so a null
|
||||
// state while inactive is never rendered — behavior is unchanged.
|
||||
if (!ctx.editor.isActive("image")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageAttrs = ctx.editor.getAttributes("image");
|
||||
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ import classes from "./link.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -83,7 +83,7 @@ export default function LinkView(props: MarkViewProps) {
|
||||
const isPopoverVisible = popoverState !== "closed";
|
||||
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
|
||||
|
||||
const { data: linkedPage } = usePageQuery({
|
||||
const { data: linkedPage } = usePageMetaQuery({
|
||||
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||
// only when a document actually contains a math node (mirrors the mermaid/
|
||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||
// crashing or blocking the whole editor: while it loads we render the raw
|
||||
// LaTeX source as a node-sized placeholder.
|
||||
const MathBlockView = lazy(
|
||||
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||
);
|
||||
|
||||
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||
<MathBlockView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||
// only when a document actually contains a math node (mirrors the mermaid/
|
||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||
// crashing or blocking the whole editor: while it loads we render the raw
|
||||
// LaTeX source as a node-sized placeholder.
|
||||
const MathInlineView = lazy(
|
||||
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||
);
|
||||
|
||||
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||
<MathInlineView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
MentionListProps,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -50,12 +50,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
// Setter-only: the tree value is read only imperatively inside createPage
|
||||
// (via `store` below), never at render, so useSetAtom avoids re-rendering the
|
||||
// mention popup on any tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const isInCommentContext = props.isInCommentContext ?? false;
|
||||
@@ -272,9 +276,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const lastIndex = data.length;
|
||||
// Read the live tree imperatively at call time.
|
||||
const currentTree = store.get(treeDataAtom);
|
||||
const lastIndex = currentTree.length;
|
||||
|
||||
setData(treeModel.insert(data, parentId, newNode, lastIndex));
|
||||
setData(treeModel.insert(currentTree, parentId, newNode, lastIndex));
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
@@ -36,7 +36,7 @@ export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
} = usePageMetaQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
|
||||
const { data: sharedPage } = useSharePageQuery({
|
||||
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||
|
||||
@@ -25,6 +25,13 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #343 PART 1: skip getAttributes unless a pdf node is active. The menu
|
||||
// only shows for an active pdf node (shouldShow), so the null state while
|
||||
// inactive is never rendered — behavior unchanged.
|
||||
if (!ctx.editor.isActive("pdf")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pdfAttrs = ctx.editor.getAttributes("pdf");
|
||||
|
||||
return {
|
||||
|
||||
@@ -70,7 +70,14 @@ export const SubpagesMenu = React.memo(
|
||||
// toggle without re-rendering on every keystroke.
|
||||
const isRecursive = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
||||
// #343 PART 1: skip getAttributes unless a subpages node is active. The
|
||||
// menu only shows for an active subpages node (shouldShow), so the value
|
||||
// is only read then; getAttributes on an inactive node returns the default
|
||||
// (recursive === false) anyway, so this is behavior-preserving.
|
||||
selector: (ctx) =>
|
||||
ctx.editor?.isActive("subpages")
|
||||
? (ctx.editor.getAttributes("subpages")?.recursive ?? false)
|
||||
: false,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import classes from "./table-of-contents.module.css";
|
||||
import clsx from "clsx";
|
||||
import { Box, Text, Title } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TableOfContentsProps = {
|
||||
@@ -79,13 +80,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
setHeadingDOMNodes(result.nodes);
|
||||
};
|
||||
|
||||
// Debounce the update-driven rescan: `$nodes("heading")` scans every heading
|
||||
// in the document, and it previously ran on EVERY keystroke while the TOC
|
||||
// panel was open. The panel is derived UI, so recomputing ~300ms after typing
|
||||
// settles keeps it correct without doing an all-headings scan per keystroke
|
||||
// (#343, PART 7). `useDebouncedCallback` returns a stable reference and always
|
||||
// invokes the latest `handleUpdate`.
|
||||
const debouncedHandleUpdate = useDebouncedCallback(handleUpdate, 300);
|
||||
|
||||
useEffect(() => {
|
||||
props.editor?.on("update", handleUpdate);
|
||||
props.editor?.on("update", debouncedHandleUpdate);
|
||||
|
||||
return () => {
|
||||
props.editor?.off("update", handleUpdate);
|
||||
props.editor?.off("update", debouncedHandleUpdate);
|
||||
};
|
||||
}, [props.editor]);
|
||||
}, [props.editor, debouncedHandleUpdate]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
|
||||
@@ -31,6 +31,13 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #343 PART 1: skip getAttributes + alignment isActive() probes unless a
|
||||
// video is active. The menu only shows for an active video (shouldShow),
|
||||
// so the null state while inactive is never rendered — behavior unchanged.
|
||||
if (!ctx.editor.isActive("video")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoAttrs = ctx.editor.getAttributes("video");
|
||||
|
||||
return {
|
||||
|
||||
@@ -81,8 +81,8 @@ import {
|
||||
createResizeHandle,
|
||||
buildResizeClasses,
|
||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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 { Bold } from "@tiptap/extension-bold";
|
||||
import { Italic } from "@tiptap/extension-italic";
|
||||
import { MarkdownClipboard } from "./markdown-clipboard";
|
||||
|
||||
/**
|
||||
* Integration coverage for the async `handlePaste` seam (issue #347). The paste
|
||||
* conversion moved to `@docmost/prosemirror-markdown`'s browser entry, whose
|
||||
* `markdownToProseMirror` is async — so `handlePaste` captures the range, claims
|
||||
* the event (returns true), and dispatches the insert on the next microtask.
|
||||
* These tests drive that path end to end on a minimal schema (a plain-markdown
|
||||
* paste whose converted nodes fit paragraph/text/bold/italic), asserting the
|
||||
* text lands with the right marks and that the raw markdown syntax is consumed
|
||||
* (recognized as markdown, not inserted literally).
|
||||
*/
|
||||
|
||||
function makeEditor() {
|
||||
const element = document.createElement("div");
|
||||
document.body.appendChild(element);
|
||||
return new Editor({
|
||||
element,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
Italic,
|
||||
MarkdownClipboard.configure({ transformPastedText: true }),
|
||||
],
|
||||
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
}
|
||||
|
||||
// Locate the markdownClipboard plugin and invoke its handlePaste directly with a
|
||||
// synthetic clipboard event (jsdom has no real paste pipeline). The plugin's
|
||||
// handlePaste closes over the extension `this`, so calling it off the plugin
|
||||
// props preserves `this.editor`/`this.options`.
|
||||
function paste(editor: Editor, text: string): boolean {
|
||||
const view = editor.view;
|
||||
const plugin = view.state.plugins.find(
|
||||
(p: any) => p.props && p.spec?.key,
|
||||
) as any;
|
||||
const event = {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||
},
|
||||
} as unknown as ClipboardEvent;
|
||||
// Find the specific handlePaste that belongs to the markdown clipboard plugin.
|
||||
const md = view.state.plugins.find(
|
||||
(p: any) => typeof p.props?.handlePaste === "function",
|
||||
) as any;
|
||||
return md.props.handlePaste(view, event, view.state.selection.content());
|
||||
}
|
||||
|
||||
// Flush the microtask queue so the async .then() dispatch runs.
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("MarkdownClipboard handlePaste (async md -> PM)", () => {
|
||||
it("converts a plain-markdown paste with bold/italic into marked text", async () => {
|
||||
const editor = makeEditor();
|
||||
const claimed = paste(editor, "hello **bold** and *italic*");
|
||||
// The paste is claimed synchronously (async insert follows).
|
||||
expect(claimed).toBe(true);
|
||||
await flush();
|
||||
|
||||
const json = editor.getJSON();
|
||||
const text = JSON.stringify(json);
|
||||
// The raw markdown asterisks are consumed (recognized), not inserted literally.
|
||||
expect(editor.getText()).not.toContain("**");
|
||||
expect(editor.getText()).toContain("bold");
|
||||
expect(editor.getText()).toContain("italic");
|
||||
// The bold/italic marks materialized.
|
||||
expect(text).toContain('"bold"');
|
||||
expect(text).toContain('"italic"');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("recognizes a bullet list paste as list structure (not literal '-')", async () => {
|
||||
// A bullet list is not representable in this minimal schema, so the converter
|
||||
// output would fail PMNode.fromJSON and the catch inserts raw text. Use a
|
||||
// paste whose nodes DO fit the schema to assert the happy path instead: two
|
||||
// paragraphs separated by a blank line.
|
||||
const editor = makeEditor();
|
||||
paste(editor, "first para\n\nsecond para");
|
||||
await flush();
|
||||
const json = editor.getJSON() as any;
|
||||
const paras = (json.content || []).filter(
|
||||
(n: any) => n.type === "paragraph",
|
||||
);
|
||||
// Two paragraphs materialized from the blank-line-separated markdown.
|
||||
expect(paras.length).toBeGreaterThanOrEqual(2);
|
||||
expect(editor.getText()).toContain("first para");
|
||||
expect(editor.getText()).toContain("second para");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("falls back to raw text when conversion yields nodes the schema lacks", async () => {
|
||||
// `# heading` converts to a `heading` node absent from this minimal schema,
|
||||
// so PMNode.fromJSON throws and the catch re-inserts the raw text — the user
|
||||
// never loses their clipboard content.
|
||||
const editor = makeEditor();
|
||||
paste(editor, "# a heading line");
|
||||
await flush();
|
||||
// Content is preserved (either as heading text or literal), never dropped.
|
||||
expect(editor.getText()).toContain("a heading line");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// The async seam captures the target range synchronously, then replaces on the
|
||||
// next microtask. If the document changed under it between capture and resolve
|
||||
// (impossible in prod — same microtask — but pinned here), BOTH the success
|
||||
// (replaceRange) and the fail-open (insertText) branches must fall back to the
|
||||
// LIVE selection rather than a stale absolute range, so neither clobbers content
|
||||
// nor throws a RangeError. We force the mid-flight change by dispatching a
|
||||
// doc-mutating transaction AFTER the synchronous claim but BEFORE flushing the
|
||||
// microtask that runs the `.then`/`.catch`.
|
||||
describe("MarkdownClipboard handlePaste — doc-changed-mid-flight guard", () => {
|
||||
// Replace the whole doc with one paragraph of `text` (synchronous dispatch).
|
||||
// An empty string yields an empty paragraph (a text node may not be empty).
|
||||
function seedContent(editor: Editor, text: string) {
|
||||
editor.commands.setContent({
|
||||
type: "doc",
|
||||
content: [
|
||||
text
|
||||
? { type: "paragraph", content: [{ type: "text", text }] }
|
||||
: { type: "paragraph" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("success branch: mid-flight doc change routes the paste to the LIVE selection, never the stale range (clobber-proving)", async () => {
|
||||
// The paste captures a NON-EMPTY range {1,5} (over "AAAA"). Then, before the
|
||||
// async resolve, the doc GROWS ("MARKER" inserted at the start) and the cursor
|
||||
// is parked at the doc END. The captured {1,5} is now stale and points INTO
|
||||
// "MARKER". A WORKING guard replaces at the live (end) selection → MARKER is
|
||||
// untouched. A BROKEN guard replaces the stale {1,5} → it erases the first
|
||||
// characters of MARKER (this is what a zero-width `from==to` range could never
|
||||
// reveal, which is why the earlier version was vacuous).
|
||||
const editor = makeEditor();
|
||||
seedContent(editor, "AAAABBBB");
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 }); // captured range = {1,5}
|
||||
const claimed = paste(editor, "hello **bold**");
|
||||
expect(claimed).toBe(true);
|
||||
|
||||
// Mid-flight: grow the doc and move the cursor to a KNOWN-safe end position.
|
||||
editor.view.dispatch(editor.view.state.tr.insertText("MARKER", 1));
|
||||
const end = editor.state.doc.content.size;
|
||||
editor.commands.setTextSelection({ from: end, to: end });
|
||||
await flush();
|
||||
|
||||
const text = editor.getText();
|
||||
// MARKER intact only if the guard used the live selection, not the stale range.
|
||||
expect(text).toContain("MARKER");
|
||||
expect(text).toContain("bold");
|
||||
expect(text).not.toContain("**");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("fail-open branch: a mid-flight doc SHRINK makes the stale `to` out of bounds — the guard must avoid a RangeError (throw-proving)", async () => {
|
||||
// The paste captures a range {1,9} over an 8-char paragraph, then the
|
||||
// conversion FAILS (`# heading` -> a heading node the minimal schema lacks,
|
||||
// so PMNode.fromJSON throws -> the fail-open catch runs). Before the reject,
|
||||
// the doc is SHRUNK to an empty paragraph, so the captured `to` (9) is now far
|
||||
// past the doc's end. A WORKING guard inserts the raw text at the live (valid)
|
||||
// selection → "raw heading" lands. A BROKEN guard does insertText(md, 1, 9) on
|
||||
// a size-2 doc → RangeError, so the dispatch never runs and "raw heading" is
|
||||
// absent (the assertion reddens). A zero-width/growing-doc setup could never
|
||||
// push `to` out of bounds, which is why the earlier version was vacuous.
|
||||
const editor = makeEditor();
|
||||
seedContent(editor, "AAAABBBB");
|
||||
editor.commands.setTextSelection({ from: 1, to: 9 }); // captured range = {1,9}
|
||||
paste(editor, "# raw heading");
|
||||
|
||||
// Mid-flight: shrink the doc so the captured `to` = 9 is now out of bounds.
|
||||
seedContent(editor, "");
|
||||
await flush();
|
||||
|
||||
const text = editor.getText();
|
||||
// Raw text lands (via the live selection) only if the guard avoided the
|
||||
// stale, now-out-of-bounds range.
|
||||
expect(text).toContain("raw heading");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("two pastes in flight: neither payload is lost (no data loss)", async () => {
|
||||
// Prod-unreachable (two paste events are separate macrotasks, and each
|
||||
// conversion resolves on a microtask before the next), but pinned here: when
|
||||
// both resolve back-to-back, the second sees the changed doc and inserts at
|
||||
// the live selection the first left — so the two payloads may INTERLEAVE, but
|
||||
// neither is dropped. We assert no data loss, not contiguity.
|
||||
const editor = makeEditor();
|
||||
paste(editor, "alphaword");
|
||||
paste(editor, "betaword");
|
||||
await flush();
|
||||
const text = editor.getText();
|
||||
// Neither payload fully dropped (interleaving may split one of them).
|
||||
expect(text).toContain("alpha");
|
||||
expect(text).toContain("beta");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
// Markdown conversion now goes through the canonical package's BROWSER entry
|
||||
// (issue #347): the same converter the server import/export uses, resolved via
|
||||
// the `browser` exports condition so it runs on the native `DOMParser` (the
|
||||
// client jsdom vitest env provides one) with jsdom never bundled.
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirrorSync,
|
||||
} from "@docmost/prosemirror-markdown/browser";
|
||||
import {
|
||||
normalizeTableColumnWidths,
|
||||
classifyClipboardSelection,
|
||||
@@ -175,10 +182,13 @@ describe("classifyClipboardSelection", () => {
|
||||
|
||||
// Output-level tests for the table clipboard regression: copying a table must
|
||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
||||
// shape that the classifier-flag tests above do not cover.
|
||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
// These exercise the actual markdown produced by convertProseMirrorToMarkdown —
|
||||
// the same serializer step the clipboardTextSerializer now runs (issue #347) —
|
||||
// so they pin the OUTPUT shape that the classifier-flag tests above do not cover.
|
||||
// Input is ProseMirror JSON (what the copied slice serializes to), matching the
|
||||
// clipboardTextSerializer's new call: it wraps the slice content in a synthetic
|
||||
// `doc` (and the bare-rows case in a `table`) and calls the converter.
|
||||
describe("table clipboard markdown output (convertProseMirrorToMarkdown)", () => {
|
||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
||||
function lines(md: string): string[] {
|
||||
return md
|
||||
@@ -188,10 +198,10 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
}
|
||||
|
||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
||||
// of the padding turndown emits.
|
||||
// of the padding the serializer emits.
|
||||
function isSeparatorRow(line: string): boolean {
|
||||
const compact = line.replace(/\s+/g, "");
|
||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
||||
return /^\|(?::?-{2,}:?\|)+$/.test(compact);
|
||||
}
|
||||
|
||||
// Split a pipe-delimited row into trimmed cell values.
|
||||
@@ -203,42 +213,33 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
||||
// const table = document.createElement("table");
|
||||
// const tbody = document.createElement("tbody");
|
||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
||||
// div.appendChild(table);
|
||||
// return htmlToMarkdown(div.innerHTML);
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [c1, c2] of [
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = c1;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = c2;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
const cell = (t: string) => ({
|
||||
type: "tableCell",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
const headerCell = (t: string) => ({
|
||||
type: "tableHeader",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
const row = (nodes: any[]) => ({ type: "tableRow", content: nodes });
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||
// Mirror the serializer's `wrapBareRows` branch: bare tableRow nodes are
|
||||
// wrapped in a synthetic `table` and convertProseMirrorToMarkdown is called
|
||||
// (see markdown-clipboard.ts clipboardTextSerializer).
|
||||
const rows = [
|
||||
row([cell("a"), cell("b")]),
|
||||
row([cell("c"), cell("d")]),
|
||||
];
|
||||
const md = convertProseMirrorToMarkdown({
|
||||
type: "doc",
|
||||
content: [{ type: "table", content: rows }],
|
||||
});
|
||||
const ls = lines(md);
|
||||
|
||||
// Valid GFM: a header/data separator row is present (an empty header is
|
||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
||||
// Valid GFM: a header/data separator row is present.
|
||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
||||
// and no line is a bare cell value on its own.
|
||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited.
|
||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
||||
// The cell values land in real pipe-delimited data rows.
|
||||
@@ -248,39 +249,21 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
});
|
||||
|
||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
for (const h of ["Name", "Age"]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = h;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [name, age] of [
|
||||
["Alice", "30"],
|
||||
["Bob", "25"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = name;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = age;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
// Mirror the serializer's non-wrap branch: the full `table` node is the
|
||||
// slice content and convertProseMirrorToMarkdown runs on it.
|
||||
const md = convertProseMirrorToMarkdown({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
row([headerCell("Name"), headerCell("Age")]),
|
||||
row([cell("Alice"), cell("30")]),
|
||||
row([cell("Bob"), cell("25")]),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const ls = lines(md);
|
||||
|
||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
||||
@@ -296,3 +279,146 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
||||
});
|
||||
});
|
||||
|
||||
// #347 acceptance: pasting CANONICAL markdown yields the SAME nodes the server
|
||||
// import produces for the same text. The paste path calls markdownToProseMirror
|
||||
// (the package browser entry) — the identical converter the server import uses —
|
||||
// so asserting the converter (via the browser entry, on the native DOMParser)
|
||||
// recognizes each canon form pins the paste-parity guarantee. These forms were
|
||||
// NOT recognized by the old editor-ext marked layer the paste used before.
|
||||
describe("canonical markdown paste recognition (browser entry parity)", () => {
|
||||
// Collect every node type present in a doc (recursively).
|
||||
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||
if (!n || typeof n !== "object") return set;
|
||||
if (n.type) set.add(n.type);
|
||||
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||
return set;
|
||||
};
|
||||
const findNode = (n: any, type: string): any => {
|
||||
if (!n || typeof n !== "object") return undefined;
|
||||
if (n.type === type) return n;
|
||||
if (Array.isArray(n.content)) {
|
||||
for (const c of n.content) {
|
||||
const hit = findNode(c, type);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const allText = (n: any): string => {
|
||||
if (!n || typeof n !== "object") return "";
|
||||
if (typeof n.text === "string") return n.text;
|
||||
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
it("^[…] inline footnote -> footnoteReference + footnotesList", () => {
|
||||
const doc = markdownToProseMirrorSync("Body^[a note here].");
|
||||
const types = collectTypes(doc);
|
||||
expect(types.has("footnoteReference")).toBe(true);
|
||||
expect(types.has("footnotesList")).toBe(true);
|
||||
expect(types.has("footnoteDefinition")).toBe(true);
|
||||
});
|
||||
|
||||
it('<!--img {…}--> attached image comment -> image with align', () => {
|
||||
const doc = markdownToProseMirrorSync(
|
||||
' <!--img {"align":"left"}-->',
|
||||
);
|
||||
const img = findNode(doc, "image");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.attrs?.align).toBe("left");
|
||||
expect(img.attrs?.src).toBe("/files/x.png");
|
||||
});
|
||||
|
||||
it("> [!type] Obsidian callout -> callout node with type", () => {
|
||||
const doc = markdownToProseMirrorSync("> [!warning]\n> be careful");
|
||||
const callout = findNode(doc, "callout");
|
||||
expect(callout).toBeTruthy();
|
||||
expect(callout.attrs?.type).toBe("warning");
|
||||
expect(allText(callout)).toContain("be careful");
|
||||
});
|
||||
|
||||
it("$…$ inline math -> mathInline node", () => {
|
||||
const doc = markdownToProseMirrorSync("Euler: $e^{i\\pi}+1=0$ done");
|
||||
const math = findNode(doc, "mathInline");
|
||||
expect(math).toBeTruthy();
|
||||
expect(math.attrs?.text).toContain("e^{i\\pi}");
|
||||
});
|
||||
|
||||
it("==…== highlight -> highlight mark", () => {
|
||||
const doc = markdownToProseMirrorSync("A ==marked== word");
|
||||
const marked = findNode(doc, "text");
|
||||
// The highlighted run carries a `highlight` mark somewhere in the doc.
|
||||
const hasHighlight = (n: any): boolean => {
|
||||
if (!n || typeof n !== "object") return false;
|
||||
if (
|
||||
n.type === "text" &&
|
||||
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||
)
|
||||
return true;
|
||||
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||
};
|
||||
expect(marked).toBeTruthy();
|
||||
expect(hasHighlight(doc)).toBe(true);
|
||||
});
|
||||
|
||||
it("<!--subpages--> standalone comment -> subpages node", () => {
|
||||
const doc = markdownToProseMirrorSync("intro\n\n<!--subpages-->\n\nafter");
|
||||
expect(collectTypes(doc).has("subpages")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// #347 negatives: plain text carrying markdown-LIKE punctuation must NOT be
|
||||
// silently converted/mangled (currency, bare `==`, a `[^1]` reference form).
|
||||
describe("plain-text paste negatives (no phantom conversion)", () => {
|
||||
const findNode = (n: any, type: string): any => {
|
||||
if (!n || typeof n !== "object") return undefined;
|
||||
if (n.type === type) return n;
|
||||
if (Array.isArray(n.content)) {
|
||||
for (const c of n.content) {
|
||||
const hit = findNode(c, type);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||
if (!n || typeof n !== "object") return set;
|
||||
if (n.type) set.add(n.type);
|
||||
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||
return set;
|
||||
};
|
||||
const allText = (n: any): string => {
|
||||
if (!n || typeof n !== "object") return "";
|
||||
if (typeof n.text === "string") return n.text;
|
||||
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
it("currency `$5 and $10` is NOT turned into math", () => {
|
||||
const doc = markdownToProseMirrorSync("It costs $5 and $10 total");
|
||||
expect(findNode(doc, "mathInline")).toBeFalsy();
|
||||
expect(allText(doc)).toContain("$5 and $10");
|
||||
});
|
||||
|
||||
it("a lone `==` is NOT turned into a highlight", () => {
|
||||
const doc = markdownToProseMirrorSync("compare a == b in code");
|
||||
const hasHighlight = (n: any): boolean => {
|
||||
if (!n || typeof n !== "object") return false;
|
||||
if (
|
||||
n.type === "text" &&
|
||||
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||
)
|
||||
return true;
|
||||
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||
};
|
||||
expect(hasHighlight(doc)).toBe(false);
|
||||
expect(allText(doc)).toContain("== b");
|
||||
});
|
||||
|
||||
it("a `[^1]` reference form (no `^[`) is NOT turned into a footnote", () => {
|
||||
const doc = markdownToProseMirrorSync("see note [^1] for details");
|
||||
expect(collectTypes(doc).has("footnoteReference")).toBe(false);
|
||||
expect(allText(doc)).toContain("[^1]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice, Node as PMNode } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import {
|
||||
markdownToHtml,
|
||||
htmlToMarkdown,
|
||||
canonicalizeFootnotes,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
// Markdown <-> ProseMirror conversion now lives ONLY in the canonical
|
||||
// `@docmost/prosemirror-markdown` package (issue #347). The BROWSER entry uses
|
||||
// the native `DOMParser` for its HTML->DOM stage (jsdom stays out of the client
|
||||
// bundle) while producing the SAME nodes the server import does — so a paste of
|
||||
// canonical markdown (`^[…]`, `<!--img …-->`, `> [!type]`, `$…$`, `==…==`,
|
||||
// standalone comments) is recognized identically to import.
|
||||
import {
|
||||
markdownToProseMirror,
|
||||
convertProseMirrorToMarkdown,
|
||||
} from "@docmost/prosemirror-markdown/browser";
|
||||
import type { Schema } from "@tiptap/pm/model";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
@@ -39,25 +47,24 @@ export const MarkdownClipboard = Extension.create({
|
||||
classifyClipboardSelection(topLevelNodes);
|
||||
if (!asMarkdown) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
|
||||
// Convert the copied selection to Markdown through the canonical
|
||||
// package (issue #347), the SAME serializer the server export uses,
|
||||
// so a copied table/list matches the on-disk markdown form. The
|
||||
// converter takes a ProseMirror `doc` JSON, so wrap the slice's
|
||||
// top-level content in a synthetic doc.
|
||||
const content = slice.content.toJSON() as any[];
|
||||
if (wrapBareRows) {
|
||||
// A partial table cell-selection serializes to bare <tr> nodes
|
||||
// (prosemirror-tables returns the whole `table` node only when the
|
||||
// entire table is selected). Bare <tr> would be foster-parented
|
||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
tbody.appendChild(fragment);
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
} else {
|
||||
div.appendChild(fragment);
|
||||
// A partial table cell-selection serializes to bare `tableRow`
|
||||
// nodes (prosemirror-tables yields the whole `table` node only for
|
||||
// a full-table selection). The converter's table case expects a
|
||||
// `table` wrapper, so wrap the bare rows in one — mirroring the old
|
||||
// <table><tbody> wrap that the HTML->markdown step needed.
|
||||
return convertProseMirrorToMarkdown({
|
||||
type: "doc",
|
||||
content: [{ type: "table", content }],
|
||||
});
|
||||
}
|
||||
return htmlToMarkdown(div.innerHTML);
|
||||
return convertProseMirrorToMarkdown({ type: "doc", content });
|
||||
},
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!event.clipboardData) {
|
||||
@@ -95,37 +102,115 @@ export const MarkdownClipboard = Extension.create({
|
||||
}
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { from, to } = view.state.selection;
|
||||
const schema = this.editor.schema;
|
||||
// Capture the target range NOW. markdownToProseMirror RETURNS A
|
||||
// PROMISE (kept async only for the Node consumers' contract; the
|
||||
// conversion pipeline itself is synchronous), so the actual replace
|
||||
// happens on the next microtask. No user input can interleave a
|
||||
// microtask, so the state is unchanged when we dispatch — but we
|
||||
// still re-read the live state before replacing and, if the doc did
|
||||
// change under us, fall back to the live selection rather than the
|
||||
// captured (now-stale) range.
|
||||
const from = view.state.selection.from;
|
||||
const to = view.state.selection.to;
|
||||
const startDoc = view.state.doc;
|
||||
const md = text.replace(/\n+$/, "");
|
||||
|
||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
||||
const body = elementFromString(parsed);
|
||||
normalizeTableColumnWidths(body);
|
||||
void markdownToProseMirror(md)
|
||||
.then((doc) => {
|
||||
if (view.isDestroyed) return;
|
||||
// Canonical PM-JSON -> HTML via the LIVE editor schema, then
|
||||
// reuse the UNCHANGED downstream seam (normalizeTableColumnWidths
|
||||
// + parseSlice + canonicalizePastedFootnotes). The JSON->HTML->
|
||||
// JSON hop is lossless (same schema both directions); it lets the
|
||||
// existing paste-insertion logic stay byte-identical — only the
|
||||
// SOURCE of the markdown conversion changed (issue #347 guardrail:
|
||||
// no converter logic in the client, only a call into the package).
|
||||
const node = PMNode.fromJSON(schema, doc);
|
||||
const div = document.createElement("div");
|
||||
DOMSerializer.fromSchema(schema).serializeFragment(
|
||||
node.content,
|
||||
{ document },
|
||||
div,
|
||||
);
|
||||
|
||||
const parsedSlice = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(body, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
const body = elementFromString(div.innerHTML);
|
||||
normalizeTableColumnWidths(body);
|
||||
|
||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
||||
// reorders an existing list. So a pasted markdown block whose footnote
|
||||
// definitions are out of order (or contains orphan defs) would be
|
||||
// stored out of order. Canonicalize the self-contained pasted block so
|
||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
||||
// to whole-block pastes that carry their own footnotesList.
|
||||
const contentNodes = canonicalizePastedFootnotes(
|
||||
parsedSlice,
|
||||
this.editor.schema,
|
||||
);
|
||||
const parsedSlice = DOMParser.fromSchema(schema).parseSlice(
|
||||
body,
|
||||
{ preserveWhitespace: true },
|
||||
);
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
tr.setMeta('paste', true)
|
||||
view.dispatch(tr);
|
||||
// A markdown paste builds its ProseMirror fragment directly (DOM
|
||||
// -> parseSlice), bypassing the editor's footnoteSyncPlugin, which
|
||||
// never reorders an existing list. So a pasted markdown block whose
|
||||
// footnote definitions are out of order (or contains orphan defs)
|
||||
// would be stored out of order. Canonicalize the self-contained
|
||||
// pasted block so its footnotes come out reference-ordered, deduped
|
||||
// and orphan-free (issue #228). See canonicalizePastedFootnotes for
|
||||
// why this is scoped to whole-block pastes that carry their own
|
||||
// footnotesList.
|
||||
const contentNodes = canonicalizePastedFootnotes(
|
||||
parsedSlice,
|
||||
schema,
|
||||
);
|
||||
|
||||
// Target the captured range (normally still valid — same
|
||||
// microtask). If the doc changed under us since capture, the
|
||||
// captured absolute from/to are stale, so fall back to the live
|
||||
// selection rather than StepMap-mapping the old range.
|
||||
const tr = view.state.tr;
|
||||
let mappedFrom = from;
|
||||
let mappedTo = to;
|
||||
if (view.state.doc !== startDoc) {
|
||||
// Defensive: if the doc changed under us, fall back to the
|
||||
// current selection rather than a stale absolute range.
|
||||
mappedFrom = view.state.selection.from;
|
||||
mappedTo = view.state.selection.to;
|
||||
}
|
||||
tr.replaceRange(mappedFrom, mappedTo, contentNodes);
|
||||
const insertEnd = tr.mapping.map(mappedFrom, 1);
|
||||
tr.setSelection(
|
||||
TextSelection.near(
|
||||
tr.doc.resolve(Math.max(mappedFrom, insertEnd - 2)),
|
||||
-1,
|
||||
),
|
||||
);
|
||||
tr.setMeta("paste", true);
|
||||
view.dispatch(tr);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Fail-open: a conversion error must not swallow the paste
|
||||
// silently in a way that loses the text. We already claimed the
|
||||
// event (returned true), so re-insert the raw text as a plain
|
||||
// paragraph so the user never loses their clipboard content.
|
||||
// Log it: this catch covers BOTH the converter and the success
|
||||
// `.then` body (e.g. PMNode.fromJSON throwing on a schema drift
|
||||
// between the canonical package and the live editor schema), so a
|
||||
// silent degrade to raw text would otherwise be an invisible,
|
||||
// non-reproducible regression ("my table pasted as text").
|
||||
console.error(
|
||||
"markdown paste conversion failed, inserting raw text",
|
||||
err,
|
||||
);
|
||||
if (view.isDestroyed) return;
|
||||
const tr = view.state.tr;
|
||||
// Same guard the success path uses: if the doc changed under us
|
||||
// since the range was captured (normally never — same microtask),
|
||||
// the captured absolute from/to are stale and would throw a
|
||||
// RangeError here (an unhandled rejection on a hot paste path).
|
||||
// Fall back to the live selection instead of a stale range.
|
||||
if (view.state.doc !== startDoc) {
|
||||
const sel = view.state.selection;
|
||||
tr.insertText(md, sel.from, sel.to);
|
||||
} else {
|
||||
tr.insertText(md, from, to);
|
||||
}
|
||||
tr.setMeta("paste", true);
|
||||
view.dispatch(tr);
|
||||
});
|
||||
// Claim the paste: we insert asynchronously above.
|
||||
return true;
|
||||
},
|
||||
// Strip trailing whitespace-only paragraphs from pasted content.
|
||||
|
||||
@@ -6,6 +6,23 @@ import getSuggestionItems from '@/features/editor/components/slash-menu/menu-ite
|
||||
|
||||
export const slashMenuPluginKey = new PluginKey('slash-command');
|
||||
|
||||
// getSuggestionItems fuzzy-matches EVERY command against the query (plus its
|
||||
// wrong-keyboard-layout remaps) and, while the slash menu is open, is invoked
|
||||
// TWICE per keystroke: once by the synchronous `allow` gate below and once by
|
||||
// the popup's `items` builder. A synchronous gating predicate can't be
|
||||
// debounced without breaking the suggestion decoration/activation, so instead we
|
||||
// memoize the LAST query's result: the two same-query calls in one keystroke
|
||||
// build the list only once, and the cache invalidates the moment the query
|
||||
// changes — so there is no stale-state risk (#343, PART 7).
|
||||
let lastQuery: string | null = null;
|
||||
let lastResult: ReturnType<typeof getSuggestionItems> | null = null;
|
||||
function suggestionItemsForQuery(query: string) {
|
||||
if (query === lastQuery && lastResult) return lastResult;
|
||||
lastQuery = query;
|
||||
lastResult = getSuggestionItems({ query });
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const Command = Extension.create({
|
||||
name: 'slash-command',
|
||||
@@ -38,7 +55,7 @@ const Command = Extension.create({
|
||||
// non-matching queries while keeping multi-word matches (e.g.
|
||||
// "/Heading 1") working.
|
||||
const query = state.doc.textBetween(range.from + 1, range.to);
|
||||
const groups = getSuggestionItems({ query });
|
||||
const groups = suggestionItemsForQuery(query);
|
||||
const hasMatches = Object.values(groups).some(
|
||||
(items) => items.length > 0,
|
||||
);
|
||||
@@ -61,7 +78,9 @@ const Command = Extension.create({
|
||||
|
||||
const SlashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
// Share the per-query memo with `allow` so the pair of same-query calls in a
|
||||
// single keystroke rebuilds the list once (#343, PART 7).
|
||||
items: ({ query }: { query: string }) => suggestionItemsForQuery(query),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||
// so this always-mounted global bridge does not statically import
|
||||
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||
// already stores these raw status strings.
|
||||
const YJS_STATUS_CONNECTED = "connected";
|
||||
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||
// annotations/casts, never as a runtime value.
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
@@ -16,16 +25,19 @@ import {
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import {
|
||||
// Types are erased at build time, so importing them does not pull the module's
|
||||
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||
// bridge is mounted for every authenticated user but recording is a rare,
|
||||
// native-host-driven action.
|
||||
import type {
|
||||
GitmostBridge,
|
||||
GitmostCreatePagePayload,
|
||||
GitmostCreatePageResult,
|
||||
GitmostListPagesPayload,
|
||||
GitmostListPagesResult,
|
||||
GitmostListSpacesResult,
|
||||
gitmostDecodePayloadToFile,
|
||||
gitmostInsertTranscriptIntoEditor,
|
||||
gitmostUploadFileToEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
|
||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||
@@ -58,7 +70,7 @@ function gitmostWaitForEditor(
|
||||
!editor.isDestroyed &&
|
||||
editor.isEditable &&
|
||||
editorPageId === pageId &&
|
||||
yjsStatus === WebSocketStatus.Connected;
|
||||
yjsStatus === YJS_STATUS_CONNECTED;
|
||||
if (ready) {
|
||||
resolve(editor);
|
||||
return;
|
||||
@@ -172,6 +184,15 @@ export default function GitmostGlobalBridge() {
|
||||
};
|
||||
}
|
||||
|
||||
// Load the recording helpers on demand (see the import note above). This
|
||||
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||
// pull in stays out of the eager startup graph.
|
||||
const {
|
||||
gitmostDecodePayloadToFile,
|
||||
gitmostUploadFileToEditor,
|
||||
gitmostInsertTranscriptIntoEditor,
|
||||
} = await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||
|
||||
// Validate/decode the recording BEFORE creating the page so a bad
|
||||
// payload never leaves an empty junk page behind. Per the createPage
|
||||
// error contract, any decode failure collapses to "insert-failed" (the
|
||||
|
||||
@@ -33,10 +33,11 @@ vi.mock("@/lib/local-emitter.ts", () => ({
|
||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||
}));
|
||||
|
||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||
// purely via the fake page editor's getHTML().
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
htmlToMarkdown: (html: string) => html,
|
||||
// convertProseMirrorToMarkdown echoes a marker carried on the fake editor's
|
||||
// getJSON() doc, so each test controls the markdown purely via the fake page
|
||||
// editor (issue #347: the hook now serializes editor JSON through the package).
|
||||
vi.mock("@docmost/prosemirror-markdown/browser", () => ({
|
||||
convertProseMirrorToMarkdown: (doc: { __md?: string }) => doc?.__md ?? "",
|
||||
}));
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
@@ -53,10 +54,12 @@ import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||
|
||||
// --- Test helpers -------------------------------------------------------------
|
||||
|
||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||
function makePageEditor(pageId: string, md = "content"): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
getHTML: () => html,
|
||||
// The mocked convertProseMirrorToMarkdown reads `__md` back off this doc,
|
||||
// so `md` is exactly the markdown the hook will send to the title service.
|
||||
getJSON: () => ({ type: "doc", __md: md }),
|
||||
storage: { pageId },
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
@@ -49,7 +49,9 @@ export function useGeneratePageTitle(pageId: string) {
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
|
||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
||||
// Serialize the live editor content to markdown through the canonical
|
||||
// converter (issue #347), matching the on-disk/export markdown form.
|
||||
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON()).trim();
|
||||
if (!markdown) {
|
||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
// Mock the app entry so importing the hook doesn't boot the whole app; the hook
|
||||
// only needs queryClient's cache read/write, which we stub here. Declared via
|
||||
// vi.hoisted so the spies exist before the hoisted vi.mock factory runs.
|
||||
const { getQueryData, setQueryData } = vi.hoisted(() => ({
|
||||
getQueryData: vi.fn(() => undefined as unknown),
|
||||
setQueryData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { getQueryData, setQueryData },
|
||||
}));
|
||||
|
||||
import { usePageContentCache } from "./use-page-content-cache";
|
||||
|
||||
const SNAPSHOT = { type: "doc", content: [] };
|
||||
|
||||
function makeFakeEditor(overrides: Partial<Editor> = {}): Editor {
|
||||
return {
|
||||
isEmpty: false,
|
||||
isDestroyed: false,
|
||||
getJSON: vi.fn(() => SNAPSHOT),
|
||||
...overrides,
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
describe("usePageContentCache (#343 PART 3) — getJSON off the keystroke path", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
// A cached page exists so the write path runs.
|
||||
getQueryData.mockReturnValue({ id: "p1", content: {} });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("onUpdate (calling the debounced fn) does NOT call getJSON synchronously", () => {
|
||||
const editor = makeFakeEditor();
|
||||
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePageContentCache(editorRef, "slug-1", 3000),
|
||||
);
|
||||
|
||||
// Simulate a keystroke's onUpdate -> only schedules the debounce.
|
||||
act(() => {
|
||||
result.current();
|
||||
result.current();
|
||||
result.current();
|
||||
});
|
||||
|
||||
// The whole-doc serialization must NOT have happened yet.
|
||||
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||
expect(setQueryData).not.toHaveBeenCalled();
|
||||
|
||||
// Once the debounce window elapses, getJSON runs exactly once (not per call).
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(editor.getJSON).toHaveBeenCalledTimes(1);
|
||||
expect(setQueryData).toHaveBeenCalledWith(["pages", "slug-1"], {
|
||||
id: "p1",
|
||||
content: SNAPSHOT,
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes the pending snapshot on unmount so the last edit isn't lost", () => {
|
||||
const editor = makeFakeEditor();
|
||||
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
usePageContentCache(editorRef, "slug-1", 3000),
|
||||
);
|
||||
|
||||
act(() => result.current());
|
||||
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||
|
||||
// Navigation/unmount must flush (not drop) the pending write.
|
||||
act(() => unmount());
|
||||
expect(editor.getJSON).toHaveBeenCalledTimes(1);
|
||||
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips the write when the editor is destroyed (flush racing teardown)", () => {
|
||||
const editor = makeFakeEditor({ isDestroyed: true });
|
||||
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePageContentCache(editorRef, "slug-1", 3000),
|
||||
);
|
||||
|
||||
act(() => result.current());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
|
||||
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||
expect(setQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
/**
|
||||
* Off-keystroke local page-cache updater (issue #343, PART 3).
|
||||
*
|
||||
* The editor's `onUpdate` fires on every keystroke — and, under collaboration,
|
||||
* on every REMOTE keystroke too. Serializing the WHOLE document with
|
||||
* `editor.getJSON()` on that hot path is expensive, and the previous 3s debounce
|
||||
* only guarded the cache WRITE, not the serialization: `getJSON()` still ran per
|
||||
* keystroke.
|
||||
*
|
||||
* This hook moves the serialization INSIDE the debounced callback, so the
|
||||
* full-doc traversal happens at most once per `delay`, not per keystroke. Call
|
||||
* the returned function from `onUpdate` (it only schedules the debounce); the
|
||||
* `getJSON()` snapshot is taken when the debounce fires.
|
||||
*
|
||||
* On unmount/navigation the pending snapshot is FLUSHED (via `flushOnUnmount`)
|
||||
* so the last edits within the debounce window aren't lost from the local cache.
|
||||
* The source of truth is collab/Yjs, but the cache must not go stale.
|
||||
*
|
||||
* IMPORTANT: call this hook BEFORE `useEditor`. React runs effect cleanups in
|
||||
* declaration order on unmount, so the debounce's flush cleanup must be declared
|
||||
* before `useEditor`'s teardown to run while the editor is still alive; the
|
||||
* `isDestroyed` guard keeps a flush that still races teardown safe (it skips).
|
||||
*/
|
||||
export function usePageContentCache(
|
||||
editorRef: MutableRefObject<Editor | null>,
|
||||
slugId: string | undefined,
|
||||
delay = 3000,
|
||||
) {
|
||||
return useDebouncedCallback(
|
||||
() => {
|
||||
const e = editorRef.current;
|
||||
if (!e || e.isDestroyed || e.isEmpty) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
// getJSON() (full-doc serialization) runs HERE, off the keystroke path.
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
content: e.getJSON(),
|
||||
});
|
||||
}
|
||||
},
|
||||
{ delay, flushOnUnmount: true },
|
||||
);
|
||||
}
|
||||
@@ -59,10 +59,10 @@ import {
|
||||
handlePaste,
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
@@ -79,6 +79,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { usePageContentCache } from "./hooks/use-page-content-cache";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
@@ -272,8 +273,13 @@ export default function PageEditor({
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
// Attach the remote provider once it's ready (and again after a pageId swap
|
||||
// recreates it) to make sure the connection gets properly established. This
|
||||
// used to run in the render body — a side effect during render (#343, PART 7).
|
||||
// `attach()` is idempotent, so re-running it on these deps is safe.
|
||||
useEffect(() => {
|
||||
providersRef.current?.remote.attach();
|
||||
}, [providersReady, pageId]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
@@ -288,6 +294,12 @@ export default function PageEditor({
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
|
||||
// getJSON() serialization + cache write live in the hook, off the keystroke
|
||||
// path, and flush on unmount so the last snapshot survives navigation (#343).
|
||||
// MUST be declared before useEditor: React runs effect cleanups in declaration
|
||||
// order on unmount, so the flush must run before the editor is torn down.
|
||||
const debouncedUpdateContent = usePageContentCache(editorRef, slugId);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
@@ -392,11 +404,11 @@ export default function PageEditor({
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
if (editor.isEmpty) return;
|
||||
const editorJson = editor.getJSON();
|
||||
//update local page cache to reduce flickers
|
||||
debouncedUpdateContent(editorJson);
|
||||
onUpdate() {
|
||||
// Only schedule the debounce here — the whole-doc getJSON() serialization
|
||||
// happens INSIDE the debounced callback (see usePageContentCache), so it
|
||||
// no longer runs synchronously on every (local or remote) keystroke.
|
||||
debouncedUpdateContent();
|
||||
},
|
||||
},
|
||||
[pageId, editable, extensions],
|
||||
@@ -442,17 +454,6 @@ export default function PageEditor({
|
||||
};
|
||||
}, [editor, pageId, editorIsEditable]);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
content: newContent,
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId, resolved } = event.detail;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,7 +31,6 @@ export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string
|
||||
const { data } = useQuery({
|
||||
queryKey: ["favorite-ids", type, spaceId],
|
||||
queryFn: () => getFavoriteIds(type, spaceId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const items = data?.items;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAtomValue } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
@@ -23,7 +23,7 @@ import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||
|
||||
export function PageDetailsAside() {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
const { data: page } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
|
||||
// breadcrumb.tsx transitively imports @/main.tsx (via usePageMetaQuery ->
|
||||
// queryClient), whose module body calls ReactDOM.createRoot on a null root in
|
||||
// jsdom. Stub it so importing the pure helper under test doesn't run that
|
||||
// (breadcrumbPathEqual does not use queryClient, so a dummy is enough).
|
||||
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||
|
||||
import { breadcrumbPathEqual } from "./breadcrumb";
|
||||
|
||||
// breadcrumbPathEqual is the ONLY point where a false-positive equality would
|
||||
// leave a stale/incorrect breadcrumb trail on screen: it decides whether the
|
||||
// selectAtom hands back the same reference (no re-render) for the ancestor chain.
|
||||
// Pin both directions — a too-loose equality goes stale on a rename; a too-tight
|
||||
// one loses the perf win.
|
||||
const node = (over: Partial<SpaceTreeNode>): SpaceTreeNode =>
|
||||
({ id: "a", slugId: "sa", name: "A", icon: "📄", ...over }) as SpaceTreeNode;
|
||||
|
||||
describe("breadcrumbPathEqual", () => {
|
||||
it("both null → true", () => {
|
||||
expect(breadcrumbPathEqual(null, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("same reference → true", () => {
|
||||
const p = [node({})];
|
||||
expect(breadcrumbPathEqual(p, p)).toBe(true);
|
||||
});
|
||||
|
||||
it("equal by id/slugId/name/icon (different arrays) → true", () => {
|
||||
expect(breadcrumbPathEqual([node({})], [node({})])).toBe(true);
|
||||
});
|
||||
|
||||
it("one side null → false", () => {
|
||||
expect(breadcrumbPathEqual([node({})], null)).toBe(false);
|
||||
expect(breadcrumbPathEqual(null, [node({})])).toBe(false);
|
||||
});
|
||||
|
||||
it("different length → false", () => {
|
||||
expect(
|
||||
breadcrumbPathEqual([node({})], [node({}), node({ id: "b" })]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["name", "icon", "slugId", "id"] as const)(
|
||||
"a changed %s → false (breadcrumb must re-render)",
|
||||
(field) => {
|
||||
expect(
|
||||
breadcrumbPathEqual([node({})], [node({ [field]: "CHANGED" })]),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -18,7 +20,7 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import {
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
usePageBreadcrumbsQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -32,39 +34,84 @@ function getTitle(name: string, icon: string) {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality over a breadcrumb chain by the only fields the breadcrumb renders
|
||||
* (id, slugId, name, icon). Lets the selectAtom below hand back the SAME
|
||||
* reference when an unrelated tree mutation leaves THIS page's ancestor chain
|
||||
* visually unchanged, so the breadcrumb no longer re-renders on every tree
|
||||
* event (it previously subscribed to the whole treeDataAtom).
|
||||
*/
|
||||
export function breadcrumbPathEqual(
|
||||
a: SpaceTreeNode[] | null,
|
||||
b: SpaceTreeNode[] | null,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (
|
||||
a[i].id !== b[i].id ||
|
||||
a[i].slugId !== b[i].slugId ||
|
||||
a[i].name !== b[i].name ||
|
||||
a[i].icon !== b[i].icon
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const { t } = useTranslation();
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: currentPage } = usePageQuery({
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const currentPageId = currentPage?.id;
|
||||
// The page's own ancestor chain, fetched independently of the lazily-built
|
||||
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
|
||||
// while the tree backfills (#218).
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPageId);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
|
||||
// Narrowed subscription: instead of subscribing to the whole treeDataAtom and
|
||||
// recomputing on every tree event, derive ONLY the current page's ancestor
|
||||
// chain. The custom equality returns the previous reference when that chain is
|
||||
// visually unchanged, so an unrelated tree mutation no longer re-renders this
|
||||
// component. Mirrors computeBreadcrumbState's tree-hit branch
|
||||
// (findBreadcrumbPath); the tree-miss/ancestors fallback is applied below.
|
||||
const treePathAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
treeDataAtom,
|
||||
(tree): SpaceTreeNode[] | null =>
|
||||
currentPageId ? findBreadcrumbPath(tree, currentPageId) : null,
|
||||
breadcrumbPathEqual,
|
||||
),
|
||||
[currentPageId],
|
||||
);
|
||||
const treePath = useAtomValue(treePathAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPage) return;
|
||||
|
||||
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
||||
// (#218). It resolves the correct chain when possible and, on a transient
|
||||
// miss, clears a chain left over from a previously-viewed page instead of
|
||||
// showing the wrong trail — while keeping a chain already resolved for THIS
|
||||
// page to avoid a blank flash.
|
||||
// (#218). The tree-hit chain (treePath) always wins when present; otherwise
|
||||
// fall back to the page's own ancestors and the stale-clearing logic — this
|
||||
// reproduces computeBreadcrumbState(fullTree, ancestors, …) exactly, since
|
||||
// its tree-hit branch is precisely findBreadcrumbPath(fullTree, pageId).
|
||||
setBreadcrumbNodes((previous) =>
|
||||
treePath ??
|
||||
computeBreadcrumbState(
|
||||
treeData,
|
||||
null,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
previous,
|
||||
),
|
||||
);
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
}, [currentPage?.id, treePath, ancestors]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
||||
|
||||
@@ -24,7 +24,7 @@ import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
@@ -37,7 +37,7 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
@@ -67,7 +67,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const commentsTriggerProps = useAsideTriggerProps("comments");
|
||||
const tocTriggerProps = useAsideTriggerProps("toc");
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
const { data: page } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const isDeleted = !!page?.deletedAt;
|
||||
@@ -146,7 +146,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page, isLoading } = usePageQuery({
|
||||
const { data: page, isLoading } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
|
||||
@@ -199,8 +199,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
|
||||
const handleCopyAsMarkdown = () => {
|
||||
if (!pageEditor) return;
|
||||
const html = pageEditor.getHTML();
|
||||
const markdown = htmlToMarkdown(html);
|
||||
// Copy the page as canonical markdown through the shared converter (issue
|
||||
// #347), so "Copy as markdown" matches the server export byte-for-byte.
|
||||
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON());
|
||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
||||
clipboard.copy(`${title}${markdown}`);
|
||||
notifications.show({ message: t("Copied") });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
@@ -35,7 +35,7 @@ type TemporaryNoteBannerProps = {
|
||||
*/
|
||||
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom). The
|
||||
// factory constructs it (QueryClient can't be referenced in vi.hoisted — that
|
||||
// runs before imports resolve); we import the SAME mocked instance back to seed
|
||||
// and assert on it.
|
||||
vi.mock("@/main.tsx", async () => {
|
||||
const { QueryClient } = await import("@tanstack/react-query");
|
||||
return { queryClient: new QueryClient() };
|
||||
});
|
||||
|
||||
import { queryClient as h_qc } from "@/main.tsx";
|
||||
import { invalidateOnUpdatePage } from "./page-query";
|
||||
|
||||
const h = { qc: h_qc };
|
||||
|
||||
// invalidateOnUpdatePage is the field-only (title/icon) tree path: instead of a
|
||||
// blanket invalidate it patches the affected node IN PLACE in every cached embed
|
||||
// subtree. The undefined-guard is LOAD-BEARING: a title-only socket event carries
|
||||
// icon:undefined, and without the guard `{...p, icon: undefined}` would WIPE the
|
||||
// icon in every cached subtree.
|
||||
const page = (over: Partial<IPage>): IPage =>
|
||||
({ id: "p1", title: "Old", icon: "📄", spaceId: "s1" }) as IPage &
|
||||
typeof over as IPage;
|
||||
|
||||
describe("invalidateOnUpdatePage — pointwise embed-cache patch", () => {
|
||||
beforeEach(() => {
|
||||
h.qc.clear();
|
||||
});
|
||||
|
||||
it("title-only event updates title but PRESERVES the icon (undefined-guard)", () => {
|
||||
const key = ["page-tree", "parent-1"];
|
||||
h.qc.setQueryData<IPage[]>(key, [
|
||||
{ id: "p1", title: "Old", icon: "📄", spaceId: "s1" } as IPage,
|
||||
{ id: "p2", title: "Other", icon: "📁", spaceId: "s1" } as IPage,
|
||||
]);
|
||||
|
||||
// icon passed as undefined (a title-only update)
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
"New Title",
|
||||
undefined as unknown as string,
|
||||
);
|
||||
|
||||
const patched = h.qc.getQueryData<IPage[]>(key)!;
|
||||
const p1 = patched.find((p) => p.id === "p1")!;
|
||||
const p2 = patched.find((p) => p.id === "p2")!;
|
||||
expect(p1.title).toBe("New Title");
|
||||
expect(p1.icon).toBe("📄"); // preserved, not wiped
|
||||
// Sibling node untouched.
|
||||
expect(p2.title).toBe("Other");
|
||||
expect(p2.icon).toBe("📁");
|
||||
});
|
||||
|
||||
it("icon-only event updates icon but preserves the title", () => {
|
||||
const key = ["page-tree", "parent-1"];
|
||||
h.qc.setQueryData<IPage[]>(key, [
|
||||
{ id: "p1", title: "Keep", icon: "📄", spaceId: "s1" } as IPage,
|
||||
]);
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
undefined as unknown as string,
|
||||
"🚀",
|
||||
);
|
||||
|
||||
const p1 = h.qc.getQueryData<IPage[]>(key)!.find((p) => p.id === "p1")!;
|
||||
expect(p1.icon).toBe("🚀");
|
||||
expect(p1.title).toBe("Keep");
|
||||
});
|
||||
|
||||
// The sidebar-pages cache (InfiniteData) is patched on the same event. It must
|
||||
// carry the SAME undefined-guard as the embed path above — otherwise a
|
||||
// title-only event's icon:undefined would wipe the sidebar entry's icon.
|
||||
const sidebarKey = ["sidebar-pages", { pageId: "parent-1", spaceId: "s1" }];
|
||||
const seedSidebar = () =>
|
||||
h.qc.setQueryData(sidebarKey, {
|
||||
pageParams: [undefined],
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
{ id: "p1", title: "Old", icon: "📄", spaceId: "s1" } as IPage,
|
||||
{ id: "p2", title: "Other", icon: "📁", spaceId: "s1" } as IPage,
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const sidebarItem = (id: string) => {
|
||||
const data = h.qc.getQueryData(sidebarKey) as {
|
||||
pages: { items: IPage[] }[];
|
||||
};
|
||||
return data.pages[0].items.find((p) => p.id === id)!;
|
||||
};
|
||||
|
||||
it("sidebar cache: title-only event updates title but PRESERVES the icon", () => {
|
||||
seedSidebar();
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
"New Title",
|
||||
undefined as unknown as string,
|
||||
);
|
||||
|
||||
const p1 = sidebarItem("p1");
|
||||
expect(p1.title).toBe("New Title");
|
||||
expect(p1.icon).toBe("📄"); // preserved, not wiped
|
||||
// Sibling untouched.
|
||||
const p2 = sidebarItem("p2");
|
||||
expect(p2.title).toBe("Other");
|
||||
expect(p2.icon).toBe("📁");
|
||||
});
|
||||
|
||||
it("sidebar cache: icon-only event updates icon but PRESERVES the title", () => {
|
||||
seedSidebar();
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
undefined as unknown as string,
|
||||
"🚀",
|
||||
);
|
||||
|
||||
const p1 = sidebarItem("p1");
|
||||
expect(p1.icon).toBe("🚀");
|
||||
expect(p1.title).toBe("Old"); // preserved, not wiped
|
||||
});
|
||||
|
||||
it("does not touch a subtree that lacks the updated node", () => {
|
||||
const otherKey = ["page-tree", "unrelated"];
|
||||
const before = [
|
||||
{ id: "x1", title: "X", icon: "❌", spaceId: "s1" } as IPage,
|
||||
];
|
||||
h.qc.setQueryData<IPage[]>(otherKey, before);
|
||||
|
||||
invalidateOnUpdatePage("s1", "parent-1", "p1", "New", "🚀");
|
||||
|
||||
// Same reference back — the subtree without p1 is left as-is.
|
||||
expect(h.qc.getQueryData<IPage[]>(otherKey)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,10 @@ export function usePageQuery(
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Keep the previously-loaded page visible while navigating to a new one
|
||||
// instead of flashing a blank/skeleton frame (the new page's content
|
||||
// streams in when ready). isLoading stays true only for the very first load.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,6 +70,61 @@ export function usePageQuery(
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page view that omits the large, frequently-changing `content` field. Every
|
||||
* other field is preserved, so consumers that read only metadata (title, icon,
|
||||
* permissions, id, creator, timestamps, …) keep working unchanged.
|
||||
*/
|
||||
export type IPageMeta = Omit<IPage, "content">;
|
||||
|
||||
function selectPageMeta(page: IPage): IPageMeta {
|
||||
// Drop `content`; react-query's structural sharing (replaceEqualDeep) then
|
||||
// returns the SAME reference whenever the remaining fields are unchanged, so a
|
||||
// pure content churn (typing / debouncedUpdateContent, collab `page.updated`)
|
||||
// no longer changes this slice's identity and its ~13 subscribers don't
|
||||
// re-render on every keystroke wave.
|
||||
const { content: _content, ...meta } = page;
|
||||
return meta as IPageMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata-only variant of {@link usePageQuery}. Shares the SAME query cache
|
||||
* entry (`["pages", pageId]`, full object incl. content), but this hook returns
|
||||
* a stable content-less slice so peripheral subscribers stop re-rendering on
|
||||
* every content update. Use it anywhere the full `content` is not read.
|
||||
*/
|
||||
export function usePageMetaQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
): UseQueryResult<IPageMeta, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["pages", pageInput.pageId],
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
select: selectPageMeta,
|
||||
// Match usePageQuery: keep the previous page's metadata visible while
|
||||
// navigating so the periphery (header, breadcrumb, …) doesn't flash blank.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Mirror usePageQuery's cross-key alias write so a page fetched by one
|
||||
// identifier is also cached under the other. The cache stores the FULL page
|
||||
// (select only narrows what THIS hook returns), so read the full object back
|
||||
// from the cache and alias THAT — never the content-less slice.
|
||||
useEffect(() => {
|
||||
if (!query.data) return;
|
||||
const full = queryClient.getQueryData<IPage>(["pages", pageInput.pageId]);
|
||||
if (!full) return;
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(["pages", full.slugId], full);
|
||||
} else {
|
||||
queryClient.setQueryData(["pages", full.id], full);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
@@ -351,6 +410,12 @@ export function useRecentChangesQuery(spaceId?: string) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
// KEEP refetchOnMount:true (against the global default false): recent-changes
|
||||
// IS invalidated on page create/update/move/delete, but invalidateQueries only
|
||||
// marks an UNMOUNTED query stale — it doesn't refetch it. The widget isn't
|
||||
// always mounted, so an event that lands while it's unmounted leaves it stale,
|
||||
// and the global refetchOnMount:false would not re-fetch on remount. The mount
|
||||
// refetch closes that gap.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
@@ -367,6 +432,9 @@ export function useCreatedByQuery(params?: {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
// KEEP refetchOnMount:true: the "created-by" key is never invalidated (no
|
||||
// socket/mutation path), so the mount refetch is its ONLY freshness mechanism
|
||||
// — without it the list shows stale cache on navigation.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
@@ -380,8 +448,14 @@ export function useDeletedPagesQuery(
|
||||
queryFn: () => getDeletedPages(spaceId, params),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
// KEEP refetchOnMount:true: ["trash-list"] IS invalidated by the
|
||||
// move-to-trash / delete / restore mutations, but invalidateQueries only marks
|
||||
// an unmounted query stale — it doesn't refetch it. The trash panel isn't
|
||||
// usually mounted when a page is trashed, so on opening it the global
|
||||
// refetchOnMount:false would show a stale list; the mount refetch closes that.
|
||||
// (Do NOT remove the three trash-list invalidations — they are not dead code.)
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -516,7 +590,35 @@ export function invalidateOnUpdatePage(
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
// Scoped page-tree refresh (was a blanket `invalidatePageTree()`): this is the
|
||||
// FIELD-only update path (title/icon — no structural change), and the sidebar
|
||||
// tree is already updated pointwise (applyUpdateOne / optimistic setData) plus
|
||||
// via the sidebar-pages cache below. Invalidating ALL ["page-tree"] queries
|
||||
// here refetched every open recursive subpages-embed block on each
|
||||
// rename/icon-change — pure duplicate work. Instead patch just the affected
|
||||
// node IN PLACE in every cached embed subtree: same visible result, no network
|
||||
// churn, no full embed-tree rebuild. Structural events (create/move/delete)
|
||||
// keep the blanket invalidate in their own helpers.
|
||||
const pageTreeMatches = queryClient.getQueriesData<IPage[]>({
|
||||
queryKey: ["page-tree"],
|
||||
});
|
||||
pageTreeMatches.forEach(([key, items]) => {
|
||||
if (!items || !items.some((p) => p.id === id)) return;
|
||||
queryClient.setQueryData<IPage[]>(key, (old) =>
|
||||
old?.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
// Guard undefined so a title-only event can't wipe the icon (and
|
||||
// vice versa) in the embed cache.
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
@@ -534,7 +636,14 @@ export function invalidateOnUpdatePage(
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
? {
|
||||
...sidebarPage,
|
||||
// Guard undefined so a title-only event can't wipe the icon
|
||||
// (and vice versa) in the sidebar-pages cache — mirrors the
|
||||
// embed-cache patch above.
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-moda
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import {
|
||||
useDeletePageMutation,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
useRestorePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
@@ -25,7 +25,7 @@ type DeletedPageBannerProps = {
|
||||
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
@@ -52,7 +52,11 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { handleDelete } = useTreeMutation(node.spaceId);
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
// Setter-only: the tree value is read only imperatively inside the duplicate
|
||||
// handler (via `store` below), never at render, so useSetAtom avoids
|
||||
// re-rendering every row's NodeMenu on any tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -125,8 +129,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
try {
|
||||
const duplicatedPage = await duplicatePage({ pageId: node.id });
|
||||
|
||||
// figure out parent + insertion index
|
||||
const siblings = treeModel.siblingsOf(data, node.id);
|
||||
// figure out parent + insertion index (read the live tree imperatively)
|
||||
const siblings = treeModel.siblingsOf(store.get(treeDataAtom), node.id);
|
||||
const parentId = siblings?.parentId ?? null;
|
||||
const currentIndex = siblings?.index ?? 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -51,7 +51,11 @@ export function SpaceTreeRow({
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
// Setter-only: subscribing to the whole treeDataAtom (via useAtom) re-rendered
|
||||
// every virtualized row on any tree event, bypassing the DocTreeRow memo. This
|
||||
// row never reads the tree value, only writes it, so useSetAtom avoids the
|
||||
// value subscription.
|
||||
const setTreeData = useSetAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
usePageMetaQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
||||
fetchAllAncestorChildrenMock(...args),
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
usePageMetaQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user