Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4b2af3d34a | |||
| ab40e82123 | |||
| dca9f2aaf0 | |||
| 51ded06fde | |||
| 456a91d289 | |||
| 515c08afed | |||
| babc42c2ff | |||
| 6ee814b7f3 | |||
| a6ff7623db |
@@ -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
|
||||
@@ -217,6 +251,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
|
||||
@@ -243,6 +288,29 @@ MCP_DOCMOST_PASSWORD=
|
||||
# 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).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
@@ -290,6 +358,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -248,6 +381,22 @@ pnpm collab:dev # run the collaboration server process standalone (
|
||||
> 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
|
||||
@@ -306,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. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- 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`.
|
||||
|
||||
@@ -321,8 +470,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||
- 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`.
|
||||
- **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
|
||||
|
||||
|
||||
+218
@@ -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,48 @@ 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)
|
||||
- **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"
|
||||
@@ -177,6 +344,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 +453,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"]
|
||||
|
||||
+106
@@ -350,3 +350,109 @@ roles:
|
||||
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.
|
||||
+106
@@ -349,3 +349,109 @@ roles:
|
||||
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: Возьми в работу текущую страницу — на ней расшифровка созвона. Если её нет, спроси у пользователя, где расшифровка.
|
||||
@@ -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: 8
|
||||
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": 8,
|
||||
"hash": "0e76efa180c3e443c8856b8787e9643923d10486b373ce078c12dc16eb04611b"
|
||||
"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",
|
||||
@@ -98,6 +100,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
+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,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError } 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
|
||||
// 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 once, guarding against a reload loop
|
||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||
// flag is already set we fall through to the manual recovery UI below.
|
||||
try {
|
||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
} 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,7 +36,7 @@ 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,
|
||||
@@ -245,7 +245,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);
|
||||
@@ -291,6 +293,10 @@ export default function AiChatWindow() {
|
||||
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
|
||||
? 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,
|
||||
);
|
||||
|
||||
// #430: re-stamp the activity clock whenever the polled rows change while the
|
||||
@@ -336,7 +342,7 @@ 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,7 +28,10 @@ const h = vi.hoisted(() => ({
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
prepareReconnectToStreamRequest?: () => { api?: string };
|
||||
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
|
||||
fetch?: (
|
||||
input: unknown,
|
||||
init?: { method?: string; body?: unknown },
|
||||
) => Promise<unknown>;
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -200,6 +203,290 @@ describe("ChatThread — send now (#198)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #486: the final onFinish -> flushNext() must be gated on the live-mount flag.
|
||||
// A clean onFinish can land AFTER the thread unmounts (New-chat / chat-switch
|
||||
// mid-stream — the async attach/resume settles late); flushing then dequeues and
|
||||
// re-POSTs a queued message from an abandoned thread (a "ghost" send).
|
||||
describe("ChatThread — onFinish flush gated on mount (#486)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
it("a clean onFinish WHILE MOUNTED flushes the queued message (control)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// Mounted: the queue flushes normally.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("a clean onFinish AFTER unmount does NOT flush (no ghost send)", () => {
|
||||
const { unmount } = renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
|
||||
h.state.sendMessage.mockClear();
|
||||
|
||||
// Chat switched away mid-stream: the streamer unmounts...
|
||||
unmount();
|
||||
// ...and a late, clean onFinish lands on the abandoned thread.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// Gated on mountedRef: NOTHING is sent from the dead thread.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #396: in autonomous mode a live sendNow must additionally request the
|
||||
// AUTHORITATIVE server stop of the detached run (a local abort is only a client
|
||||
// disconnect the server ignores) and arm a bounded 409 retry so the re-POST
|
||||
// converges once the one-active-run slot frees. Legacy mode is unchanged.
|
||||
describe("ChatThread — send now server-stop + supersede retry (#396)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
// A settled assistant tail => no mount resume (attemptResumeRef false), so the
|
||||
// "Send now" button is visible for the NEW local streaming turn while
|
||||
// autonomous runs are enabled.
|
||||
const settledTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "succeeded", "done"),
|
||||
];
|
||||
|
||||
it("autonomous: sendNow during a live stream calls onServerStop with the chat id", () => {
|
||||
const { onServerStop } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: settledTail(),
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(onServerStop).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("legacy (autonomous off): sendNow does NOT call onServerStop and does NOT retry the send", async () => {
|
||||
const { onServerStop } = renderThread({
|
||||
autonomousRunsEnabled: false,
|
||||
initialRows: settledTail(),
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
expect(onServerStop).not.toHaveBeenCalled();
|
||||
|
||||
// The supersede retry must NOT be armed: a POST that 409s is returned as-is
|
||||
// (single fetch, no retry).
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("armed supersede send retries 409 A_RUN_ALREADY_ACTIVE and succeeds once the slot frees", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
// Arm the retry by performing a live sendNow (autonomous branch sets the ref).
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
// First POST: the old detached run still holds the slot -> 409.
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
)
|
||||
// Retry: the server stop settled the old run -> 200.
|
||||
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("supersede retry is one-shot: a later send (ref cleared) does NOT retry a 409", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now")); // arms the one-shot
|
||||
|
||||
// First armed send: immediately succeeds, consuming the arm.
|
||||
let fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A subsequent send is NOT armed -> a 409 is returned as-is (no retry).
|
||||
fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("supersede retry is bounded: exhaustion surfaces the 409 error", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// Every attempt 409s -> after 4 attempts the last 409 surfaces.
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
// 4 attempts total (1 immediate + 3 backoff retries), then give up.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("armed supersede send does NOT retry a non-409 status", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("boom", { status: 500 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
// Strand-path regression: sendNow arms the supersede retry, but if the promoted
|
||||
// head is removed before the abort's onFinish lands, flushNext() sends nothing
|
||||
// (returns false) and NO re-POST consumes the arm. The arm must be disarmed on
|
||||
// that no-send branch so the NEXT unrelated NORMAL send does not inherit it and
|
||||
// silently retry a genuine 409 (e.g. a legitimate two-tab conflict) 4x instead
|
||||
// of surfacing it immediately.
|
||||
it("strand-path: a stranded supersede arm (flushNext no-send) does NOT retry a later normal 409", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
// Arm the retry via a live autonomous sendNow (promotes the head + arms).
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// Remove the promoted head BEFORE the abort lands, so flushNext() returns
|
||||
// false (no POST) and the arm would strand without the disarm fix.
|
||||
fireEvent.click(screen.getByLabelText("Remove queued message"));
|
||||
|
||||
// The abort's onFinish now takes the flushOnAbortRef branch, calls flushNext()
|
||||
// which finds an empty queue and returns false -> the no-send disarm must run.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a1", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// No re-POST was sent (nothing to flush).
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// A subsequent NORMAL send that 409s must be returned as-is (exactly 1 fetch):
|
||||
// the stranded arm must NOT cause the genuine 409 to be retried.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("armed supersede send does NOT retry a 409 with a different (non-A_RUN_ALREADY_ACTIVE) body", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "SOMETHING_ELSE" }), {
|
||||
status: 409,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let res!: Response;
|
||||
await act(async () => {
|
||||
res = (await h.state.transport!.fetch!("http://x", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
})) as Response;
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
// #388: the editor selection is snapshotted at send time and nested inside
|
||||
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||
// fresh snapshot.
|
||||
|
||||
@@ -70,6 +70,36 @@ const RECONNECT_MAX_ATTEMPTS = 5;
|
||||
// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s.
|
||||
const RECONNECT_BASE_DELAY_MS = 1000;
|
||||
|
||||
// #396: bounded retry for the "Interrupt and send now" re-send when it races the
|
||||
// authoritative server stop of the just-superseded detached run. The re-POST can
|
||||
// arrive before the old run has released the one-active-run slot, so the server
|
||||
// returns 409 A_RUN_ALREADY_ACTIVE. The server stop guarantees the slot frees, so
|
||||
// a few short backoffs converge. 4 total attempts: attempt 1 fires immediately,
|
||||
// then these are the waits BEFORE attempts 2, 3 and 4 (150ms, 300ms, 600ms). If
|
||||
// all 4 attempts 409, the last 409 surfaces (the banner) — acceptable per #396.
|
||||
const SUPERSEDE_RETRY_DELAYS_MS = [150, 300, 600];
|
||||
// The server error code that means "another run is already active for this chat".
|
||||
const A_RUN_ALREADY_ACTIVE = "A_RUN_ALREADY_ACTIVE";
|
||||
|
||||
/**
|
||||
* #396: defensively decide whether a 409 response is the one-active-run gate
|
||||
* rejection (code A_RUN_ALREADY_ACTIVE) vs. some other 409. Reads a CLONE so the
|
||||
* original response body stays intact for the caller when it is returned as-is.
|
||||
* Any parse failure or unexpected shape => false (do NOT retry).
|
||||
*/
|
||||
async function isRunAlreadyActive(response: Response): Promise<boolean> {
|
||||
try {
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
return (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
(body as { code?: unknown }).code === A_RUN_ALREADY_ACTIVE
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The page the user is currently viewing, sent as chat context. */
|
||||
export interface OpenPageContext {
|
||||
id: string;
|
||||
@@ -326,6 +356,26 @@ export default function ChatThread({
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// #396: one-shot arm for the bounded 409 A_RUN_ALREADY_ACTIVE retry on the
|
||||
// "Interrupt and send now" re-send in autonomous mode. sendNow triggers the
|
||||
// authoritative server stop of the detached run, but that stop and the
|
||||
// onFinish->flushNext re-POST race: the new POST can hit the one-active-run
|
||||
// gate before the old detached run has settled, yielding a spurious 409. When
|
||||
// this ref is armed, the transport's send path retries that 409 with a short
|
||||
// bounded backoff (the server stop guarantees convergence). A normal send (ref
|
||||
// not armed) must STILL fail a 409 instantly (e.g. a genuine two-tab conflict).
|
||||
//
|
||||
// INVARIANT: sendNow arms this only to be consumed by the ONE re-POST that
|
||||
// flushNext fires from onFinish. But that re-POST does not always happen (the
|
||||
// promoted head may be gone, the finish may be a resumed turn, or the arm may
|
||||
// race a stale finish). To keep the arm strictly one-shot it is disarmed on
|
||||
// EVERY path where the paired interrupt one-shots (flushOnAbortRef /
|
||||
// interruptNextSendRef) are cleared without a POST: the transport POST branch
|
||||
// consumes it (read-and-clear), the onFinish `!flushNext()` no-send branch
|
||||
// clears it, and the isStreaming-defuse effect clears it symmetrically. So it
|
||||
// can never leak into a later, unrelated send and retry that send's genuine 409.
|
||||
const supersedeRetryRef = useRef(false);
|
||||
|
||||
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
|
||||
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
|
||||
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
|
||||
@@ -382,7 +432,43 @@ export default function ChatThread({
|
||||
}`,
|
||||
}),
|
||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
|
||||
if ((init.method ?? "GET") !== "GET") {
|
||||
// Send path (POST). #396: read-and-clear the one-shot supersede arm
|
||||
// here so it is strictly scoped to THIS send. When unarmed, behave
|
||||
// exactly as before — a single fetch, a 409 surfaces instantly (a
|
||||
// genuine two-tab conflict must NOT be retried).
|
||||
const supersede = supersedeRetryRef.current;
|
||||
supersedeRetryRef.current = false;
|
||||
if (!supersede) return fetch(input, init);
|
||||
// Buffer a ReadableStream body once so each retry can replay it.
|
||||
// DefaultChatTransport sends the body as a JSON STRING (replayable as
|
||||
// is), but guard defensively in case a future SDK streams it.
|
||||
let sendInit = init;
|
||||
if (init.body instanceof ReadableStream) {
|
||||
const buffered = await new Response(init.body).arrayBuffer();
|
||||
sendInit = { ...init, body: buffered };
|
||||
}
|
||||
// Bounded retry: attempt 1 fires immediately, then wait between
|
||||
// attempts per SUPERSEDE_RETRY_DELAYS_MS. Retry ONLY on a real
|
||||
// 409 A_RUN_ALREADY_ACTIVE; any other status/body is returned as-is.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await fetch(input, sendInit);
|
||||
if (
|
||||
response.status !== 409 ||
|
||||
attempt >= SUPERSEDE_RETRY_DELAYS_MS.length ||
|
||||
!(await isRunAlreadyActive(response))
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
// The old detached run has not released the one-active-run slot
|
||||
// yet; the server stop we requested guarantees it will, so back off
|
||||
// and re-POST (the 409 fired before the user message was persisted,
|
||||
// so re-POSTing is safe — no duplicate rows).
|
||||
await new Promise((r) =>
|
||||
setTimeout(r, SUPERSEDE_RETRY_DELAYS_MS[attempt]),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
|
||||
// for observer Stop / unmount abort.
|
||||
const controller = new AbortController();
|
||||
@@ -562,13 +648,24 @@ export default function ChatThread({
|
||||
setStopNotice(null);
|
||||
// If the promoted head vanished (e.g. the user removed it before the
|
||||
// abort landed) flushNext sends nothing — clear the one-shot interrupt
|
||||
// tag so it can't leak onto the next unrelated send. On a real send the
|
||||
// tag is consumed by prepareSendMessagesRequest and stays untouched.
|
||||
if (!flushNext()) interruptNextSendRef.current = false;
|
||||
// tag AND the #396 supersede arm so neither can leak onto the next
|
||||
// unrelated send (no re-POST will consume the arm here). On a real send
|
||||
// the tag is consumed by prepareSendMessagesRequest and the arm by the
|
||||
// transport POST branch, so both stay untouched then.
|
||||
if (!flushNext()) {
|
||||
interruptNextSendRef.current = false;
|
||||
supersedeRetryRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
// Gate the final flush on the live-mount flag (#486): a clean onFinish can
|
||||
// land AFTER this thread unmounted (a New-chat / chat-switch mid-stream —
|
||||
// the async attach/resume settles late). Flushing then dequeues and POSTs a
|
||||
// queued message from an abandoned thread — a "ghost" send / ghost chat.
|
||||
// Every other queue side effect already guards on mountedRef; this last one
|
||||
// was the gap.
|
||||
if (mountedRef.current) flushNext();
|
||||
},
|
||||
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
||||
// Log the raw failure here for devtools; the UI shows a friendly classified
|
||||
@@ -873,6 +970,30 @@ export default function ChatThread({
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
// #396: in autonomous mode the turn is a DETACHED run — a local stop()
|
||||
// is only a client disconnect the server ignores, so the run keeps going.
|
||||
// The onFinish->flushNext re-POST would then hit the one-active-run gate
|
||||
// and get a spurious 409 A_RUN_ALREADY_ACTIVE. Mirror handleStop: request
|
||||
// the AUTHORITATIVE server stop so the detached run settles, and arm the
|
||||
// one-shot bounded 409 retry BEFORE stop() so the re-send converges once
|
||||
// the slot frees. Read chatId live from chatIdRef (adopted at the `start`
|
||||
// chunk). If it is not known yet (brand-new chat, first moment of its
|
||||
// first turn), defer the server stop via stopPendingRef exactly as
|
||||
// handleStop does — the onServerChatId adoption effect fires it once the
|
||||
// id lands; the retry stays armed so the re-send still converges then.
|
||||
if (autonomousRunsEnabled) {
|
||||
supersedeRetryRef.current = true; // arm the bounded 409 retry
|
||||
if (chatIdRef.current) {
|
||||
onServerStop?.(chatIdRef.current);
|
||||
} else {
|
||||
// Same #234-F5 sub-window limitation documented in handleStop: if the
|
||||
// local abort below cancels the reader before the `start` chunk lands,
|
||||
// the adoption effect never runs and the deferred stop never fires. Not
|
||||
// a regression; at minimum we don't strand refs (the isStreaming effect
|
||||
// defuses stopPendingRef on the next turn start).
|
||||
stopPendingRef.current = true;
|
||||
}
|
||||
}
|
||||
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
|
||||
} else {
|
||||
// Nothing to interrupt: just send it now (no interrupt note).
|
||||
@@ -884,7 +1005,7 @@ export default function ChatThread({
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop, setResumedTurnPair],
|
||||
[setQueue, stop, setResumedTurnPair, autonomousRunsEnabled, onServerStop],
|
||||
);
|
||||
|
||||
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||
@@ -944,6 +1065,13 @@ export default function ChatThread({
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
// #396: symmetric with the other one-shot interrupt flags — defuse a stale
|
||||
// supersede arm that was set but whose expected re-POST never fired (the
|
||||
// turn finished in the same tick as the click, or the promoted head was
|
||||
// gone), so it can never leak into this (or a later) turn's send and retry
|
||||
// that send's genuine 409. A legit arm is consumed by the transport POST
|
||||
// branch before this new turn streams, so this does not clobber it.
|
||||
supersedeRetryRef.current = false;
|
||||
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
|
||||
// previous turn that never adopted an id, so it can never fire against this
|
||||
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
|
||||
|
||||
@@ -47,6 +47,13 @@ interface MessageItemProps {
|
||||
* 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,
|
||||
@@ -125,6 +132,7 @@ function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
@@ -219,6 +227,7 @@ function MessageItem({
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
showErrors={showErrors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +293,7 @@ export function arePropsEqual(
|
||||
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) —
|
||||
|
||||
@@ -32,6 +32,12 @@ interface MessageListProps {
|
||||
* 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).
|
||||
@@ -127,6 +133,7 @@ export default function MessageList({
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
@@ -217,6 +224,7 @@ export default function MessageList({
|
||||
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
|
||||
|
||||
@@ -30,6 +30,16 @@ interface ToolCallCardProps {
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +51,7 @@ export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
@@ -74,7 +85,7 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
{state === "error" && showErrors && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
</Text>
|
||||
|
||||
@@ -53,8 +53,12 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
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 }),
|
||||
@@ -63,6 +67,7 @@ export function useAiChatsQuery() {
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -93,6 +98,9 @@ export function useAiChatMessagesQuery(
|
||||
// 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 ?? ""),
|
||||
@@ -103,7 +111,7 @@ export function useAiChatMessagesQuery(
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId,
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,25 @@ 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",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
|
||||
@@ -24,6 +24,21 @@ 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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,45 @@ 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.
|
||||
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?: { undoStack: unknown[]; redoStack: unknown[] } }
|
||||
| undefined;
|
||||
if (yState?.undoManager) {
|
||||
return {
|
||||
canUndo: yState.undoManager.undoStack.length > 0,
|
||||
canRedo: yState.undoManager.redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||
return {
|
||||
canUndo: undoDepth(state) > 0,
|
||||
canRedo: redoDepth(state) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
@@ -31,6 +64,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 +76,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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
@@ -76,7 +76,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const spaceIdRef = useRef(spaceId);
|
||||
spaceIdRef.current = spaceId;
|
||||
const { data: currentPage } = usePageQuery({
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -34,7 +34,10 @@ export type UseTreeMutation = {
|
||||
|
||||
export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const { t } = useTranslation();
|
||||
const [, setData] = useAtom(treeDataAtom);
|
||||
// Setter-only: this hook never reads the tree reactively (handlers read the
|
||||
// live value imperatively via `store` below), so useSetAtom avoids
|
||||
// re-rendering SpaceSidebar on every tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
// `store` reads the *current* treeDataAtom imperatively in handlers — avoids
|
||||
// stale-closure issues when the caller updates the tree (e.g. lazy-load
|
||||
// children) and then immediately invokes a handler.
|
||||
|
||||
@@ -168,6 +168,10 @@ export default function ShareAiWidget({
|
||||
// Anonymous reader: suppress the tool-argument summary line so the
|
||||
// agent's raw query/argument text isn't shown on the public share.
|
||||
showInput={false}
|
||||
// Anonymous reader: never paint a tool's raw errorText (it can carry
|
||||
// internal detail). This is the render gate; the bytes are also
|
||||
// sanitized server-side in PublicShareChatToolsService.forShare (#394).
|
||||
showErrors={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||
|
||||
export default function ShareLayout() {
|
||||
return (
|
||||
<ShareShell>
|
||||
<Outlet />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="60vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ShareShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
usePageMetaQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -37,7 +37,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const { data: page } = usePageMetaQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
|
||||
@@ -38,6 +38,11 @@ export function useGetSpacesQuery(
|
||||
queryKey: ["spaces", params],
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
// KEEP refetchOnMount:true (against the global default false): the ["spaces"]
|
||||
// key is invalidated only by same-tab mutations (no socket path), so a
|
||||
// cross-actor change — an admin adding/removing THIS user from a space — has
|
||||
// no local mutation or socket event and would leave the space list stale until
|
||||
// a hard reload. The mount refetch is its only cross-actor freshness path.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function useWatchedSpaceIds(): Set<string> {
|
||||
const { data } = useQuery({
|
||||
queryKey: [WATCHED_SPACE_IDS_KEY],
|
||||
queryFn: () => getWatchedSpaceIds(),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const items = data?.items;
|
||||
|
||||
@@ -19,7 +19,11 @@ export const useQuerySubscription = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
socket?.on("message", (event) => {
|
||||
if (!socket) return;
|
||||
// Named handler + off() cleanup (mirrors use-notification-socket). Without
|
||||
// cleanup, every socket recreation / effect re-run stacked another listener,
|
||||
// so a single broadcast fired duplicated invalidateQueries / setQueryData.
|
||||
const handleMessage = (event) => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
@@ -163,6 +167,11 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
socket.on("message", handleMessage);
|
||||
return () => {
|
||||
socket.off("message", handleMessage);
|
||||
};
|
||||
}, [queryClient, socket]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -16,7 +16,10 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
// Setter-only: this hook writes the tree from socket events but never reads it
|
||||
// reactively, so useSetAtom avoids re-rendering UserProvider (its host) on
|
||||
// every tree event.
|
||||
const setTreeData = useSetAtom(treeDataAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,7 +40,11 @@ export const useTreeSocket = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("message", (event: WebSocketEvent) => {
|
||||
if (!socket) return;
|
||||
// Named handler + off() cleanup (mirrors use-notification-socket). Without
|
||||
// cleanup, every socket recreation / effect re-run stacked another listener,
|
||||
// so a single broadcast fired duplicated tree walks after each reconnect.
|
||||
const handleMessage = (event: WebSocketEvent) => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
@@ -64,6 +71,11 @@ export const useTreeSocket = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [socket]);
|
||||
};
|
||||
|
||||
socket.on("message", handleMessage);
|
||||
return () => {
|
||||
socket.off("message", handleMessage);
|
||||
};
|
||||
}, [socket, queryClient, setTreeData]);
|
||||
};
|
||||
|
||||
@@ -243,6 +243,5 @@ export function useAppVersion(
|
||||
queryFn: () => getAppVersion(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hr
|
||||
enabled: isEnabled,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||
// polyfilled to support execCommand fallback
|
||||
import { useState } from "react";
|
||||
import { execCommandCopy } from "@docmost/editor-ext";
|
||||
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||
|
||||
export type UseClipboardOptions = {
|
||||
timeout?: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import bytes from "bytes";
|
||||
import { castToBoolean } from "@/lib/utils.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Client-local execCommand copy fallback (previously imported from
|
||||
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||
// engine — through the eager startup graph. Behavior is identical to the
|
||||
// editor-ext helper it replaces.
|
||||
export function execCommandCopy(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeUrl } from "./sanitize-url";
|
||||
|
||||
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||
// test so the extracted copy keeps the same guarantees.
|
||||
describe("sanitizeUrl", () => {
|
||||
it("blocks dangerous schemes (returns empty string)", () => {
|
||||
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty / undefined input", () => {
|
||||
expect(sanitizeUrl(undefined)).toBe("");
|
||||
expect(sanitizeUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("allows safe https, relative file and mailto URLs", () => {
|
||||
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||
/^https:\/\/example\.com\/page/,
|
||||
);
|
||||
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||
"mailto:user@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||
// dependency.
|
||||
export function sanitizeUrl(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
|
||||
const sanitized = braintreeSanitizeUrl(url);
|
||||
|
||||
// Return an empty string instead of "about:blank".
|
||||
return sanitized === "about:blank" ? "" : sanitized;
|
||||
}
|
||||
+60
-27
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import {
|
||||
getPostHogHost,
|
||||
getPostHogKey,
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
import { initVitals } from "@/lib/telemetry/vitals";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -35,15 +34,6 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
}
|
||||
|
||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||
// subscribing to any observer; non-sampled sessions send nothing.
|
||||
initVitals();
|
||||
@@ -51,19 +41,62 @@ initVitals();
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
function renderApp() {
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||
404 after a deploy is caught and recovered here instead of
|
||||
blanking the whole app. */}
|
||||
<ChunkLoadErrorBoundary>
|
||||
<App />
|
||||
</ChunkLoadErrorBoundary>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function initAnalytics() {
|
||||
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||
// self-hosted builds never download it. The gate is kept identical to the
|
||||
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||
// simply deferred behind it.
|
||||
//
|
||||
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||
// working app without analytics instead of a permanently blank page.
|
||||
//
|
||||
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||
// an already-initialized `client` is a no-op — all capture goes through the
|
||||
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||
// App (running every mount effect twice and dropping local state / focus /
|
||||
// in-progress input on cloud cold-load) for no functional gain.
|
||||
if (!(isCloud() && isPostHogEnabled)) return;
|
||||
try {
|
||||
const { default: posthog } = await import("posthog-js");
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||
}
|
||||
}
|
||||
|
||||
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||
// initialized after, without re-rendering the tree.
|
||||
renderApp();
|
||||
void initAnalytics();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
@@ -11,7 +11,7 @@ export default function PageRedirect() {
|
||||
data: page,
|
||||
isLoading: pageIsLoading,
|
||||
isError,
|
||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
} = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Skeleton } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
const MemoizedFullEditor = React.memo(FullEditor);
|
||||
@@ -58,7 +58,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
@@ -87,7 +87,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,3 +116,18 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight loading placeholder shown instead of a blank fragment while the
|
||||
// page (or its space) is loading, so navigation into a not-yet-cached page no
|
||||
// longer flashes empty. Approximates the title + first content lines.
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton height={34} width="45%" mt="xl" radius="sm" />
|
||||
<Skeleton height={16} mt="xl" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" width="85%" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" width="70%" radius="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import * as path from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
// Emit .br and .gz next to every built asset so the server can serve the
|
||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||
compression({
|
||||
algorithms: ["brotliCompress", "gzip"],
|
||||
// vite-plugin-compression2's default `include` only covers text-ish
|
||||
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
|
||||
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
|
||||
// they are brotli/gzip'd once at build time and served via
|
||||
// @fastify/static preCompressed — otherwise @fastify/compress would
|
||||
// re-brotli them on EVERY request. The default types are repeated here
|
||||
// because setting `include` replaces (does not extend) the default.
|
||||
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
|
||||
// index.html is rewritten at server boot (window.CONFIG injection); a
|
||||
// precompressed copy would go stale — NEVER precompress it.
|
||||
exclude: [/index\.html$/],
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
@@ -63,6 +82,20 @@ export default defineConfig(({ mode }) => {
|
||||
name: "vendor-mantine",
|
||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||
},
|
||||
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||
// a small module shared with the (eager) react-i18next runtime into
|
||||
// that group chunk, which then drags the whole ~590KB editor engine
|
||||
// into the eager modulepreload graph. Left to the default splitting,
|
||||
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||
// eager references it).
|
||||
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||
// node views (never in the startup path).
|
||||
{
|
||||
name: "vendor-katex",
|
||||
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@fastify/compress": "^9.0.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||
import { encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
||||
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||
|
||||
@@ -10,13 +11,12 @@ import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||
*
|
||||
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
||||
* this exercises the REAL server import conversion path that ImportService uses
|
||||
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
|
||||
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
|
||||
* that such a node is DETECTED and STRIPPABLE — so the share read path's
|
||||
* (`markdownToProseMirror`, the canonical converter — issue #345/#347) and
|
||||
* asserts that such a node is DETECTED and STRIPPABLE — so the share read path's
|
||||
* master-toggle strip can remove it when the workspace toggle is OFF.
|
||||
*/
|
||||
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
||||
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
|
||||
it('round-trips through markdownToProseMirror and is DETECTED (base64 data-source)', async () => {
|
||||
const source = '<script>steal()</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const md = [
|
||||
@@ -27,12 +27,9 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
||||
'World',
|
||||
].join('\n');
|
||||
|
||||
const html = await markdownToHtml(md);
|
||||
// marked preserves the raw block-level div verbatim.
|
||||
expect(html).toContain('data-type="htmlEmbed"');
|
||||
|
||||
const json = htmlToJson(html);
|
||||
// The div parses into a real htmlEmbed node carrying the decoded source.
|
||||
// The canonical importer parses the raw block-level div into a real
|
||||
// htmlEmbed node carrying the decoded source.
|
||||
const json = await markdownToProseMirror(md);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
|
||||
// Because it is detected, the share master-toggle strip can remove it.
|
||||
@@ -59,8 +56,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
||||
// therefore stripping) does not depend on the source being well-formed, so
|
||||
// the bypass cannot be hidden by sending a malformed data-source.
|
||||
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
||||
const html = await markdownToHtml(md);
|
||||
const json = htmlToJson(html);
|
||||
const json = await markdownToProseMirror(md);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,9 @@ function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||
// #487: terminal finalize now goes through the CONDITIONAL write. Default
|
||||
// returns a truthy row (the run WAS active -> this call wrote it).
|
||||
finalizeIfActive: jest.fn(async () => ({ id: 'run-1', status: 'succeeded' })),
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
findLatestByChat: jest.fn(async () => undefined),
|
||||
@@ -336,14 +339,12 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
// #487: the terminal write is CONDITIONAL (finalizeIfActive); finishedAt is
|
||||
// stamped inside the repo method, so the service passes just status + error.
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: 'provider blew up',
|
||||
finishedAt: expect.any(Date),
|
||||
}),
|
||||
expect.objectContaining({ status: 'failed', error: 'provider blew up' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -366,8 +367,8 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||
@@ -389,8 +390,8 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
const updateGate = new Promise((res) => {
|
||||
resolveUpdate = res;
|
||||
});
|
||||
const update = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ update });
|
||||
const finalizeIfActive = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ finalizeIfActive });
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
@@ -399,23 +400,23 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
});
|
||||
|
||||
// Fire both before the (pending) update resolves. The first synchronously
|
||||
// claims the entry (active.delete) and awaits update; the second, started in
|
||||
// the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever calling update.
|
||||
// claims the entry (active.delete) and awaits the write; the second, started
|
||||
// in the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever writing.
|
||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||
|
||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Let the single in-flight update land; both calls resolve cleanly.
|
||||
resolveUpdate({ id: 'run-1' });
|
||||
resolveUpdate({ id: 'run-1', status: 'succeeded' });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||
// 'error' settle never wrote, so it could not clobber the real status.
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
@@ -431,10 +432,10 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
// 409s until a restart. The fix updates FIRST and retries.
|
||||
let calls = 0;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('deadlock detected');
|
||||
return { id: 'run-1' };
|
||||
return { id: 'run-1', status: 'succeeded' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
@@ -447,26 +448,29 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed), no
|
||||
// zombie left, and the row carries the real terminal status.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(2);
|
||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||
it('#487 give-up: if the terminal write keeps failing, finalizeRun leaves a ZOMBIE (does NOT restore the entry) and settleZombie re-drives it', async () => {
|
||||
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||
// #487 changes the give-up behaviour: the entry is NOT restored (a restored
|
||||
// entry is indistinguishable from a live run). Instead a ZOMBIE record holds
|
||||
// the intended terminal status, and a re-drive (settleZombie — called by the
|
||||
// reconcile / supersede / opportunistic paths) applies it later.
|
||||
let healthy = false;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('pool exhausted');
|
||||
return { id: 'run-1' };
|
||||
return { id: 'run-1', status: 'succeeded' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
@@ -480,35 +484,83 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||
// First settle: every bounded attempt fails -> ZOMBIE, entry NOT restored.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||
// blip — distinct from the per-attempt warns.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false); // NOT a live entry
|
||||
expect(svc.hasZombie('run-1')).toBe(true);
|
||||
expect(svc.zombieRunIds()).toContain('run-1');
|
||||
// The give-up emits ONE explicit, greppable ERROR mentioning the zombie.
|
||||
const gaveUp = errorSpy.mock.calls.some(
|
||||
(c) =>
|
||||
/NON-TERMINAL/.test(String(c[0])) &&
|
||||
/ZOMBIE/.test(String(c[0])) &&
|
||||
/run-1/.test(String(c[0])) &&
|
||||
/chat-1/.test(String(c[0])),
|
||||
);
|
||||
expect(gaveUp).toBe(true);
|
||||
// The settle notifier resolved as terminalWriteFailed (a subscriber learns the
|
||||
// slot still needs the intended status applied).
|
||||
const outcome = await svc.peekSettled('run-1');
|
||||
expect(outcome).toEqual({
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
terminalWriteFailed: true,
|
||||
});
|
||||
|
||||
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||
// The DB recovers; a re-drive settles the zombie via the conditional UPDATE.
|
||||
healthy = true;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
const redriven = await svc.settleZombie('run-1');
|
||||
expect(redriven).toBe(true);
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
|
||||
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||
// written), so a double-settle can never clobber the real status.
|
||||
const callsBefore = repo.update.mock.calls.length;
|
||||
// A later finalizeRun is idempotent (row already terminal): it no-ops at the
|
||||
// once-gate, never re-writing.
|
||||
const callsBefore = repo.finalizeIfActive.mock.calls.length;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(callsBefore);
|
||||
});
|
||||
|
||||
it('#487 double-settle collapses to a benign no-op (conditional write; notifier resolves once)', async () => {
|
||||
// A second concurrent settle is stopped at the synchronous active.delete
|
||||
// claim, so the terminal write runs exactly once and the notifier resolves
|
||||
// exactly once with the FIRST settler's outcome.
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'aborted');
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late'); // no-op
|
||||
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
const outcome = await svc.peekSettled('run-1');
|
||||
// peekSettled after resolve+delete falls through (notifier dropped, no zombie)
|
||||
// -> undefined; the FIRST settler already resolved any earlier subscriber.
|
||||
expect(outcome).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#487 late settledPromise subscriber gets the resolved outcome', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||
|
||||
// Subscribe BEFORE settle: hold the promise reference (as supersede does).
|
||||
const early = svc.peekSettled('run-1');
|
||||
expect(early).toBeDefined();
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The reference grabbed before settle resolves with the written outcome, even
|
||||
// though the notifier was dropped from the map on resolve (bounded).
|
||||
await expect(early).resolves.toEqual({
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
terminalWriteFailed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||
@@ -525,3 +577,197 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#487 AiChatRunService.supersede (CAS)', () => {
|
||||
const chat = 'chat-1';
|
||||
const ws = 'ws-1';
|
||||
|
||||
it('degrade: no active run on the chat -> caller sends a normal turn', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => undefined),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'degrade' });
|
||||
});
|
||||
|
||||
it('invalid: the target run belongs to a DIFFERENT chat -> 400', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-x',
|
||||
chatId: 'other-chat',
|
||||
workspaceId: ws,
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'invalid' });
|
||||
});
|
||||
|
||||
it('mismatch: a DIFFERENT run is active than the one targeted -> current runId', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-x', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-live',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({
|
||||
kind: 'mismatch',
|
||||
activeRunId: 'run-live',
|
||||
});
|
||||
});
|
||||
|
||||
it('ready: the target IS active -> stop it, await its (fast) settle, free the slot', async () => {
|
||||
// Simulate a live long TOOL (NOT a slow UPDATE): the run stays active until an
|
||||
// explicit Stop unwinds it; commit-1's race makes that settle land quickly.
|
||||
// The abort listener stands in for streamText's onAbort -> finalizeRun.
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'aborted',
|
||||
error: null,
|
||||
})),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
handle.signal.addEventListener('abort', () => {
|
||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||
});
|
||||
|
||||
// supersede: getRun -> getActiveByChat(==target) -> requestStop -> the abort
|
||||
// listener settles the run -> awaitSettled resolves -> ready.
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
expect(handle.signal.aborted).toBe(true); // Stop reached the run
|
||||
});
|
||||
|
||||
it('timeout: the target never settles within W -> 409 SUPERSEDE_TIMEOUT (nothing persisted)', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
// Do NOT settle the run: a tiny W elapses -> timeout.
|
||||
const result = await svc.supersede(chat, 'run-1', ws, 30);
|
||||
expect(result).toEqual({ kind: 'timeout' });
|
||||
});
|
||||
|
||||
it('ready then a DUPLICATE supersede POST degrades (the run is already gone)', async () => {
|
||||
let active: unknown = {
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
};
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'aborted',
|
||||
error: null,
|
||||
})),
|
||||
findActiveByChat: jest.fn(async () => active),
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
active = undefined; // settling frees the active slot
|
||||
return { id: 'run-1', status: 'aborted' };
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
handle.signal.addEventListener('abort', () => {
|
||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||
});
|
||||
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
// The duplicate POST for the same target now finds no active run -> degrade.
|
||||
expect(await svc.supersede(chat, 'run-1', ws)).toEqual({ kind: 'degrade' });
|
||||
});
|
||||
|
||||
it('reconcileStaleRuns: aborts a stale run with NO entry/zombie; NEVER touches a live entry', async () => {
|
||||
const finalizeIfActive = jest.fn(async () => ({ id: 'x', status: 'aborted' }));
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async (v: any) => ({
|
||||
id: 'live-1',
|
||||
status: 'running',
|
||||
chatId: v.chatId,
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
finalizeIfActive,
|
||||
findStaleActive: jest.fn(async () => [
|
||||
{ id: 'orphan-1', workspaceId: ws, chatId: 'c-orphan' },
|
||||
{ id: 'live-1', workspaceId: ws, chatId: 'c-live' },
|
||||
]),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
// A LIVE run this replica owns (in the `active` map).
|
||||
await svc.beginRun({ chatId: 'c-live', workspaceId: ws, userId: 'u1' });
|
||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||
|
||||
const aborted = await svc.reconcileStaleRuns(15 * 60 * 1000);
|
||||
expect(aborted).toBe(1);
|
||||
// The orphan (no entry) was aborted; the live entry was NEVER passed to the DB.
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||
'orphan-1',
|
||||
ws,
|
||||
expect.objectContaining({ status: 'aborted' }),
|
||||
);
|
||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('gave-up zombie: supersede applies the intended status (settleZombie) then is ready', async () => {
|
||||
let healthy = false;
|
||||
let active: unknown = {
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
};
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => active),
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('db down');
|
||||
active = undefined;
|
||||
return { id: 'run-1', status: 'aborted' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
|
||||
// The run's terminal write gives up -> zombie (row still 'running').
|
||||
await svc.finalizeRun('run-1', ws, 'aborted');
|
||||
expect(svc.hasZombie('run-1')).toBe(true);
|
||||
|
||||
// The DB recovers; supersede awaits the (already-resolved, terminalWriteFailed)
|
||||
// settle, then settleZombie applies the intended status -> ready.
|
||||
healthy = true;
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,88 @@ export class RunAlreadyActiveError extends Error {
|
||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||
|
||||
/** The terminal run statuses — the row is done once it reads one of these. */
|
||||
export const RUN_TERMINAL_STATUSES: readonly RunTerminalStatus[] = [
|
||||
'succeeded',
|
||||
'failed',
|
||||
'aborted',
|
||||
];
|
||||
|
||||
/** Whether a persisted run status is terminal (settled). */
|
||||
export function isRunTerminal(status: string | null | undefined): boolean {
|
||||
return (
|
||||
status === 'succeeded' || status === 'failed' || status === 'aborted'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the outcome a run's {@link AiChatRunService.finalizeRun} settled with.
|
||||
* `terminalWriteFailed` = the terminal write GAVE UP after the bounded retry, so
|
||||
* the row is still non-terminal ('running') and a ZOMBIE record holds the
|
||||
* `intended` status for a later re-drive (reconcile / supersede / boot sweep). A
|
||||
* subscriber (supersede, #487 commit 3) uses this to decide whether the slot is
|
||||
* genuinely free or must first have the intended status applied.
|
||||
*/
|
||||
export interface RunSettleOutcome {
|
||||
status: RunTerminalStatus;
|
||||
error: string | null;
|
||||
terminalWriteFailed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: how long a supersede waits for the target run to settle after Stop before
|
||||
* it degrades to `SUPERSEDE_TIMEOUT`. W=10s is generous under a HEALTHY DB: commit
|
||||
* 1's race-on-abort makes an in-app tool abort->settle in ms/hundreds of ms, so a
|
||||
* live run releases its slot well within the window. Under a DB brownout the
|
||||
* timeout is normal (the write cannot land); W must NOT be raised to paper
|
||||
* over a slow DB — a SUPERSEDE_TIMEOUT is the honest signal (nothing persisted,
|
||||
* the composer keeps the user's text). Env-tunable for ops, default 10s.
|
||||
*/
|
||||
export const SUPERSEDE_SETTLE_TIMEOUT_MS = (() => {
|
||||
const raw = Number(process.env.AI_CHAT_SUPERSEDE_TIMEOUT_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
|
||||
})();
|
||||
|
||||
/**
|
||||
* #487: the result of the supersede CAS ({@link AiChatRunService.supersede}).
|
||||
* - `degrade` : no active run on the chat (it ended between click and POST) —
|
||||
* the caller sends a NORMAL turn (NOT a mismatch);
|
||||
* - `invalid` : the target runId belongs to a DIFFERENT chat (malformed CAS 400);
|
||||
* - `mismatch` : a DIFFERENT run is active than the one the client targeted —
|
||||
* 409 SUPERSEDE_TARGET_MISMATCH carrying the current `activeRunId`
|
||||
* (the client does NOT auto-retry);
|
||||
* - `timeout` : the target did not settle within W — 409 SUPERSEDE_TIMEOUT,
|
||||
* nothing persisted;
|
||||
* - `ready` : the target was stopped AND settled (or its zombie's intended was
|
||||
* applied) — the slot is free; the caller may beginRun the new run.
|
||||
*/
|
||||
export type SupersedeResult =
|
||||
| { kind: 'degrade' }
|
||||
| { kind: 'invalid' }
|
||||
| { kind: 'mismatch'; activeRunId: string }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'ready' };
|
||||
|
||||
/** A one-shot settle notifier (#487): `resolve` is called EXACTLY ONCE. */
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: a run whose terminal write GAVE UP (every bounded attempt failed). The
|
||||
* row is stranded non-terminal ('running'); this record is the ONLY thing that
|
||||
* distinguishes it from a live run, and carries the `intended` terminal status so
|
||||
* a re-drive can apply it via the conditional UPDATE. Process-local (phase-1
|
||||
* single-process assumption): a restart drops it, and the boot sweep then writes
|
||||
* 'aborted' over the intended — a documented loss (see finalizeRun).
|
||||
*/
|
||||
interface ZombieRun {
|
||||
workspaceId: string;
|
||||
chatId: string;
|
||||
intended: { status: RunTerminalStatus; error: string | null };
|
||||
}
|
||||
|
||||
export function mapTurnStatusToRun(
|
||||
status: TurnTerminalStatus,
|
||||
): RunTerminalStatus {
|
||||
@@ -101,6 +183,22 @@ export class AiChatRunService implements OnModuleInit {
|
||||
// uptime — negligible in phase 1's single process.
|
||||
private readonly settled = new Set<string>();
|
||||
|
||||
// #487 runId -> one-shot settle notifier. Kept in a SEPARATE map from `active`
|
||||
// ON PURPOSE: it must OUTLIVE the `active.delete` claim inside finalizeRun (the
|
||||
// claim frees the slot the instant finalize starts), so a subscriber can still
|
||||
// await the outcome after the entry is gone. Created in beginRun, resolved
|
||||
// EXACTLY ONCE in finalizeRun, then removed (bounded). Absence => this replica
|
||||
// has no live notifier: a subscriber falls back to the zombie map, then to the
|
||||
// row (see peekSettled). Process-local (phase-1 single-process assumption).
|
||||
private readonly settledPromises = new Map<string, Deferred<RunSettleOutcome>>();
|
||||
|
||||
// #487 runId -> ZOMBIE record: a run whose terminal write gave up (row stranded
|
||||
// non-terminal). BOUNDED — an entry is added only on give-up and removed on a
|
||||
// successful re-drive (settleZombie) or when the row is found already terminal;
|
||||
// a process restart clears it (and the boot sweep settles the stranded row).
|
||||
// Process-local (phase-1 single-process assumption).
|
||||
private readonly zombies = new Map<string, ZombieRun>();
|
||||
|
||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||
// brief connection blip). Riding out that blip in-place matters because the
|
||||
@@ -224,6 +322,10 @@ export class AiChatRunService implements OnModuleInit {
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
});
|
||||
// #487: arm the one-shot settle notifier BEFORE returning, so a subscriber
|
||||
// that races in immediately after begin always finds a promise to await. It
|
||||
// is resolved exactly once when the run settles (or gives up).
|
||||
this.settledPromises.set(run.id, this.makeDeferred<RunSettleOutcome>());
|
||||
return { runId: run.id, signal: controller.signal };
|
||||
}
|
||||
|
||||
@@ -263,47 +365,43 @@ export class AiChatRunService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted) via a
|
||||
* CONDITIONAL UPDATE, stamping finishedAt + any error. Atomically safe against a
|
||||
* concurrent settle AND robust against a transient terminal-write failure.
|
||||
*
|
||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||
* so two callers can both see `false` and both write the row (last-write-wins
|
||||
* clobbers the real terminal status, and the bounded retry only widens that
|
||||
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||
* check-and-clear with NO await between the gate and the entry removal: the
|
||||
* second concurrent caller finds the entry already gone and returns in the same
|
||||
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||
* finalizing" is thus a single atomic step.
|
||||
* claim happens via `active.delete`, a SYNCHRONOUS check-and-clear with NO await
|
||||
* between the gate and the entry removal: the second concurrent caller finds the
|
||||
* entry already gone and returns in the same tick, before any UPDATE.
|
||||
*
|
||||
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||
* and harmless meanwhile.
|
||||
* ALL TERMINAL WRITES ARE CONDITIONAL (#487): `finalizeIfActive` only flips a
|
||||
* row still in pending|running (mirror of the assistant message's
|
||||
* `onlyIfStreaming`). So even a settle that DID reach the UPDATE (e.g. a
|
||||
* reconcile stamp racing an owner finalize) can never clobber a terminal status
|
||||
* — the loser matches nothing and is a benign no-op. `active.delete` is the
|
||||
* fast, in-process gate; the conditional WHERE is the authoritative one.
|
||||
*
|
||||
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||
* double-settle collapses to a single write and a late settle can never clobber
|
||||
* the real terminal status or double-write the row.
|
||||
* ZOMBIE ON GIVE-UP (#487): if every bounded attempt THROWS (the DB is down for
|
||||
* the whole finalize), we do NOT restore the entry. The row is stranded
|
||||
* non-terminal ('running'); we record a ZOMBIE `{ terminalWriteFailed, intended
|
||||
* }` (the ONLY thing distinguishing this dead run from a live one) and resolve
|
||||
* the settle notifier with `terminalWriteFailed: true`. A restore would make the
|
||||
* zombie indistinguishable from a live run to every reader; instead a re-drive
|
||||
* (settleZombie, called by the periodic reconcile / supersede / opportunistic
|
||||
* paths) applies the intended status later via the same conditional UPDATE.
|
||||
*
|
||||
* DOCUMENTED LOSS (#487, single-process phase 1): if the process RESTARTS before
|
||||
* a zombie is re-driven, the in-memory zombie map is gone and the boot sweep
|
||||
* (unconditional) writes 'aborted' over the ACTUAL intended status. This is
|
||||
* unavoidable while the run lifecycle is single-process — there is no durable
|
||||
* record of `intended`; a cross-process durable intent is deferred to phase 2.
|
||||
*
|
||||
* IDEMPOTENT: the settle notifier resolves EXACTLY ONCE; a second settle is
|
||||
* stopped at `settled.has` or the `active.delete` claim, so a double-settle
|
||||
* collapses to a single write and can never double-resolve or clobber the row.
|
||||
*/
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
@@ -314,13 +412,17 @@ export class AiChatRunService implements OnModuleInit {
|
||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||
// Already terminally written -> idempotent no-op.
|
||||
if (this.settled.has(runId)) return;
|
||||
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||
// Capture the entry BEFORE the delete for the give-up log context.
|
||||
const entry = this.active.get(runId);
|
||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||
// the same tick, before any await — so it can never reach the UPDATE.
|
||||
if (!this.active.delete(runId)) return;
|
||||
|
||||
const status = mapTurnStatusToRun(turnStatus);
|
||||
const err = error ?? null;
|
||||
const chatId = entry?.chatId ?? 'unknown';
|
||||
|
||||
let lastError: unknown;
|
||||
for (
|
||||
let attempt = 1;
|
||||
@@ -328,47 +430,294 @@ export class AiChatRunService implements OnModuleInit {
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, {
|
||||
status: mapTurnStatusToRun(turnStatus),
|
||||
finishedAt: new Date(),
|
||||
error: error ?? null,
|
||||
const row = await this.runRepo.finalizeIfActive(runId, workspaceId, {
|
||||
status,
|
||||
error: err,
|
||||
});
|
||||
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||
// (claimed above); we do NOT restore it. The slot is now free.
|
||||
// No throw => the row is now terminal (we wrote it, or it was ALREADY
|
||||
// terminal — another writer won the conditional UPDATE, a benign no-op).
|
||||
this.settled.add(runId);
|
||||
this.zombies.delete(runId);
|
||||
// Resolve with the persisted outcome: our status when WE wrote it, else
|
||||
// the row's real terminal status (re-read on the already-terminal path so
|
||||
// a subscriber never sees a status we did not actually persist).
|
||||
const outcome: RunSettleOutcome = row
|
||||
? { status, error: err, terminalWriteFailed: false }
|
||||
: await this.readTerminalOutcome(runId, workspaceId, status, err);
|
||||
this.resolveSettled(runId, outcome);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
} catch (err2) {
|
||||
lastError = err2;
|
||||
this.logger.warn(
|
||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
}): ${err2 instanceof Error ? err2.message : 'unknown error'}`,
|
||||
);
|
||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||
// Every attempt threw: GIVE UP. The row is stranded non-terminal ('running').
|
||||
// Do NOT restore the entry (a restored entry is indistinguishable from a live
|
||||
// run); leave a ZOMBIE record instead, and resolve the notifier as
|
||||
// terminalWriteFailed so a subscriber knows the slot still needs the intended
|
||||
// status applied. One explicit, greppable ERROR so an operator can tell a
|
||||
// give-up from a per-attempt blip.
|
||||
this.logger.error(
|
||||
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||
`('running'): terminal write failed after ${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||
`boot sweep`,
|
||||
`Run ${runId} (chat ${chatId}) left NON-TERMINAL ('running'): terminal ` +
|
||||
`write failed after ${AiChatRunService.FINALIZE_MAX_ATTEMPTS} attempts; ` +
|
||||
`ZOMBIE recorded (intended '${status}'), recovery deferred to reconcile / ` +
|
||||
`supersede / boot sweep`,
|
||||
lastError,
|
||||
);
|
||||
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||
// cleared on restart.
|
||||
if (entry) this.active.set(runId, entry);
|
||||
this.zombies.set(runId, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
intended: { status, error: err },
|
||||
});
|
||||
this.resolveSettled(runId, { status, error: err, terminalWriteFailed: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: re-drive a zombie run's intended terminal write (the conditional
|
||||
* UPDATE). Called by the periodic reconcile (commit 4), an opportunistic
|
||||
* single-chat reconcile, and supersede (commit 3). On success — the row is now
|
||||
* terminal (written OR found already terminal) — the zombie is cleared and the
|
||||
* once-gate armed; on another failure the zombie is kept for a later retry.
|
||||
* Returns true when the row is now terminal. Best-effort; never throws.
|
||||
*/
|
||||
async settleZombie(runId: string): Promise<boolean> {
|
||||
const z = this.zombies.get(runId);
|
||||
if (!z) return false;
|
||||
try {
|
||||
await this.runRepo.finalizeIfActive(runId, z.workspaceId, {
|
||||
status: z.intended.status,
|
||||
error: z.intended.error,
|
||||
});
|
||||
this.zombies.delete(runId);
|
||||
this.settled.add(runId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Re-drive of zombie run ${runId} (chat ${z.chatId}) failed; will retry ` +
|
||||
`later: ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 reconcile clause (c): abort runs the DB still shows active (pending|
|
||||
* running) but that this replica does NOT own — NO live entry AND NO zombie —
|
||||
* and that have been UNTOUCHED past `staleMs` (from last-progress `updated_at`,
|
||||
* NOT startedAt, so a legit long marathon is never a candidate). "No entry" is
|
||||
* the PRIMARY gate: a live entry (an actively-executing run on this replica) is
|
||||
* NEVER aborted, whatever its age. Returns the number aborted. Best-effort —
|
||||
* never throws (a periodic-job failure must not crash the process).
|
||||
*/
|
||||
async reconcileStaleRuns(staleMs: number): Promise<number> {
|
||||
let candidates: Array<{ id: string; workspaceId: string; chatId: string }>;
|
||||
try {
|
||||
candidates = await this.runRepo.findStaleActive(staleMs);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (stale runs) query failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
let aborted = 0;
|
||||
for (const c of candidates) {
|
||||
// PRIMARY gate: never touch a live entry, and never race a zombie we are
|
||||
// already re-driving (settleZombie owns those).
|
||||
if (this.active.has(c.id) || this.zombies.has(c.id)) continue;
|
||||
try {
|
||||
const row = await this.runRepo.finalizeIfActive(c.id, c.workspaceId, {
|
||||
status: 'aborted',
|
||||
error: 'Run aborted by reconcile: no live runner (stale).',
|
||||
});
|
||||
if (row) {
|
||||
aborted += 1;
|
||||
this.settled.add(c.id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile abort of stale run ${c.id} failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the run's settle outcome as seen by THIS replica, or undefined when it
|
||||
* has no record (the caller then reads the row — the DB is the source of truth).
|
||||
* A LIVE deferred (still settling, or resolved-but-not-yet-consumed) wins; a
|
||||
* ZOMBIE synthesizes the give-up outcome. A subscriber (supersede) races this
|
||||
* against a timeout.
|
||||
*/
|
||||
peekSettled(runId: string): Promise<RunSettleOutcome> | undefined {
|
||||
const d = this.settledPromises.get(runId);
|
||||
if (d) return d.promise;
|
||||
const z = this.zombies.get(runId);
|
||||
if (z) {
|
||||
return Promise.resolve({
|
||||
status: z.intended.status,
|
||||
error: z.intended.error,
|
||||
terminalWriteFailed: true,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: await a run's settle outcome, bounded by `timeoutMs`. Returns the
|
||||
* outcome on settle, or undefined on TIMEOUT (or when this replica has no record
|
||||
* of the run and its row is not terminal). Uses the LIVE settle notifier / the
|
||||
* zombie synth when present; else reads the row (the DB is the source of truth
|
||||
* once the in-memory record is gone). The subscriber (supersede) grabs this
|
||||
* right after Stop; commit 1's race makes the settle land in ms on a healthy DB.
|
||||
*/
|
||||
async awaitSettled(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<RunSettleOutcome | undefined> {
|
||||
const pending = this.peekSettled(runId);
|
||||
if (pending) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([pending, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
// No live notifier and no zombie: read the row (already settled-and-written,
|
||||
// or unknown here). A terminal row is an outcome; anything else -> undefined.
|
||||
const row = await this.runRepo.findById(runId, workspaceId);
|
||||
if (row && isRunTerminal(row.status)) {
|
||||
return {
|
||||
status: row.status as RunTerminalStatus,
|
||||
error: row.error ?? null,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the SERVER supersede CAS for `POST /stream { supersede: { runId: X } }`.
|
||||
* Atomically transitions "X is the chat's active run" -> "X is stopped, settled,
|
||||
* slot free" so the caller can start a replacement run. See {@link
|
||||
* SupersedeResult} for the branch semantics.
|
||||
*
|
||||
* On a `ready` result the caller MUST still go through the normal beginRun gate
|
||||
* (the partial unique index) — between the slot freeing here and beginRun a
|
||||
* neighbouring tab's ordinary POST can win the slot (documented SLOT-THEFT: the
|
||||
* loser then gets a MISMATCH carrying the NEW runId). There is also NO side-
|
||||
* effect quiescence: an in-flight write of the stopped run may still land AFTER
|
||||
* the new run starts (commit 1 stops the NEXT call, not one already committing),
|
||||
* so the caller adds a prompt note to the new run.
|
||||
*/
|
||||
async supersede(
|
||||
chatId: string,
|
||||
targetRunId: string,
|
||||
workspaceId: string,
|
||||
timeoutMs: number = SUPERSEDE_SETTLE_TIMEOUT_MS,
|
||||
): Promise<SupersedeResult> {
|
||||
// Validate the target belongs to THIS chat (a CAS targeting another chat's run
|
||||
// is malformed -> 400). A missing row is NOT invalid: the run may have ended
|
||||
// and been pruned; the active-run check below decides degrade vs mismatch.
|
||||
const target = await this.getRun(targetRunId, workspaceId);
|
||||
if (target && target.chatId !== chatId) return { kind: 'invalid' };
|
||||
|
||||
const active = await this.getActiveForChat(chatId, workspaceId);
|
||||
// No active run: it ended between the client's click and this POST — this is a
|
||||
// DEGRADE to a normal send, NOT a mismatch (the user's intent still holds).
|
||||
if (!active) return { kind: 'degrade' };
|
||||
// A DIFFERENT run is active than the one the client saw -> mismatch. The
|
||||
// client does not auto-retry; it surfaces the new runId.
|
||||
if (active.id !== targetRunId) {
|
||||
return { kind: 'mismatch', activeRunId: active.id };
|
||||
}
|
||||
|
||||
// The target IS active: stop it, then await its settle within W.
|
||||
await this.requestStop(targetRunId, workspaceId);
|
||||
const outcome = await this.awaitSettled(targetRunId, workspaceId, timeoutMs);
|
||||
if (!outcome) return { kind: 'timeout' };
|
||||
// Gave up (terminal write failed): apply the intended status via the
|
||||
// conditional UPDATE so the slot actually frees. If that ALSO fails, the row
|
||||
// is still stranded -> treat as a timeout (nothing persisted for the new run).
|
||||
if (outcome.terminalWriteFailed) {
|
||||
const settled = await this.settleZombie(targetRunId);
|
||||
if (!settled) return { kind: 'timeout' };
|
||||
}
|
||||
return { kind: 'ready' };
|
||||
}
|
||||
|
||||
/** #487 test/diagnostic seam: whether a give-up zombie is held for this run. */
|
||||
hasZombie(runId: string): boolean {
|
||||
return this.zombies.has(runId);
|
||||
}
|
||||
|
||||
/** #487: every zombie runId held on this replica (reconcile clause a, commit 4). */
|
||||
zombieRunIds(): string[] {
|
||||
return [...this.zombies.keys()];
|
||||
}
|
||||
|
||||
/** #487: create a one-shot deferred (resolve captured for a later single call). */
|
||||
private makeDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** #487: resolve a run's settle notifier EXACTLY ONCE, then drop it (bounded).
|
||||
* A subscriber that already grabbed the promise still resolves; a later one
|
||||
* falls back to the zombie map / the row (see peekSettled). */
|
||||
private resolveSettled(runId: string, outcome: RunSettleOutcome): void {
|
||||
const d = this.settledPromises.get(runId);
|
||||
if (!d) return;
|
||||
this.settledPromises.delete(runId);
|
||||
d.resolve(outcome);
|
||||
}
|
||||
|
||||
/** #487: read the persisted terminal outcome when the conditional finalize was a
|
||||
* no-op (the row was already terminal). Falls back to the intended status when
|
||||
* the read fails or the row is unexpectedly missing/non-terminal. */
|
||||
private async readTerminalOutcome(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
fallbackStatus: RunTerminalStatus,
|
||||
fallbackError: string | null,
|
||||
): Promise<RunSettleOutcome> {
|
||||
try {
|
||||
const row = await this.runRepo.findById(runId, workspaceId);
|
||||
if (row && isRunTerminal(row.status)) {
|
||||
return {
|
||||
status: row.status as RunTerminalStatus,
|
||||
error: row.error ?? null,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the intended status — best-effort only.
|
||||
}
|
||||
return {
|
||||
status: fallbackStatus,
|
||||
error: fallbackError,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||
|
||||
/**
|
||||
* Fail-fast guard for beginRun failures (#486, commit 4).
|
||||
*
|
||||
* When runHooks.begin() rejects for a reason OTHER than RunAlreadyActiveError
|
||||
* (e.g. a DB-pool blip), the turn must NOT continue untracked. The old code
|
||||
* logged and streamed anyway, leaving a run with NO run-row: in autonomous mode
|
||||
* nobody could abort it (/stop can't see it, disconnect doesn't abort it, and the
|
||||
* one-run gate would admit a SECOND run) — an unstoppable invisible run until
|
||||
* restart. The fix throws A_RUN_BEGIN_FAILED (503) BEFORE the first byte and
|
||||
* before the user row is persisted.
|
||||
*
|
||||
* We drive `stream()` directly on a prototype instance wired with only the
|
||||
* collaborators it touches before the throw, so the assertion is on the REAL
|
||||
* control flow, not a mock of it.
|
||||
*/
|
||||
describe('AiChatService beginRun failure (#486)', () => {
|
||||
function makeService(insertSpy: jest.Mock): AiChatService {
|
||||
// Bypass the (heavy) DI constructor: exercise the real stream() method on a
|
||||
// bare prototype instance with just the fields reached before the throw.
|
||||
// `any` because the private `logger` field makes a typed intersection collapse.
|
||||
const svc = Object.create(AiChatService.prototype);
|
||||
svc.aiChatRepo = {
|
||||
// Existing chat -> no insert path; chatId is kept as-is.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'chat1' }),
|
||||
};
|
||||
svc.aiChatMessageRepo = { insert: insertSpy };
|
||||
svc.logger = new Logger('test');
|
||||
return svc as AiChatService;
|
||||
}
|
||||
|
||||
const baseArgs = () => {
|
||||
const write = jest.fn();
|
||||
const res = {
|
||||
raw: { write, writableEnded: false, headersSent: false },
|
||||
};
|
||||
return {
|
||||
user: { id: 'u1' } as never,
|
||||
workspace: { id: 'w1' } as never,
|
||||
sessionId: 's1',
|
||||
// openPage undefined -> resolveOpenPageContext returns null without any DB
|
||||
// call; chatId present -> the existing-chat path.
|
||||
body: { chatId: 'chat1', messages: [] } as never,
|
||||
res: res as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
write,
|
||||
};
|
||||
};
|
||||
|
||||
it('throws A_RUN_BEGIN_FAILED (503) before the first byte and before persisting the user turn', async () => {
|
||||
const insertSpy = jest.fn();
|
||||
const svc = makeService(insertSpy);
|
||||
const { write, ...args } = baseArgs();
|
||||
|
||||
const runHooks = {
|
||||
begin: jest.fn().mockRejectedValue(new Error('DB pool exhausted')),
|
||||
} as never;
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.stream({ ...args, runHooks });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ServiceUnavailableException);
|
||||
const http = caught as ServiceUnavailableException;
|
||||
expect(http.getStatus()).toBe(503);
|
||||
expect(http.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
||||
|
||||
// Fail-fast: nothing was written to the socket and NO user message row was
|
||||
// persisted, so the turn left no orphan state to clean up.
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still maps a lost-the-race RunAlreadyActiveError to a 409, not A_RUN_BEGIN_FAILED', async () => {
|
||||
const insertSpy = jest.fn();
|
||||
const svc = makeService(insertSpy);
|
||||
const { write, ...args } = baseArgs();
|
||||
|
||||
const runHooks = {
|
||||
begin: jest.fn().mockRejectedValue(new RunAlreadyActiveError('chat1')),
|
||||
} as never;
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.stream({ ...args, runHooks });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ConflictException);
|
||||
expect((caught as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
});
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
||||
|
||||
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
||||
async function dispatchFinalize(
|
||||
repo: { insert: jest.Mock; update: jest.Mock },
|
||||
repo: { insert: jest.Mock; finalizeOwner: jest.Mock },
|
||||
assistantId: string | undefined,
|
||||
flushed: AssistantFlush,
|
||||
): Promise<void> {
|
||||
@@ -135,21 +135,22 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
||||
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
||||
});
|
||||
|
||||
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
|
||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||
it('(a) upfront insert succeeded -> finalize CONDITIONALLY updates the row by id (#487 owner-write)', async () => {
|
||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||
const flushed = flushAssistant([], 'final answer', 'completed', {
|
||||
finishReason: 'stop',
|
||||
});
|
||||
await dispatchFinalize(repo, 'a1', flushed);
|
||||
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||
// #487: the owner write is the CONDITIONAL finalizeOwner, not a raw update.
|
||||
expect(repo.finalizeOwner).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
||||
await dispatchFinalize(repo, undefined, flushed);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(repo.finalizeOwner).not.toHaveBeenCalled();
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
const arg = repo.insert.mock.calls[0][0];
|
||||
// The fallback insert carries the terminal content/status/metadata.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* #487 commit 3 — the single concurrency GATE (both modes) + the server supersede
|
||||
* CAS, at the controller boundary. The gate + CAS run BEFORE res.hijack(), so a
|
||||
* rejected concurrent start / a CAS branch returns clean JSON (an HttpException
|
||||
* the controller's post-hijack catch re-serializes). These assert the OBSERVABLE
|
||||
* HTTP contract against the real controller + a stubbed run service.
|
||||
*/
|
||||
describe('#487 AiChatController.stream — gate + supersede', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
|
||||
function wsWith(autonomousRuns: boolean): Workspace {
|
||||
return {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: true, autonomousRuns } },
|
||||
} as unknown as Workspace;
|
||||
}
|
||||
|
||||
function makeReqRes(body: Record<string, unknown>) {
|
||||
const req = {
|
||||
raw: { sessionId: 'sess', once: jest.fn(), destroyed: false },
|
||||
body,
|
||||
};
|
||||
const res = {
|
||||
raw: {
|
||||
writableEnded: false,
|
||||
headersSent: false,
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
end: jest.fn(),
|
||||
statusCode: 200,
|
||||
flushHeaders: jest.fn(),
|
||||
},
|
||||
hijack: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
function makeController(
|
||||
runServiceOverrides: Record<string, jest.Mock>,
|
||||
// The chat assertOwnedChat resolves. Default: a chat OWNED by `user` (u1), so
|
||||
// the ownership gate is transparent to the gate/CAS assertions below. Pass a
|
||||
// foreign-owner (or undefined) chat to exercise the #487 owner rejection.
|
||||
chat: { creatorId: string } | undefined = { creatorId: 'u1' },
|
||||
) {
|
||||
const aiChatService = {
|
||||
resolveRoleForRequest: jest.fn().mockResolvedValue(null),
|
||||
getChatModel: jest.fn().mockResolvedValue({}),
|
||||
stream: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiChatRunService = {
|
||||
getActiveForChat: jest.fn().mockResolvedValue(undefined),
|
||||
supersede: jest.fn(),
|
||||
beginRun: jest.fn().mockResolvedValue({
|
||||
runId: 'run-new',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
linkAssistantMessage: jest.fn(),
|
||||
recordStep: jest.fn(),
|
||||
finalizeRun: jest.fn(),
|
||||
requestStop: jest.fn(),
|
||||
...runServiceOverrides,
|
||||
};
|
||||
const aiChatRepo = { findById: jest.fn().mockResolvedValue(chat) };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
aiChatRunService as never,
|
||||
aiChatRepo as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
);
|
||||
return { controller, aiChatService, aiChatRunService, aiChatRepo };
|
||||
}
|
||||
|
||||
const codeOf = (err: unknown) =>
|
||||
(((err as HttpException).getResponse() as Record<string, unknown>) ?? {})
|
||||
.code;
|
||||
|
||||
describe('single concurrency gate — BOTH modes reject the second tab with 409', () => {
|
||||
for (const autonomousRuns of [true, false]) {
|
||||
it(`rejects a concurrent start with 409 A_RUN_ALREADY_ACTIVE (autonomousRuns=${autonomousRuns})`, async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
getActiveForChat: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'run-live', chatId: 'c1' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({ chatId: 'c1' });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(
|
||||
req as never,
|
||||
res as never,
|
||||
user,
|
||||
wsWith(autonomousRuns),
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(409);
|
||||
expect(codeOf(thrown)).toBe('A_RUN_ALREADY_ACTIVE');
|
||||
// Rejected BEFORE committing to the stream (no hijack, no service.stream).
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// #487 [security, F1]: stream() MUST owner-gate an existing chat exactly like its
|
||||
// six sibling endpoints, BEFORE the supersede CAS. Otherwise a same-workspace
|
||||
// non-owner could POST a supersede against another user's chat and (a) harvest
|
||||
// that user's active runId from the 409 SUPERSEDE_TARGET_MISMATCH body, then (b)
|
||||
// requestStop the foreign run. The gate must reject FIRST — no run lookup, no
|
||||
// supersede, no stop, no runId leak.
|
||||
describe('cross-user ownership gate (F1)', () => {
|
||||
it('a non-owner streaming against someone else\'s chat is rejected (403) with NO runId leak and NO foreign requestStop', async () => {
|
||||
// A live run exists on the victim's chat. Without the gate the supersede CAS
|
||||
// would run and (faithful to the run service) return a MISMATCH carrying the
|
||||
// victim's runId — the exact leak. With the gate it must never be reached.
|
||||
const getActiveForChat = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'run-victim', chatId: 'c-other' });
|
||||
const supersede = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-victim' });
|
||||
const requestStop = jest.fn();
|
||||
const { controller, aiChatService } = makeController(
|
||||
{ getActiveForChat, supersede, requestStop },
|
||||
{ creatorId: 'someone-else' }, // the chat is NOT owned by u1
|
||||
);
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c-other',
|
||||
supersede: { runId: 'guessed-uuid' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
// Rejected by the ownership gate (403), the SAME shape the neighbors use.
|
||||
expect(thrown).toBeInstanceOf(ForbiddenException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(403);
|
||||
// Crucially NOT a 409 that would carry activeRunId — no runId is leaked.
|
||||
const payload = JSON.stringify(
|
||||
(thrown as HttpException).getResponse() ?? {},
|
||||
);
|
||||
expect(payload).not.toContain('run-victim');
|
||||
expect(codeOf(thrown)).not.toBe('SUPERSEDE_TARGET_MISMATCH');
|
||||
// The gate short-circuits BEFORE any run machinery runs.
|
||||
expect(getActiveForChat).not.toHaveBeenCalled();
|
||||
expect(supersede).not.toHaveBeenCalled();
|
||||
expect(requestStop).not.toHaveBeenCalled();
|
||||
expect(aiChatService.stream).not.toHaveBeenCalled();
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('supersede MISMATCH -> 409 SUPERSEDE_TARGET_MISMATCH carrying the current runId', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-other' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_TARGET_MISMATCH');
|
||||
expect(
|
||||
((thrown as HttpException).getResponse() as Record<string, unknown>)
|
||||
.activeRunId,
|
||||
).toBe('run-other');
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede TIMEOUT -> 409 SUPERSEDE_TIMEOUT, nothing streamed', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'timeout' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_TIMEOUT');
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede INVALID (target on another chat) -> 400 SUPERSEDE_INVALID', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'invalid' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||
});
|
||||
|
||||
it('supersede without chatId -> 400 SUPERSEDE_INVALID', async () => {
|
||||
const { controller, aiChatRunService } = makeController({});
|
||||
const { req, res } = makeReqRes({ supersede: { runId: 'run-x' } });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||
expect(aiChatRunService.supersede).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede READY -> proceeds to stream with superseded=true', async () => {
|
||||
const { controller, aiChatService } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'ready' }),
|
||||
getActiveForChat: jest.fn().mockResolvedValue(undefined), // slot free after CAS
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
expect(res.hijack).toHaveBeenCalled();
|
||||
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(true);
|
||||
// The run hooks are always present now (both modes).
|
||||
expect(aiChatService.stream.mock.calls[0][0].runHooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('supersede DEGRADE -> proceeds to a normal send (superseded=false)', async () => {
|
||||
const { controller, aiChatService } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'degrade' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user