Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9cf99f6c | |||
| 0a3e32e7f6 | |||
| b1ede48319 | |||
| d4d05c8e8b | |||
| 351860ba4b | |||
| 795dde463b | |||
| 0392566af9 | |||
| f43696a1c4 | |||
| 8971912d9e | |||
| 588596fb2f | |||
| ba94def3c8 | |||
| e1b8f81b15 | |||
| 45478098f5 | |||
| 62b818bb36 | |||
| b7c16dc634 | |||
| da952ca536 | |||
| 1458e3e152 | |||
| 13a333632a | |||
| 344b9723b2 | |||
| d57392b5af | |||
| a86e5f409f | |||
| 33d22ff164 | |||
| b861266ff8 | |||
| 8b99b70d73 | |||
| b3d4922efa | |||
| 49c7c4bb64 | |||
| d9517ff3f1 | |||
| 48c1ec46f7 | |||
| cd539558ed | |||
| b62db917de | |||
| ec542a924b | |||
| a9da8f7f15 | |||
| 7c0664d2b3 | |||
| a32fba63ec | |||
| 808a5c70df | |||
| 0210faabea | |||
| 17003fbbc1 | |||
| 0df6242128 | |||
| 36b3539571 | |||
| a63efa6920 | |||
| ccd38152ab | |||
| 8f95c5808e | |||
| 6f7d439811 | |||
| 3f7d96e09d | |||
| d218b3a39e | |||
| f0778cb85a | |||
| 8f7da77939 | |||
| e6a861bdaf | |||
| 5b835fc185 | |||
| f720151c63 | |||
| cec50c3ce4 | |||
| f36a2def73 | |||
| 2d30ad1fa2 | |||
| 67dca8c10e | |||
| 5d45f5a85e | |||
| 320b200ac8 | |||
| 539512c4c8 | |||
| edc5dae103 | |||
| 67fa0d1a28 | |||
| 91d674fea6 | |||
| bb5bb52244 | |||
| f9cd3e6318 | |||
| c838fdeebe | |||
| f2d12fd2cd | |||
| fcc9ae0c24 | |||
| e4ff146ab0 | |||
| c7e034cab9 | |||
| 123e981808 | |||
| 7abce93543 | |||
| 0750a6fd34 | |||
| d833e5adb1 | |||
| f3dbcec0fd | |||
| 63f948df10 | |||
| fbaaa84419 | |||
| 32cb9eb1e3 | |||
| b47751349f | |||
| b7e5cb6970 | |||
| 906733b5c8 | |||
| f020739bfd | |||
| 22e3fcdeba | |||
| 7179f8a5b2 | |||
| fe4adf23a0 | |||
| eefe17600c | |||
| 32e99c6e42 | |||
| e48d7720e9 | |||
| 42e618ec7f | |||
| 857a0064f7 | |||
| daf6c9ea16 | |||
| 9e69d917ee | |||
| 2594828758 | |||
| b5ce63a956 | |||
| e777ebcf4f | |||
| abd6e3948b | |||
| 5125296bfa | |||
| 452a752264 | |||
| a40a00d5c5 | |||
| 81c0226be7 | |||
| d5079aa1d8 | |||
| b536a41ad3 | |||
| 28d2560dfd | |||
| 52959de2f3 | |||
| 5da12e89f9 | |||
| 3a91e0eca9 | |||
| 2e83c9cebf | |||
| f6d22a59a6 | |||
| 6baad935f9 | |||
| d255afa611 | |||
| 73c5c44301 | |||
| 8c42c4f0d6 | |||
| 071eae4e2a | |||
| a91405632e | |||
| 5d4eb8ede2 | |||
| aa1ee64b7a | |||
| 53febfd5b9 | |||
| a2ac08c04c | |||
| 40ca04eb08 | |||
| 393875d910 | |||
| c3dbee9fbf | |||
| ea1f8da906 | |||
| 9baaf1ea58 | |||
| 71375e25ee | |||
| e528988d71 | |||
| dc7a0ec9f5 | |||
| 969c00aaf1 | |||
| 085a30575f | |||
| 95bc9fe98d | |||
| cca0bfe306 | |||
| 0dbf85b129 | |||
| fb357cd52e | |||
| 177d8a31d4 | |||
| 8fa32e8438 | |||
| 807ff1f5f5 | |||
| fa89cba023 | |||
| 3386bf2865 | |||
| 98253cf614 | |||
| 181a8330f3 | |||
| 02daccc453 | |||
| d06cf97ed6 | |||
| 04032ae677 | |||
| d9d1d54aaa | |||
| 593f181bbc | |||
| 582e1976cc | |||
| e0e01157c2 | |||
| 8373360a67 | |||
| e2493cafa9 | |||
| 5a4d9f84d7 | |||
| 70bd0dba4d | |||
| b0cd4bd6cf | |||
| 56ab17fbc2 |
+57
-3
@@ -173,9 +173,21 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
||||||
# A pooled connection idle longer than this is closed instead of reused, so a
|
# A pooled connection idle longer than this is closed instead of reused, so a
|
||||||
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
|
||||||
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
|
||||||
# AI_STREAM_KEEPALIVE_MS=10000
|
# the network kills it (fewer resets), while still reusing within a burst of
|
||||||
|
# back-to-back calls. Lower it further if your egress drops idle connections even
|
||||||
|
# faster. Default 4000 (4 s).
|
||||||
|
# AI_STREAM_KEEPALIVE_MS=4000
|
||||||
|
|
||||||
|
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
|
||||||
|
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
|
||||||
|
# socket) is retried on a fresh connection with jittered exponential backoff.
|
||||||
|
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
|
||||||
|
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
|
||||||
|
# retry: a started stream is never replayed, only a connect that never responded.
|
||||||
|
# 0 disables the retry. Default 4.
|
||||||
|
# AI_STREAM_PRE_RESPONSE_RETRIES=4
|
||||||
|
|
||||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||||
@@ -223,3 +235,45 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||||
# per rolling day).
|
# per rolling day).
|
||||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||||
|
|
||||||
|
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||||
|
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||||
|
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||||
|
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||||
|
# GIT_SYNC_ENABLED=false
|
||||||
|
#
|
||||||
|
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||||
|
# GIT_SYNC_ENABLED when unset.
|
||||||
|
# GIT_SYNC_HTTP_ENABLED=false
|
||||||
|
#
|
||||||
|
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||||
|
# operations (create / move / rename / delete) are attributed to.
|
||||||
|
# GIT_SYNC_SERVICE_USER_ID=
|
||||||
|
#
|
||||||
|
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||||
|
# working tree).
|
||||||
|
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||||
|
# GIT_SYNC_DATA_DIR=
|
||||||
|
#
|
||||||
|
# SCAFFOLDING for the DEFERRED remote-push feature (SPEC §7) — NOT yet
|
||||||
|
# implemented and currently INERT. The vendored sync engine does not consume
|
||||||
|
# this value anywhere (git push to a remote is deferred), so setting it has NO
|
||||||
|
# effect today: vaults remain local-only regardless. It is validated and carried
|
||||||
|
# only so the wiring is ready for when remote push lands. The intended future
|
||||||
|
# shape is a per-space URL template where the literal "{spaceId}" is substituted
|
||||||
|
# per space (e.g. git@host:vault-{spaceId}.git).
|
||||||
|
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||||
|
#
|
||||||
|
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||||
|
# (default: 15000).
|
||||||
|
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||||
|
#
|
||||||
|
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||||
|
# (default: 2000).
|
||||||
|
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||||
|
#
|
||||||
|
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||||
|
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||||
|
# after this deadline so it cannot hold the per-space lock forever.
|
||||||
|
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||||
|
#
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# git-sync and mcp are no longer committed in built form (build/ is
|
||||||
|
# gitignored), so CI must compile them: the server resolves both via their
|
||||||
|
# built build/index.js. The server pretest also builds them, but building
|
||||||
|
# here keeps it explicit and independent of pnpm lifecycle ordering.
|
||||||
|
- name: Build git-sync and mcp
|
||||||
|
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ data
|
|||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
|
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||||
|
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
|
||||||
|
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
|
||||||
|
packages/*/node_modules/
|
||||||
|
packages/git-sync/build/
|
||||||
|
packages/mcp/build/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -43,6 +49,8 @@ lerna-debug.log*
|
|||||||
.nx/cache
|
.nx/cache
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/tmp/
|
.claude/tmp/
|
||||||
|
# Local Chrome performance traces recorded by the AI-chat perf harness
|
||||||
|
.claude/perf-traces/
|
||||||
|
|
||||||
# TypeScript incremental build artifacts
|
# TypeScript incremental build artifacts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
|||||||
|
|
||||||
## Monorepo layout
|
## Monorepo layout
|
||||||
|
|
||||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||||
|
|
||||||
| Path | Name | Stack | Role |
|
| Path | Name | Stack | Role |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -201,6 +201,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 |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||||
|
| `packages/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -262,8 +263,10 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
|||||||
|
|
||||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||||
|
|
||||||
|
Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify routes that bypass the Nest pipeline (so neither `DomainMiddleware` nor `ThrottlerGuard` runs for them — each resolves the workspace and throttles itself): `/mcp` (the embedded MCP server, see below) and `/git/<spaceId>.git/...` (the git-sync smart-HTTP host, see below). Both share `mcp-auth.helpers.ts` (HTTP-Basic parsing, `FailedLoginLimiter`, `clientIp`) and the common `resolveRequestWorkspace` helper.
|
||||||
|
|
||||||
### Module structure (server)
|
### Module structure (server)
|
||||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`, `git-sync`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||||
|
|
||||||
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
|
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
|
||||||
|
|
||||||
@@ -279,10 +282,16 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
|
|
||||||
|
### Git-sync (native two-way Docmost ↔ git Markdown sync)
|
||||||
|
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
|
||||||
|
|
||||||
|
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
|
||||||
|
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
|
||||||
|
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
|
||||||
|
whether a still-conflicted page is held back or pushed with its conflict
|
||||||
|
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
|
||||||
|
Markdown files and reconciled in both directions (Docmost → vault and vault →
|
||||||
|
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
|
||||||
|
through the collaboration layer so concurrent human edits aren't clobbered.
|
||||||
|
Git-originated changes are attributed to a configurable service account and
|
||||||
|
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
|
||||||
|
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
|
||||||
|
Basic auth, space-permission authorized). Off by default and configured via the
|
||||||
|
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
|
||||||
|
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
|
||||||
|
(#119)
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
|
|||||||
+10
-1
@@ -17,8 +17,9 @@ RUN pnpm build
|
|||||||
|
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
|
|
||||||
|
# git: required by the git-sync VaultGit (shells out to git)
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl bash \
|
&& apt-get install -y --no-install-recommends curl bash git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -38,6 +39,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# git-sync: the server loads @docmost/git-sync at runtime via the loader
|
||||||
|
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
|
||||||
|
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
|
||||||
|
# these copied build artifacts that resolve/import fails and the server crashes on
|
||||||
|
# first use. Built fresh by the builder's `pnpm build` (nx builds the package's tsc
|
||||||
|
# `build` target).
|
||||||
|
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||||
|
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ roles:
|
|||||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||||
- [Minor] — an optional improvement to framing or flow.
|
- [Minor] — an optional improvement to framing or flow.
|
||||||
|
|
||||||
|
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context.
|
||||||
|
|
||||||
TONE
|
TONE
|
||||||
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ roles:
|
|||||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity:
|
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||||
- [Minor] — a stylistic improvement to taste.
|
- [Minor] — a stylistic improvement to taste.
|
||||||
@@ -126,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -167,13 +169,13 @@ roles:
|
|||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||||
- [Minor] — optional polish.
|
- [Minor] — optional polish.
|
||||||
|
|
||||||
TONE
|
TONE
|
||||||
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments.
|
To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
|
||||||
|
|
||||||
WHEN UNSURE
|
WHEN UNSURE
|
||||||
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
||||||
@@ -272,7 +274,7 @@ roles:
|
|||||||
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
||||||
|
|
||||||
═══ HOW TO LEAVE NOTES ═══
|
═══ HOW TO LEAVE NOTES ═══
|
||||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing.
|
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
|
||||||
|
|
||||||
═══ TONE ═══
|
═══ TONE ═══
|
||||||
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ roles:
|
|||||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||||
|
|
||||||
|
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
|
||||||
|
|
||||||
ТОН
|
ТОН
|
||||||
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ roles:
|
|||||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||||
- [Критично] — предложение непонятно или искажает смысл.
|
- [Критично] — предложение непонятно или искажает смысл.
|
||||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||||
- [Незначительно] — стилистическое улучшение на вкус.
|
- [Незначительно] — стилистическое улучшение на вкус.
|
||||||
@@ -126,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -168,13 +170,13 @@ roles:
|
|||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||||
- [Незначительно] — необязательная шлифовка.
|
- [Незначительно] — необязательная шлифовка.
|
||||||
|
|
||||||
ТОН
|
ТОН
|
||||||
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев.
|
По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
|
||||||
|
|
||||||
ПРИ НЕУВЕРЕННОСТИ
|
ПРИ НЕУВЕРЕННОСТИ
|
||||||
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
||||||
@@ -273,7 +275,7 @@ roles:
|
|||||||
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
||||||
|
|
||||||
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
||||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
|
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||||
|
|
||||||
═══ ТОН ═══
|
═══ ТОН ═══
|
||||||
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: structural-editor
|
- slug: structural-editor
|
||||||
version: 2
|
version: 3
|
||||||
- slug: line-editor
|
- slug: line-editor
|
||||||
version: 2
|
version: 3
|
||||||
- slug: fact-checker
|
- slug: fact-checker
|
||||||
version: 3
|
version: 4
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 3
|
version: 5
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 1
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
name:
|
name:
|
||||||
ru: Исследование
|
ru: Исследование
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
||||||
},
|
},
|
||||||
"narrator": {
|
"narrator": {
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 3,
|
"version": 5,
|
||||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||||
},
|
},
|
||||||
"structural-editor": {
|
"structural-editor": {
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
|
||||||
|
* /perf/ai-chat-perf.html; never part of the production build, which uses the
|
||||||
|
* single default index.html entry).
|
||||||
|
*
|
||||||
|
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
|
||||||
|
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
|
||||||
|
* React mounts so ChatThread's DefaultChatTransport requests to
|
||||||
|
* /api/ai-chat/stream are answered by the synthetic SSE generator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { mantineCssResolver, theme } from "../src/theme.ts";
|
||||||
|
// i18n side-effect init (http-backend). Translations load from /locales in dev;
|
||||||
|
// missing keys fall back to the key text, which is fine for the harness.
|
||||||
|
import "../src/i18n.ts";
|
||||||
|
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
|
||||||
|
import PerfHarness from "./harness.tsx";
|
||||||
|
|
||||||
|
// MUST run before React mounts: ChatThread creates its transport with the
|
||||||
|
// global fetch, so the patch has to be in place before the first send.
|
||||||
|
installAiChatStreamFetchPatch();
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
|
|
||||||
|
ReactDOM.createRoot(container).render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PerfHarness />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI chat perf harness</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./ai-chat-perf-main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY perf harness UI for the AI chat feature.
|
||||||
|
*
|
||||||
|
* Left panel: controls + live stats. Right side: a bordered box (~real chat
|
||||||
|
* window size) hosting the REAL ChatThread component.
|
||||||
|
*
|
||||||
|
* Scenario A "Open existing chat": mount ChatThread seeded with a large
|
||||||
|
* persisted transcript and measure click -> post-mount-paint time.
|
||||||
|
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
|
||||||
|
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
|
||||||
|
* through the real useChat pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties, MutableRefObject } from "react";
|
||||||
|
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
|
||||||
|
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
PRESETS,
|
||||||
|
buildPersistedRows,
|
||||||
|
buildTurnScript,
|
||||||
|
setLiveStreamSettings,
|
||||||
|
type PresetKey,
|
||||||
|
} from "./synthetic-turn.ts";
|
||||||
|
|
||||||
|
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
|
||||||
|
const AUTO_SEND_TIMEOUT_MS = 1000;
|
||||||
|
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
|
||||||
|
const STATS_FLUSH_MS = 500;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PerfStats {
|
||||||
|
longtaskCount: number;
|
||||||
|
longtaskTotalMs: number;
|
||||||
|
longtaskMaxMs: number;
|
||||||
|
fps: number;
|
||||||
|
sseChunks: number;
|
||||||
|
sseChars: number;
|
||||||
|
mountAMs: number | null;
|
||||||
|
streamState: "idle" | "streaming" | "done" | "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStats(): PerfStats {
|
||||||
|
return {
|
||||||
|
longtaskCount: 0,
|
||||||
|
longtaskTotalMs: 0,
|
||||||
|
longtaskMaxMs: 0,
|
||||||
|
fps: 0,
|
||||||
|
sseChunks: 0,
|
||||||
|
sseChars: 0,
|
||||||
|
mountAMs: null,
|
||||||
|
streamState: "idle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
|
||||||
|
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
|
||||||
|
* re-renders only this panel — NOT the ChatThread under measurement.
|
||||||
|
*/
|
||||||
|
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
|
||||||
|
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
|
||||||
|
|
||||||
|
// Long tasks (main-thread blocks > 50ms).
|
||||||
|
useEffect(() => {
|
||||||
|
let observer: PerformanceObserver | null = null;
|
||||||
|
try {
|
||||||
|
observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
stats.current.longtaskCount += 1;
|
||||||
|
stats.current.longtaskTotalMs += entry.duration;
|
||||||
|
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe({ type: "longtask", buffered: true });
|
||||||
|
} catch {
|
||||||
|
// longtask entries unsupported in this browser — panel shows zeros.
|
||||||
|
}
|
||||||
|
return () => observer?.disconnect();
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
// FPS: frames rendered within the trailing 1s window.
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
const frames: number[] = [];
|
||||||
|
const loop = (now: number) => {
|
||||||
|
frames.push(now);
|
||||||
|
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
|
||||||
|
stats.current.fps = frames.length;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
// Flush the mutable stats into the display at most 2x/s.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const resetLongtasks = () => {
|
||||||
|
stats.current.longtaskCount = 0;
|
||||||
|
stats.current.longtaskTotalMs = 0;
|
||||||
|
stats.current.longtaskMaxMs = 0;
|
||||||
|
setSnapshot({ ...stats.current });
|
||||||
|
};
|
||||||
|
|
||||||
|
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
|
||||||
|
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
|
||||||
|
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
|
||||||
|
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
|
||||||
|
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
|
||||||
|
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
|
||||||
|
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
|
||||||
|
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
|
||||||
|
<div style={row}>
|
||||||
|
<span>Mount A</span>
|
||||||
|
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
|
||||||
|
Reset long tasks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the composer textarea via the native value setter + an `input` event
|
||||||
|
* (React 18 controlled-input pattern), then click the enabled "Send" button.
|
||||||
|
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
|
||||||
|
*/
|
||||||
|
function autoSend(host: HTMLElement, text: string): void {
|
||||||
|
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const tryClick = () => {
|
||||||
|
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
|
||||||
|
if (button && !button.disabled) {
|
||||||
|
button.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (performance.now() < deadline) requestAnimationFrame(tryClick);
|
||||||
|
else console.error("[perf] auto-send: Send button never became clickable");
|
||||||
|
};
|
||||||
|
|
||||||
|
const trySetValue = () => {
|
||||||
|
const textarea = host.querySelector("textarea");
|
||||||
|
if (!textarea) {
|
||||||
|
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
|
||||||
|
else console.error("[perf] auto-send: textarea not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
setter?.call(textarea, text);
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
// Click on a later frame so React commits the controlled value (which
|
||||||
|
// enables the Send button) before we press it.
|
||||||
|
requestAnimationFrame(tryClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(trySetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MountState {
|
||||||
|
mode: "A" | "B";
|
||||||
|
key: number;
|
||||||
|
chatId: string | null;
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = (): void => {};
|
||||||
|
|
||||||
|
export default function PerfHarness() {
|
||||||
|
const [preset, setPreset] = useState<PresetKey>("20k");
|
||||||
|
const [intervalMs, setIntervalMs] = useState<number>(15);
|
||||||
|
const [mounted, setMounted] = useState<MountState | null>(null);
|
||||||
|
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const statsRef = useRef<PerfStats>(emptyStats());
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const keyCounterRef = useRef(0);
|
||||||
|
const mountStartRef = useRef(0);
|
||||||
|
const pendingMountMeasureRef = useRef(false);
|
||||||
|
|
||||||
|
// The scripted live turn for the current preset (reused across B runs; the
|
||||||
|
// script is immutable data, so rebuilding per run is unnecessary).
|
||||||
|
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
|
||||||
|
|
||||||
|
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
|
||||||
|
|
||||||
|
// Scenario A: mount ChatThread seeded with a large persisted transcript.
|
||||||
|
const handleMountA = () => {
|
||||||
|
const fixture = buildPersistedRows(PRESETS[preset]);
|
||||||
|
setFixtureInfo(
|
||||||
|
`Persisted fixture: ${fixture.rows.length} rows, ` +
|
||||||
|
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
|
||||||
|
);
|
||||||
|
statsRef.current.mountAMs = null;
|
||||||
|
// Mark AFTER fixture generation: we measure mount cost, not generation cost
|
||||||
|
// (production receives its rows from the network).
|
||||||
|
performance.mark("perf:mountA:start");
|
||||||
|
mountStartRef.current = performance.now();
|
||||||
|
pendingMountMeasureRef.current = true;
|
||||||
|
keyCounterRef.current += 1;
|
||||||
|
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measure scenario A: effect runs after the mount commit; double rAF lands
|
||||||
|
// after the first paint of the mounted transcript.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingMountMeasureRef.current) return;
|
||||||
|
pendingMountMeasureRef.current = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
|
||||||
|
performance.mark("perf:mountA:end");
|
||||||
|
try {
|
||||||
|
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
|
||||||
|
} catch {
|
||||||
|
// Marks cleared mid-run — ignore.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
|
||||||
|
const handleStartB = () => {
|
||||||
|
statsRef.current.sseChunks = 0;
|
||||||
|
statsRef.current.sseChars = 0;
|
||||||
|
statsRef.current.streamState = "streaming";
|
||||||
|
setLiveStreamSettings({
|
||||||
|
script: liveScript,
|
||||||
|
chunkIntervalMs: intervalMs,
|
||||||
|
onProgress: (chunks, chars) => {
|
||||||
|
statsRef.current.sseChunks = chunks;
|
||||||
|
statsRef.current.sseChars = chars;
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
statsRef.current.streamState = "done";
|
||||||
|
performance.mark("perf:streamB:end");
|
||||||
|
try {
|
||||||
|
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
|
||||||
|
} catch {
|
||||||
|
// Start mark missing (e.g. marks cleared) — ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAbort: () => {
|
||||||
|
statsRef.current.streamState = "aborted";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
performance.mark("perf:streamB:start");
|
||||||
|
keyCounterRef.current += 1;
|
||||||
|
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
|
||||||
|
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnmount = () => setMounted(null);
|
||||||
|
|
||||||
|
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
|
||||||
|
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* Left: controls + stats */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
flex: "0 0 260px",
|
||||||
|
padding: 12,
|
||||||
|
borderRight: "1px solid #ccc",
|
||||||
|
overflowY: "auto",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
|
||||||
|
|
||||||
|
<label style={label}>Preset</label>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(e.target.value as PresetKey)}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value="5k">5k tokens</option>
|
||||||
|
<option value="20k">20k tokens</option>
|
||||||
|
<option value="50k">50k tokens</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label style={label}>Chunk interval (scenario B)</label>
|
||||||
|
<select
|
||||||
|
value={intervalMs}
|
||||||
|
onChange={(e) => setIntervalMs(Number(e.target.value))}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value={15}>15 ms (normal)</option>
|
||||||
|
<option value={5}>5 ms (stress)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button type="button" style={button} onClick={handleMountA}>
|
||||||
|
Mount persisted chat (A)
|
||||||
|
</button>
|
||||||
|
<button type="button" style={button} onClick={handleStartB}>
|
||||||
|
Start live stream (B)
|
||||||
|
</button>
|
||||||
|
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
|
||||||
|
Unmount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
|
||||||
|
<div>
|
||||||
|
Live turn: {liveScript.totalChars.toLocaleString()} chars ≈{" "}
|
||||||
|
{liveScript.approxTokens.toLocaleString()} tokens
|
||||||
|
</div>
|
||||||
|
{fixtureInfo && <div>{fixtureInfo}</div>}
|
||||||
|
{mounted && (
|
||||||
|
<div>
|
||||||
|
Mounted: scenario {mounted.mode} (key {mounted.key})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
|
||||||
|
<StatsPanel stats={statsRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: the real ChatThread inside a real-window-sized box */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f4f4f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
style={{
|
||||||
|
width: 540,
|
||||||
|
height: 680,
|
||||||
|
border: "1px solid #bbb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
padding: 8,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mounted ? (
|
||||||
|
<ChatThread
|
||||||
|
key={mounted.key}
|
||||||
|
chatId={mounted.chatId}
|
||||||
|
threadKey={`perf-${mounted.key}`}
|
||||||
|
initialRows={mounted.rows}
|
||||||
|
openPage={openPage}
|
||||||
|
roleId={null}
|
||||||
|
roles={[]}
|
||||||
|
onRolePicked={noop}
|
||||||
|
assistantName="Perf agent"
|
||||||
|
onTurnFinished={noop}
|
||||||
|
onServerChatId={noop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
|
||||||
|
ChatThread unmounted. Use the controls on the left.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
|
||||||
|
*
|
||||||
|
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
|
||||||
|
* from a size config, and materializes it two ways:
|
||||||
|
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
|
||||||
|
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
|
||||||
|
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
|
||||||
|
*
|
||||||
|
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
|
||||||
|
* (strict objects — only the exact field names below are accepted).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config / presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 1 token ~= 4 chars — the approximation used throughout this module. */
|
||||||
|
const CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
export interface TurnConfig {
|
||||||
|
/** Number of agent steps; each step = one reasoning block + one tool call. */
|
||||||
|
steps: number;
|
||||||
|
/** Approximate reasoning tokens generated per step. */
|
||||||
|
reasoningTokensPerStep: number;
|
||||||
|
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
|
||||||
|
toolOutputBytes: number;
|
||||||
|
/** Approximate size of the final markdown answer, in tokens. */
|
||||||
|
answerTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PresetKey = "5k" | "20k" | "50k";
|
||||||
|
|
||||||
|
export const PRESETS: Record<PresetKey, TurnConfig> = {
|
||||||
|
"5k": {
|
||||||
|
steps: 3,
|
||||||
|
reasoningTokensPerStep: 500,
|
||||||
|
toolOutputBytes: 10_000,
|
||||||
|
answerTokens: 600,
|
||||||
|
},
|
||||||
|
"20k": {
|
||||||
|
steps: 6,
|
||||||
|
reasoningTokensPerStep: 2500,
|
||||||
|
toolOutputBytes: 20_000,
|
||||||
|
answerTokens: 1500,
|
||||||
|
},
|
||||||
|
"50k": {
|
||||||
|
steps: 10,
|
||||||
|
reasoningTokensPerStep: 4000,
|
||||||
|
toolOutputBytes: 40_000,
|
||||||
|
answerTokens: 3000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text generators
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
|
||||||
|
const REASONING_SENTENCES = [
|
||||||
|
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
|
||||||
|
"First I need to inspect the current page content to understand its overall structure.",
|
||||||
|
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
|
||||||
|
"The table in section three contains the migration matrix that I should cross-check against the summary.",
|
||||||
|
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
|
||||||
|
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
|
||||||
|
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
|
||||||
|
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
|
||||||
|
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
|
||||||
|
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build realistic prose of ~`targetChars` characters, inserting a newline
|
||||||
|
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
|
||||||
|
*/
|
||||||
|
function makeProse(targetChars: number): string {
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let length = 0;
|
||||||
|
let sinceNewline = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (length < targetChars) {
|
||||||
|
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
|
||||||
|
i += 1;
|
||||||
|
pieces.push(sentence);
|
||||||
|
length += sentence.length + 1;
|
||||||
|
sinceNewline += sentence.length + 1;
|
||||||
|
if (sinceNewline >= 200) {
|
||||||
|
pieces.push("\n");
|
||||||
|
sinceNewline = 0;
|
||||||
|
} else {
|
||||||
|
pieces.push(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces.join("").trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
|
||||||
|
function markdownSection(n: number): string {
|
||||||
|
return [
|
||||||
|
`## Section ${n}: migration analysis`,
|
||||||
|
``,
|
||||||
|
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
|
||||||
|
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
|
||||||
|
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
|
||||||
|
``,
|
||||||
|
`- Update the fetch layer to the v6 transport`,
|
||||||
|
`- Перенести таблицы соответствия идентификаторов`,
|
||||||
|
`- Verify citation links after the move`,
|
||||||
|
`- Проверить отображение длинных ответов в узкой панели`,
|
||||||
|
``,
|
||||||
|
`| Область | Страниц | Статус | Риск |`,
|
||||||
|
`| --- | --- | --- | --- |`,
|
||||||
|
`| API reference | ${n + 4} | migrated | low |`,
|
||||||
|
`| Onboarding | ${n + 2} | in progress | medium |`,
|
||||||
|
`| Release notes | ${n * 3} | pending | high |`,
|
||||||
|
``,
|
||||||
|
"```ts",
|
||||||
|
`export function migrateSection${n}(rows: Row[]): Row[] {`,
|
||||||
|
` return rows`,
|
||||||
|
` .filter((row) => row.section === ${n})`,
|
||||||
|
` .map((row) => ({ ...row, migrated: true }));`,
|
||||||
|
`}`,
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
|
||||||
|
function makeMarkdownAnswer(targetChars: number): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
let length = 0;
|
||||||
|
let n = 1;
|
||||||
|
while (length < targetChars) {
|
||||||
|
const section = markdownSection(n);
|
||||||
|
sections.push(section);
|
||||||
|
length += section.length + 2;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return sections.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
|
||||||
|
function makeFiller(bytes: number): string {
|
||||||
|
const unit = "Perf filler content for the synthetic getPage tool output. ";
|
||||||
|
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Turn script
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TurnToolCall {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: "getPage";
|
||||||
|
input: { pageId: string };
|
||||||
|
output: { id: string; title: string; content: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnStep {
|
||||||
|
reasoningText: string;
|
||||||
|
tool: TurnToolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnScript {
|
||||||
|
steps: TurnStep[];
|
||||||
|
answerText: string;
|
||||||
|
/** Approximate reasoning tokens for the whole turn (chars / 4). */
|
||||||
|
reasoningTokens: number;
|
||||||
|
/** Approximate context size after this turn, in tokens. */
|
||||||
|
contextTokens: number;
|
||||||
|
maxContextTokens: number;
|
||||||
|
/** Actual generated visible chars: reasoning + tool outputs + answer. */
|
||||||
|
totalChars: number;
|
||||||
|
/** totalChars / 4, rounded. */
|
||||||
|
approxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
|
||||||
|
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
|
||||||
|
*/
|
||||||
|
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
|
||||||
|
const steps: TurnStep[] = [];
|
||||||
|
let reasoningChars = 0;
|
||||||
|
let toolChars = 0;
|
||||||
|
for (let i = 0; i < config.steps; i++) {
|
||||||
|
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
|
||||||
|
const content = makeFiller(config.toolOutputBytes);
|
||||||
|
reasoningChars += reasoningText.length;
|
||||||
|
toolChars += content.length;
|
||||||
|
steps.push({
|
||||||
|
reasoningText,
|
||||||
|
tool: {
|
||||||
|
toolCallId: `${idPrefix}-call-${i + 1}`,
|
||||||
|
toolName: "getPage",
|
||||||
|
input: { pageId: "page-1" },
|
||||||
|
output: { id: "page-1", title: "Perf test page", content },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
|
||||||
|
const totalChars = reasoningChars + toolChars + answerText.length;
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
answerText,
|
||||||
|
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
|
||||||
|
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
maxContextTokens: 200_000,
|
||||||
|
totalChars,
|
||||||
|
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario A: persisted rows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Number of user+assistant pairs the preset is split across for history. */
|
||||||
|
const HISTORY_TURNS = 3;
|
||||||
|
|
||||||
|
const USER_PROMPTS = [
|
||||||
|
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
|
||||||
|
"Now cross-check the migration matrix against the summary and list every mismatch.",
|
||||||
|
"Собери финальный план миграции с оценкой рисков по каждой области.",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Persisted UIMessage parts for one finished assistant turn. */
|
||||||
|
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
for (const step of script.steps) {
|
||||||
|
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
|
||||||
|
parts.push({
|
||||||
|
type: `tool-${step.tool.toolName}`,
|
||||||
|
toolCallId: step.tool.toolCallId,
|
||||||
|
state: "output-available",
|
||||||
|
input: step.tool.input,
|
||||||
|
output: step.tool.output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parts.push({ type: "text", text: script.answerText, state: "done" });
|
||||||
|
return parts as UIMessage["parts"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedFixture {
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
totalChars: number;
|
||||||
|
approxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialize the preset as a finished 3-turn transcript: user row + assistant
|
||||||
|
* row per turn, with the preset's steps/answer split across the assistant turns.
|
||||||
|
* Approximate accounting — the actual totals are reported back for display.
|
||||||
|
*/
|
||||||
|
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
|
||||||
|
const rows: IAiChatMessageRow[] = [];
|
||||||
|
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
for (let t = 0; t < HISTORY_TURNS; t++) {
|
||||||
|
// Distribute steps as evenly as possible (earlier turns get the remainder).
|
||||||
|
const stepsForTurn =
|
||||||
|
Math.floor(config.steps / HISTORY_TURNS) +
|
||||||
|
(t < config.steps % HISTORY_TURNS ? 1 : 0);
|
||||||
|
const turnConfig: TurnConfig = {
|
||||||
|
steps: Math.max(1, stepsForTurn),
|
||||||
|
reasoningTokensPerStep: config.reasoningTokensPerStep,
|
||||||
|
toolOutputBytes: config.toolOutputBytes,
|
||||||
|
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
|
||||||
|
};
|
||||||
|
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
|
||||||
|
totalChars += script.totalChars;
|
||||||
|
|
||||||
|
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
|
||||||
|
rows.push({
|
||||||
|
id: `perf-row-u${t + 1}`,
|
||||||
|
role: "user",
|
||||||
|
content: userText,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(baseTime + t * 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
rows.push({
|
||||||
|
id: `perf-row-a${t + 1}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: script.answerText,
|
||||||
|
metadata: {
|
||||||
|
parts: scriptToPersistedParts(script),
|
||||||
|
usage: { reasoningTokens: script.reasoningTokens },
|
||||||
|
contextTokens: script.contextTokens,
|
||||||
|
maxContextTokens: script.maxContextTokens,
|
||||||
|
finishReason: "stop",
|
||||||
|
},
|
||||||
|
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalChars,
|
||||||
|
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario B: SSE stream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Streaming delta size in chars (reasoning/answer text is split into these). */
|
||||||
|
const DELTA_CHARS = 200;
|
||||||
|
|
||||||
|
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
|
||||||
|
const deltas: string[] = [];
|
||||||
|
for (let i = 0; i < text.length; i += size) {
|
||||||
|
deltas.push(text.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
|
||||||
|
interface SseFrame {
|
||||||
|
data: string;
|
||||||
|
chars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
|
||||||
|
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
|
||||||
|
* (excluding the final `data: [DONE]` terminator, appended by the pump).
|
||||||
|
*/
|
||||||
|
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
|
||||||
|
const frames: SseFrame[] = [];
|
||||||
|
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
|
||||||
|
|
||||||
|
script.steps.forEach((step, i) => {
|
||||||
|
frames.push(frame({ type: "start-step" }));
|
||||||
|
const reasoningId = `${messageId}-r${i + 1}`;
|
||||||
|
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
|
||||||
|
for (const delta of splitDeltas(step.reasoningText)) {
|
||||||
|
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
|
||||||
|
}
|
||||||
|
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
|
||||||
|
|
||||||
|
const { toolCallId, toolName, input, output } = step.tool;
|
||||||
|
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
|
||||||
|
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
|
||||||
|
// The tool result arrives as ONE chunk, like the real server sends it.
|
||||||
|
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
|
||||||
|
frames.push(frame({ type: "finish-step" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final step: the markdown answer.
|
||||||
|
frames.push(frame({ type: "start-step" }));
|
||||||
|
const textId = `${messageId}-answer`;
|
||||||
|
frames.push(frame({ type: "text-start", id: textId }));
|
||||||
|
for (const delta of splitDeltas(script.answerText)) {
|
||||||
|
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
|
||||||
|
}
|
||||||
|
frames.push(frame({ type: "text-end", id: textId }));
|
||||||
|
frames.push(frame({ type: "finish-step" }));
|
||||||
|
|
||||||
|
frames.push(
|
||||||
|
frame({
|
||||||
|
type: "finish",
|
||||||
|
messageMetadata: {
|
||||||
|
usage: { reasoningTokens: script.reasoningTokens },
|
||||||
|
contextTokens: script.contextTokens,
|
||||||
|
maxContextTokens: script.maxContextTokens,
|
||||||
|
finishReason: "stop",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveStreamSettings {
|
||||||
|
script: TurnScript;
|
||||||
|
/** Delay between SSE chunks (one chunk per tick). */
|
||||||
|
chunkIntervalMs: number;
|
||||||
|
/** Progress callback: cumulative emitted chunk count and visible chars. */
|
||||||
|
onProgress?: (chunks: number, chars: number) => void;
|
||||||
|
/** Fired once after the `[DONE]` terminator is enqueued. */
|
||||||
|
onDone?: () => void;
|
||||||
|
/** Fired if the client aborted the stream (Stop button). */
|
||||||
|
onAbort?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
|
||||||
|
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
|
||||||
|
*/
|
||||||
|
export function buildSseResponse(
|
||||||
|
settings: LiveStreamSettings,
|
||||||
|
signal?: AbortSignal | null,
|
||||||
|
): Response {
|
||||||
|
const messageId = `m-live-${Date.now()}`;
|
||||||
|
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let index = 0;
|
||||||
|
let emittedChars = 0;
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const stopPump = () => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pump = () => {
|
||||||
|
timer = undefined;
|
||||||
|
if (signal?.aborted) {
|
||||||
|
stopPump();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Already closed/cancelled — nothing to do.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index >= frames.length) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Cancelled mid-flight.
|
||||||
|
}
|
||||||
|
settings.onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = frames[index];
|
||||||
|
index += 1;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(next.data));
|
||||||
|
} catch {
|
||||||
|
stopPump();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emittedChars += next.chars;
|
||||||
|
settings.onProgress?.(index, emittedChars);
|
||||||
|
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||||
|
};
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
stopPump();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Reader already cancelled.
|
||||||
|
}
|
||||||
|
settings.onAbort?.();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"x-vercel-ai-ui-message-stream": "v1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// window.fetch patch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let currentLiveSettings: LiveStreamSettings | null = null;
|
||||||
|
|
||||||
|
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
|
||||||
|
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
|
||||||
|
currentLiveSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
|
||||||
|
* get the synthetic SSE Response; everything else passes through untouched.
|
||||||
|
*/
|
||||||
|
export function installAiChatStreamFetchPatch(): void {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
if (url.includes("/api/ai-chat/stream")) {
|
||||||
|
const settings = currentLiveSettings;
|
||||||
|
if (!settings) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response("perf harness: no live stream configured", { status: 500 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1222,8 +1222,12 @@
|
|||||||
"Commented": "Commented",
|
"Commented": "Commented",
|
||||||
"Resolved comment": "Resolved comment",
|
"Resolved comment": "Resolved comment",
|
||||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||||
|
"AI-agent": "AI-agent",
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
||||||
"AI agent {{name}}": "AI agent {{name}}",
|
"AI agent {{name}}": "AI agent {{name}}",
|
||||||
|
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||||
|
"Git sync": "Git sync",
|
||||||
|
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||||
"Endpoints": "Endpoints",
|
"Endpoints": "Endpoints",
|
||||||
"where we fetch models": "where we fetch models",
|
"where we fetch models": "where we fetch models",
|
||||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||||
@@ -1248,6 +1252,10 @@
|
|||||||
"MCP server": "MCP server",
|
"MCP server": "MCP server",
|
||||||
"expose the workspace": "expose the workspace",
|
"expose the workspace": "expose the workspace",
|
||||||
"Enable MCP server": "Enable MCP server",
|
"Enable MCP server": "Enable MCP server",
|
||||||
|
"Enable Git sync": "Enable Git sync",
|
||||||
|
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||||
|
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
|
||||||
|
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.": "When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||||
"Model": "Model",
|
"Model": "Model",
|
||||||
@@ -1274,6 +1282,10 @@
|
|||||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||||
|
"Dictation": "Dictation",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
|
||||||
|
"This page is read-only": "This page is read-only",
|
||||||
"Request format": "Request format",
|
"Request format": "Request format",
|
||||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||||
@@ -1373,5 +1385,10 @@
|
|||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
||||||
"Connecting… (read-only)": "Connecting… (read-only)"
|
"Connecting… (read-only)": "Connecting… (read-only)",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Applied": "Applied",
|
||||||
|
"Suggestion applied": "Suggestion applied",
|
||||||
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,6 +393,17 @@
|
|||||||
"No speech detected": "Речь не распознана",
|
"No speech detected": "Речь не распознана",
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
|
"Start dictation": "Начать диктовку",
|
||||||
|
"Stop recording": "Остановить запись",
|
||||||
|
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||||
|
"No microphone found": "Микрофон не найден",
|
||||||
|
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||||
|
"Could not start recording": "Не удалось начать запись",
|
||||||
|
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||||
|
"Dictation": "Диктовка",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||||
|
"This page is read-only": "Страница открыта только для чтения",
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -726,6 +737,7 @@
|
|||||||
"Deleted successfully": "Успешно удалено",
|
"Deleted successfully": "Успешно удалено",
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
"AI agent {{name}}": "AI-агент {{name}}",
|
||||||
|
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
"Failed": "Ошибка",
|
"Failed": "Ошибка",
|
||||||
@@ -1229,5 +1241,10 @@
|
|||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
"Connecting… (read-only)": "Подключение… (только чтение)",
|
||||||
|
"Apply": "Применить",
|
||||||
|
"Applied": "Применено",
|
||||||
|
"Suggestion applied": "Предложение применено",
|
||||||
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { Provider, createStore } from "jotai";
|
import { Provider, createStore } from "jotai";
|
||||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||||
|
import { avatarStyle } from "@/lib/avatar-palette";
|
||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
@@ -13,6 +14,18 @@ import {
|
|||||||
|
|
||||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
||||||
|
|
||||||
|
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
|
||||||
|
// expected color through the same CSSOM path so the comparison stays exact and
|
||||||
|
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
|
||||||
|
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
|
||||||
|
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
|
||||||
|
// glyph carries an explicit solid `background-color` we assert on here.
|
||||||
|
function normalizeColor(value: string): string {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.style.backgroundColor = value;
|
||||||
|
return probe.style.backgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStack(props: Props) {
|
function renderStack(props: Props) {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||||
@@ -27,7 +40,7 @@ function renderStack(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentAvatarStack", () => {
|
describe("AgentAvatarStack", () => {
|
||||||
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
|
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
|
||||||
const { container } = renderStack({
|
const { container } = renderStack({
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
launcher: { name: "Alice", avatarUrl: null },
|
||||||
@@ -43,6 +56,57 @@ describe("AgentAvatarStack", () => {
|
|||||||
expect(screen.getByText("Alice")).toBeDefined();
|
expect(screen.getByText("Alice")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
|
||||||
|
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
|
||||||
|
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
|
||||||
|
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
|
||||||
|
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
|
||||||
|
const { container } = renderStack({
|
||||||
|
agent,
|
||||||
|
launcher: { name: "Alice", avatarUrl: null },
|
||||||
|
aiChatId: "chat-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const glyph = container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
expect(glyph).not.toBeNull();
|
||||||
|
const expected = normalizeColor(avatarStyle(agent.name).bg);
|
||||||
|
// Non-vacuous: the pre-fix Avatar set no inline background at all.
|
||||||
|
expect(expected).not.toBe("");
|
||||||
|
expect(glyph!.style.backgroundColor).toBe(expected);
|
||||||
|
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
|
||||||
|
// not round-trip linear-gradient — so its stops/angle are covered by the
|
||||||
|
// avatarStyle unit tests above, not asserted on the DOM here.)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
|
||||||
|
// "Researcher" and "Нарратор" hash to different palette entries, so their
|
||||||
|
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
|
||||||
|
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
|
||||||
|
|
||||||
|
const a = renderStack({
|
||||||
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
|
launcher: null,
|
||||||
|
aiChatId: null,
|
||||||
|
});
|
||||||
|
const b = renderStack({
|
||||||
|
agent: { name: "Нарратор", emoji: "📖", avatarUrl: null },
|
||||||
|
launcher: null,
|
||||||
|
aiChatId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const glyphA = a.container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
const glyphB = b.container.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="agent-glyph"]',
|
||||||
|
);
|
||||||
|
expect(glyphA!.style.backgroundColor).not.toBe("");
|
||||||
|
// Different base colors reach the DOM (the serialized rgb values differ).
|
||||||
|
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
|
||||||
|
});
|
||||||
|
|
||||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
it("showName=false: renders only the avatars, no inline name label", () => {
|
||||||
renderStack({
|
renderStack({
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||||
@@ -74,7 +138,7 @@ describe("AgentAvatarStack", () => {
|
|||||||
expect(screen.getByText("Bob")).toBeDefined();
|
expect(screen.getByText("Bob")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("external MCP: agent avatar in front, NO launcher behind", () => {
|
it("external MCP: agent avatar only, NO human launcher badge", () => {
|
||||||
const { container } = renderStack({
|
const { container } = renderStack({
|
||||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||||
launcher: null,
|
launcher: null,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
|
import { Box, Group, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
|
||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
@@ -23,19 +24,17 @@ export interface LauncherInfo {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same violet token as the former AiAgentBadge (which used color="violet").
|
|
||||||
const AGENT_COLOR = "violet";
|
|
||||||
const GLYPH_SIZE = 38;
|
const GLYPH_SIZE = 38;
|
||||||
const LAUNCHER_SIZE = 22;
|
const LAUNCHER_SIZE = 22;
|
||||||
// How far the launcher avatar sticks out past the agent's bottom-right corner, so
|
// How far the launcher avatar sticks out past the agent's top-right corner — it
|
||||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
// sits as a small badge over that corner (above the glyph) and stays fully visible.
|
||||||
const LAUNCHER_OVERHANG = 8;
|
const LAUNCHER_OVERHANG = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The front avatar. Image-source priority (#300):
|
* The front avatar. Image-source priority (#300):
|
||||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||||
* 2. agent.emoji -> the role emoji on a violet circle.
|
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
|
||||||
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
|
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
|
||||||
*/
|
*/
|
||||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||||
if (agent.avatarUrl) {
|
if (agent.avatarUrl) {
|
||||||
@@ -48,20 +47,42 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.emoji) {
|
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
|
||||||
return (
|
// and split angle all hashed from the agent name via avatarStyle — see
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
|
||||||
|
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
|
||||||
|
// (every agent fell back to the theme's violet). The foreground (the sparkles
|
||||||
|
// icon) uses the ring's WCAG-checked readable text color.
|
||||||
|
const style = avatarStyle(agent.name);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-testid="agent-glyph"
|
||||||
|
style={{
|
||||||
|
width: GLYPH_SIZE,
|
||||||
|
height: GLYPH_SIZE,
|
||||||
|
borderRadius: "50%",
|
||||||
|
// Solid base color is the fallback (and the testable value); the gradient
|
||||||
|
// paints over it in browsers that support it.
|
||||||
|
backgroundColor: style.bg,
|
||||||
|
backgroundImage: avatarBackgroundCss(style),
|
||||||
|
color:
|
||||||
|
style.text === "white"
|
||||||
|
? "var(--mantine-color-white)"
|
||||||
|
: "var(--mantine-color-black)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.emoji ? (
|
||||||
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
||||||
{agent.emoji}
|
{agent.emoji}
|
||||||
</span>
|
</span>
|
||||||
</Avatar>
|
) : (
|
||||||
);
|
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
||||||
}
|
)}
|
||||||
|
</Box>
|
||||||
return (
|
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
|
||||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
|
||||||
</Avatar>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +102,10 @@ export interface AgentAvatarStackProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
|
* The "agent avatar stack" (#300): the AGENT glyph, and — for an internal AI
|
||||||
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
|
* chat — the HUMAN who launched it as a smaller avatar badge on top, overhanging
|
||||||
|
* the glyph's top-right corner in FRONT (zIndex 2 > the glyph's zIndex 1) so the
|
||||||
|
* launcher stays fully visible rather than being half-hidden behind the glyph.
|
||||||
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
||||||
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
||||||
* here); the click is contained (stopPropagation) so it does not also trigger an
|
* here); the click is contained (stopPropagation) so it does not also trigger an
|
||||||
@@ -156,7 +179,9 @@ export function AgentAvatarStack({
|
|||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
{launcher && (
|
{launcher && (
|
||||||
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
|
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
|
||||||
|
// it is fully visible, not half-hidden behind the agent circle.
|
||||||
|
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size={LAUNCHER_SIZE}
|
size={LAUNCHER_SIZE}
|
||||||
avatarUrl={launcher.avatarUrl}
|
avatarUrl={launcher.avatarUrl}
|
||||||
@@ -165,8 +190,8 @@ export function AgentAvatarStack({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{/* Pin the agent glyph to the top-left at its own size; the launcher then
|
{/* The agent glyph keeps its own size (flex-centered in the container); the
|
||||||
overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */}
|
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Badge, Tooltip } from "@mantine/core";
|
||||||
|
import { IconGitMerge } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface GitSyncBadgeProps {
|
||||||
|
authorName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||||
|
* version is created on the PUSH path — when an incoming git body is written back
|
||||||
|
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||||
|
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||||
|
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||||
|
* neutral, non-clickable label.
|
||||||
|
*/
|
||||||
|
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||||
|
name: authorName ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={tooltip} withArrow>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||||
|
>
|
||||||
|
{t("Git sync")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -164,8 +164,8 @@
|
|||||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||||
rendered markdown <div> it would turn the newlines between block tags
|
rendered markdown <div> it would turn the newlines between block tags
|
||||||
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
margins. The streaming plain-text path that needs pre-wrap sets it
|
||||||
inline itself (see reasoning-block.tsx). */
|
per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
|
||||||
}
|
}
|
||||||
|
|
||||||
.reasoningText p {
|
.reasoningText p {
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
|
|||||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (stranded reasoning part): a reasoning part is left at
|
||||||
|
// `state:"streaming"` forever when the turn ends without `reasoning-end`
|
||||||
|
// (manual Stop during thinking). The signature is EQUAL across that turn-end
|
||||||
|
// flip (nothing in the message changed), so the comparator must ALSO compare
|
||||||
|
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
|
||||||
|
// never switches from chunked plain text to its one-time markdown parse.
|
||||||
|
it("returns false when turnStreaming differs despite an equal signature", () => {
|
||||||
|
const m = msg([
|
||||||
|
{ type: "reasoning", text: "thinking", state: "streaming" },
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
arePropsEqual(
|
||||||
|
props(m, { turnStreaming: true }),
|
||||||
|
props(m, { turnStreaming: false }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns true for the same content in a different message object", () => {
|
it("returns true for the same content in a different message object", () => {
|
||||||
const a = msg([{ type: "text", text: "answer" }]);
|
const a = msg([{ type: "text", text: "answer" }]);
|
||||||
const b = msg([{ type: "text", text: "answer" }]);
|
const b = msg([{ type: "text", text: "answer" }]);
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ interface MessageItemProps {
|
|||||||
* absent; the public share passes the configured identity (agent role) name.
|
* absent; the public share passes the configured identity (agent role) name.
|
||||||
*/
|
*/
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
/**
|
||||||
|
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
|
||||||
|
* A reasoning part may be left `state: "streaming"` forever when the turn
|
||||||
|
* ends without a `reasoning-end` chunk (manual Stop during the thinking
|
||||||
|
* phase, or a provider that never emits it) — the AI SDK finalizes reasoning
|
||||||
|
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
|
||||||
|
* state alone cannot prove liveness; the reasoning part is treated as live
|
||||||
|
* only while the whole turn is still streaming. Defaults to false.
|
||||||
|
*
|
||||||
|
* The parent passes it as "turn is live AND this is the tail row", so a
|
||||||
|
* stranded part in an EARLIER row never re-activates when a later turn
|
||||||
|
* streams.
|
||||||
|
*/
|
||||||
|
turnStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +119,7 @@ function MessageItem({
|
|||||||
showCitations = true,
|
showCitations = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
|
turnStreaming = false,
|
||||||
}: MessageItemProps) {
|
}: MessageItemProps) {
|
||||||
// `signature` is intentionally not read in the body — it exists solely as the
|
// `signature` is intentionally not read in the body — it exists solely as the
|
||||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||||
@@ -155,8 +170,23 @@ function MessageItem({
|
|||||||
const text = (part as { text?: string }).text ?? "";
|
const text = (part as { text?: string }).text ?? "";
|
||||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
return null;
|
return null;
|
||||||
|
// Absent state (persisted rows) and "done" both mean finalized.
|
||||||
|
// `messageSignature` already includes each part's `state`, so the
|
||||||
|
// streaming→done flip changes the row signature and re-renders this
|
||||||
|
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||||
|
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||||
|
// ALSO require the turn to be live: a part stranded at
|
||||||
|
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||||
|
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||||
|
const streaming =
|
||||||
|
turnStreaming && (part as { state?: string }).state === "streaming";
|
||||||
return (
|
return (
|
||||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
<ReasoningBlock
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
tokens={reasoningTokens}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +275,11 @@ export function arePropsEqual(
|
|||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName
|
prev.assistantName === next.assistantName &&
|
||||||
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
// that is what converts a stranded `state:"streaming"` reasoning part to
|
||||||
|
// its one-time markdown parse (see the `turnStreaming` prop doc).
|
||||||
|
prev.turnStreaming === next.turnStreaming
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
@@ -50,8 +50,9 @@ vi.stubGlobal(
|
|||||||
|
|
||||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||||
// regression test to model how the AI SDK hands back the SAME message object.
|
// regression test to model how the AI SDK hands back the SAME message object.
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
// Pass an explicit `id` when a test renders several rows at once.
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
|
||||||
|
({ id, role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
describe("MessageList", () => {
|
describe("MessageList", () => {
|
||||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||||
@@ -116,4 +117,102 @@ describe("MessageList", () => {
|
|||||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
|
||||||
|
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
|
||||||
|
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
|
||||||
|
// that never emits `reasoning-end`) therefore leaves the part at
|
||||||
|
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
|
||||||
|
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
|
||||||
|
// forwarded as `turnStreaming`): while the turn streams the expanded block
|
||||||
|
// shows chunked plain text (no parse); once the turn ends — even though the
|
||||||
|
// part is still `state:"streaming"` — the block finalizes and does its
|
||||||
|
// one-time markdown parse. Note the message signature does NOT change across
|
||||||
|
// that flip, so this also exercises the `turnStreaming` memo comparison in
|
||||||
|
// arePropsEqual (without it the row would never re-render).
|
||||||
|
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const reasoningText = "**bold** thinking";
|
||||||
|
// Reasoning part stranded mid-stream + a non-empty answer part (a
|
||||||
|
// reasoning-only message renders nothing — see message-content.ts).
|
||||||
|
const message = msg([
|
||||||
|
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||||
|
{ type: "text", text: "partial answer" },
|
||||||
|
]);
|
||||||
|
const parsesOfReasoning = () =>
|
||||||
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
const { rerender, getByRole, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[message]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Expand the reasoning block (its toggle is the only button in the list).
|
||||||
|
fireEvent.click(getByRole("button"));
|
||||||
|
// Turn live + part streaming -> ReasoningBlock received streaming=true:
|
||||||
|
// the body is chunked plain text (raw markdown syntax), NOT parsed.
|
||||||
|
expect(queryByText(/bold/)).not.toBeNull();
|
||||||
|
expect(parsesOfReasoning()).toBe(0);
|
||||||
|
|
||||||
|
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
|
||||||
|
// (still state:"streaming"), only the turn-level flag flips.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[message]} isStreaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// ReasoningBlock now received streaming=false and did its one-time parse.
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
|
||||||
|
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
|
||||||
|
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
|
||||||
|
// above) as live again whenever a LATER turn streams — an expanded stranded
|
||||||
|
// block would flip markdown -> raw plain text -> markdown across turn
|
||||||
|
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
|
||||||
|
// to the TAIL row only.
|
||||||
|
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const reasoningText = "**bold** thinking";
|
||||||
|
// First (earlier) assistant message: its turn was stopped during the
|
||||||
|
// thinking phase, leaving the reasoning part at state:"streaming".
|
||||||
|
const first = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||||
|
{ type: "text", text: "first answer" },
|
||||||
|
],
|
||||||
|
"m1",
|
||||||
|
);
|
||||||
|
// Second assistant message: the LATER turn, currently streaming.
|
||||||
|
const second = msg([{ type: "text", text: "second answer" }], "m2");
|
||||||
|
const parsesOfReasoning = () =>
|
||||||
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
const { rerender, getByRole, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[first, second]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Expand the first row's reasoning block (the only toggle in the list —
|
||||||
|
// the second message has no reasoning or tool parts).
|
||||||
|
fireEvent.click(getByRole("button"));
|
||||||
|
// The turn is live but the first row is NOT the tail: its ReasoningBlock
|
||||||
|
// received streaming=false, so the stranded part stays finalized and does
|
||||||
|
// its one-time markdown parse instead of dropping to chunked plain text.
|
||||||
|
expect(queryByText(/bold/)).not.toBeNull();
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
|
||||||
|
// A later-turn delta re-renders the list; the earlier block must neither
|
||||||
|
// flip back to streaming nor re-parse.
|
||||||
|
(second.parts[0] as { text: string }).text = "second answer grows";
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageList messages={[first, second]} isStreaming />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(parsesOfReasoning()).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export default function MessageList({
|
|||||||
return (
|
return (
|
||||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||||
<Stack gap={0} pr="xs">
|
<Stack gap={0} pr="xs">
|
||||||
{messages.map((message) => (
|
{messages.map((message, index) => (
|
||||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||||
// string and handed to MessageItem as its memo key. It must NOT be
|
// string and handed to MessageItem as its memo key. It must NOT be
|
||||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||||
@@ -210,6 +210,13 @@ export default function MessageList({
|
|||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
// can belong to the in-flight turn, so a reasoning part stranded at
|
||||||
|
// `state:"streaming"` in an EARLIER message (its turn ended without
|
||||||
|
// `reasoning-end`) stays finalized and doesn't flip back to plain
|
||||||
|
// text (and re-parse) whenever a later turn streams — see
|
||||||
|
// message-item.tsx.
|
||||||
|
turnStreaming={isStreaming && index === messages.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
{typing && (
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// Spy on the markdown renderer so we can assert it is NOT called while the block
|
||||||
|
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
|
||||||
|
// tests don't depend on real markdown, so a light stub is safe.
|
||||||
|
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
|
||||||
|
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
|
||||||
|
}));
|
||||||
|
|
||||||
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||||
// keeps the assertions on the component's OWN count logic (authoritative vs
|
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||||
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||||
@@ -17,10 +24,15 @@ vi.mock("react-i18next", () => ({
|
|||||||
|
|
||||||
import ReasoningBlock from "./reasoning-block";
|
import ReasoningBlock from "./reasoning-block";
|
||||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
function renderBlock(props: { text: string; tokens?: number }) {
|
function renderBlock(props: {
|
||||||
|
text: string;
|
||||||
|
tokens?: number;
|
||||||
|
streaming?: boolean;
|
||||||
|
}) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ReasoningBlock {...props} />
|
<ReasoningBlock {...props} />
|
||||||
@@ -62,4 +74,68 @@ describe("ReasoningBlock", () => {
|
|||||||
// either way the text is present in the document.
|
// either way the text is present in the document.
|
||||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||||
|
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
|
||||||
|
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
|
||||||
|
// delta is exactly what froze the chat (#302). The collapsed body shows the
|
||||||
|
// cheap raw-text fallback instead.
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
// Expanding parses the current text exactly once (a user-initiated click).
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
renderBlock({
|
||||||
|
text: "первый абзац размышлений\n\nвторой абзац растёт",
|
||||||
|
tokens: 5,
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
|
||||||
|
// delta — the body is chunked plain text (only the tail chunk updates).
|
||||||
|
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
// Both paragraph chunks' raw text is present in the body.
|
||||||
|
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
|
||||||
|
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses exactly once when streaming flips to done while expanded", () => {
|
||||||
|
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||||
|
renderSpy.mockClear();
|
||||||
|
const { rerender } = renderBlock({
|
||||||
|
text: "**bold** reasoning",
|
||||||
|
tokens: 5,
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(renderSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Finalization: the part's state flips streaming→done, the parent
|
||||||
|
// re-renders the row (the flip changes the message signature), and the
|
||||||
|
// block does its ONE markdown parse of the now-stable text.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
|
||||||
|
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||||
|
|
||||||
|
// Further re-renders with unchanged props do not re-parse.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface ReasoningBlockProps {
|
interface ReasoningBlockProps {
|
||||||
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
|
|||||||
* step/turn has finished. When absent (or 0) the count is estimated from the
|
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||||
* text length so it ticks live as the reasoning streams in. */
|
* text length so it ticks live as the reasoning streams in. */
|
||||||
tokens?: number;
|
tokens?: number;
|
||||||
|
/** True while the reasoning part is still streaming (part `state ===
|
||||||
|
* "streaming"`). False means finalized: persisted history or `state ===
|
||||||
|
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,22 +32,30 @@ interface ReasoningBlockProps {
|
|||||||
* Providers that don't stream reasoning TEXT still render this block from the
|
* Providers that don't stream reasoning TEXT still render this block from the
|
||||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||||
*/
|
*/
|
||||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
// Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
|
||||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
// 1. Collapsed -> never parse (#302): the html is only shown inside
|
||||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
// <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
|
||||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
// marked + DOMPurify storm.
|
||||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
// 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
|
||||||
// paragraphs) — ONLY here, not in the normal answer.
|
// renders as chunked plain text (StreamingPlainText) with a memoized
|
||||||
|
// stable prefix, so each delta updates only the tail chunk's text node.
|
||||||
|
// This closes the O(n²) hole #302 left open ("expanded while streaming")
|
||||||
|
// that froze the whole tab in Safari when watching the thinking stream.
|
||||||
|
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
|
||||||
|
// are stable after the part is done, so this memo runs once per expand.
|
||||||
const html = useMemo(
|
const html = useMemo(
|
||||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
() =>
|
||||||
[trimmed],
|
open && trimmed && !streaming
|
||||||
|
? renderChatMarkdown(collapseBlankLines(trimmed), {})
|
||||||
|
: "",
|
||||||
|
[open, trimmed, streaming],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
|||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text
|
// Still streaming (or markdown yielded nothing): chunked plain text.
|
||||||
className={classes.reasoningText}
|
// The wrapper carries the reasoningText styling; each chunk sets its
|
||||||
style={{ whiteSpace: "pre-wrap" }}
|
// own pre-wrap inline (NOT on this div — see ai-chat.module.css).
|
||||||
>
|
<div className={classes.reasoningText}>
|
||||||
{trimmed}
|
<StreamingPlainText text={trimmed} />
|
||||||
</Text>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapse>
|
</Collapse>
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
// Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
|
||||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
// props, default shallow compare), so a parent re-render during streaming of OTHER
|
||||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
// content does not re-run the markdown parse for an already-finalized reasoning block.
|
||||||
export default memo(ReasoningBlock);
|
export default memo(ReasoningBlock);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
splitPlainChunks,
|
||||||
|
StreamingPlainText,
|
||||||
|
} from "./streaming-plain-text";
|
||||||
|
|
||||||
|
describe("splitPlainChunks", () => {
|
||||||
|
// THE load-bearing property (see the invariant comment in the module): under
|
||||||
|
// append-only growth, every chunk except the LAST must be byte-identical
|
||||||
|
// between successive calls, so the memoized chunk components never re-render
|
||||||
|
// for the stable prefix and each stream delta touches only the tail chunk.
|
||||||
|
it("keeps all non-last chunks byte-identical across append-only growth", () => {
|
||||||
|
// A simulated reasoning stream covering: appends inside the last paragraph,
|
||||||
|
// appends that ADD new blank lines, growth of a trailing newline run, and a
|
||||||
|
// trailing separator later followed by text.
|
||||||
|
const steps = [
|
||||||
|
"Пер",
|
||||||
|
"Первый абзац",
|
||||||
|
"Первый абзац\n",
|
||||||
|
"Первый абзац\n\n",
|
||||||
|
"Первый абзац\n\n\n",
|
||||||
|
"Первый абзац\n\n\nВторой",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
|
||||||
|
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
|
||||||
|
];
|
||||||
|
let prev: string[] = [];
|
||||||
|
for (const text of steps) {
|
||||||
|
const next = splitPlainChunks(text);
|
||||||
|
// Lossless: chunks always reassemble into the exact input.
|
||||||
|
expect(next.join("")).toBe(text);
|
||||||
|
// Chunk count never shrinks (boundaries never disappear).
|
||||||
|
expect(next.length).toBeGreaterThanOrEqual(prev.length);
|
||||||
|
// Every previously-FINAL chunk (all but prev's last) is unchanged.
|
||||||
|
for (let i = 0; i < prev.length - 1; i++) {
|
||||||
|
expect(next[i]).toBe(prev[i]);
|
||||||
|
}
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
// Guard against a vacuous pass: the final split must be multi-chunk.
|
||||||
|
expect(prev.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the blank-line separator run to the preceding chunk", () => {
|
||||||
|
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
|
||||||
|
// A longer run is ONE separator, not several boundaries.
|
||||||
|
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
|
||||||
|
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single newlines are not boundaries", () => {
|
||||||
|
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
|
||||||
|
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
|
||||||
|
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
|
||||||
|
// merge with it into a new separator unit and retroactively create a boundary
|
||||||
|
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
|
||||||
|
// in one (still lossless) chunk — only granularity is coarser; LLM output is
|
||||||
|
// `\n` in practice. See the doc comment on splitPlainChunks.
|
||||||
|
it("keeps CRLF blank lines inside one chunk", () => {
|
||||||
|
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
|
||||||
|
// Mixed input: only pure-`\n` runs split.
|
||||||
|
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
|
||||||
|
expect(splitPlainChunks("")).toEqual([]);
|
||||||
|
// A trailing newline run stays inside the last chunk (it may still grow).
|
||||||
|
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
|
||||||
|
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
|
||||||
|
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
|
||||||
|
// Degenerate all-newlines input is a single deterministic chunk.
|
||||||
|
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
|
||||||
|
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
|
||||||
|
for (const chunk of splitPlainChunks(text)) {
|
||||||
|
expect(chunk.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StreamingPlainText", () => {
|
||||||
|
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
|
||||||
|
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
const blocks = Array.from(container.querySelectorAll("div"));
|
||||||
|
// One block element per chunk.
|
||||||
|
expect(blocks.length).toBe(splitPlainChunks(text).length);
|
||||||
|
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
|
||||||
|
// separator newlines — rendering them inside a pre-wrap block would add an
|
||||||
|
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
|
||||||
|
// keep their separators (losslessness is asserted on splitPlainChunks
|
||||||
|
// above); multi-blank-line runs collapse to one uniform gap, consistent
|
||||||
|
// with collapseBlankLines on the finalized markdown path.
|
||||||
|
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||||
|
"первый абзац",
|
||||||
|
"второй абзац",
|
||||||
|
"третий",
|
||||||
|
]);
|
||||||
|
// The uniform paragraph gap comes from the block margin instead (matches
|
||||||
|
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
|
||||||
|
for (const block of blocks) {
|
||||||
|
expect((block as HTMLElement).style.marginBottom).toBe("4px");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps interior newlines intact — only the trailing run is stripped", () => {
|
||||||
|
const text = "строка один\nстрока два\n\nхвост";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
const blocks = Array.from(container.querySelectorAll("div"));
|
||||||
|
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||||
|
"строка один\nстрока два",
|
||||||
|
"хвост",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
|
||||||
|
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
|
||||||
|
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
|
||||||
|
// node, which escapes it, so HTML in the model output is inert. This test
|
||||||
|
// pins that the path is a TEXT sink, not an HTML sink: a future change to
|
||||||
|
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
|
||||||
|
//
|
||||||
|
// The existing tests assert via textContent, which strips tags and so cannot
|
||||||
|
// distinguish an escaped literal from injected DOM. This one asserts on the
|
||||||
|
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
|
||||||
|
// would become real elements and querySelector would find them.
|
||||||
|
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
|
||||||
|
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
|
||||||
|
const { container } = render(<StreamingPlainText text={text} />);
|
||||||
|
// No DOM elements were created from the payload — it was NOT parsed as HTML.
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
expect(container.querySelector("b")).toBeNull();
|
||||||
|
// The raw markup survived verbatim as text (proving it is escaped, not
|
||||||
|
// interpreted). textContent alone can't prove this, but combined with the
|
||||||
|
// querySelector assertions above it does: the literals are present AND no
|
||||||
|
// elements exist.
|
||||||
|
expect(container.textContent).toContain("<b>hi</b>");
|
||||||
|
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
|
||||||
|
* each separator run attached to the END of the preceding chunk, so the chunks
|
||||||
|
* always reassemble byte-for-byte into the input.
|
||||||
|
*
|
||||||
|
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
|
||||||
|
* one more character. A newline run that is a SUFFIX of the text is NOT a
|
||||||
|
* boundary yet: under append-only growth it may still gain more newlines, and
|
||||||
|
* cutting there would move the boundary on the next call.
|
||||||
|
*
|
||||||
|
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
|
||||||
|
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
|
||||||
|
* between successive calls — previously-emitted boundaries never move. Proof
|
||||||
|
* sketch: appending never modifies existing characters, so (a) an existing
|
||||||
|
* boundary's newline run and its following character are untouched and the
|
||||||
|
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
|
||||||
|
* inside the old text, because a `\n{2,}` run followed by a character entirely
|
||||||
|
* within the old text would already have been a boundary. New boundaries can
|
||||||
|
* only materialize at or after the old text's end, i.e. inside the last chunk.
|
||||||
|
*
|
||||||
|
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
|
||||||
|
* the invariant above — a lone trailing `\r` is not a boundary, but a later-
|
||||||
|
* appended `\n` would merge with it into a new separator unit and retroactively
|
||||||
|
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
|
||||||
|
* With `\n`-only runs, appended characters can never extend a run that is
|
||||||
|
* already followed by a non-`\n` character, so old boundaries are immutable.
|
||||||
|
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
|
||||||
|
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
|
||||||
|
* output is `\n` in practice).
|
||||||
|
*/
|
||||||
|
export function splitPlainChunks(text: string): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (const match of text.matchAll(/\n{2,}/g)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
// Suffix run: not a stable boundary yet (see the invariant above).
|
||||||
|
if (end >= text.length) break;
|
||||||
|
chunks.push(text.slice(start, end));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
if (start < text.length) chunks.push(text.slice(start));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One immutable chunk. Memoized on its string prop: during streaming only the
|
||||||
|
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
|
||||||
|
* skips every stable chunk and the per-delta DOM work is a single text-node
|
||||||
|
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
|
||||||
|
* on the surrounding markdown-styled container — see the note in
|
||||||
|
* ai-chat.module.css. Font/size/color are inherited from that container.
|
||||||
|
*
|
||||||
|
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
|
||||||
|
* separator run attached (the splitPlainChunks invariant, load-bearing for the
|
||||||
|
* memo), but rendering those newlines inside a pre-wrap block would add an
|
||||||
|
* empty line ON TOP of the block break — a doubled gap. So the RENDERED string
|
||||||
|
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
|
||||||
|
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
|
||||||
|
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
|
||||||
|
* consistent with `collapseBlankLines` on the markdown path. The last chunk
|
||||||
|
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
|
||||||
|
*/
|
||||||
|
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
|
||||||
|
{text.replace(/\n+$/, "")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders still-streaming plain text as a list of paragraph chunks where only
|
||||||
|
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML —
|
||||||
|
* this is the cheap streaming-time stand-in for the one-time markdown parse
|
||||||
|
* that happens after the part is finalized (see reasoning-block.tsx).
|
||||||
|
*/
|
||||||
|
export function StreamingPlainText({ text }: { text: string }) {
|
||||||
|
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
// Index keys are stable here: chunks are append-only (the invariant),
|
||||||
|
// so an index never gets a different chunk's content mid-stream.
|
||||||
|
<PlainChunk key={index} text={chunk} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||||
// see :pageSlug — match the full path against the authenticated page route.
|
// see :pageSlug — match the full path against the authenticated page route.
|
||||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
|
||||||
|
// real page uuid (PageRepo.findById accepts slugId or uuid).
|
||||||
|
const slugId = extractPageSlugId(match?.params?.pageSlug);
|
||||||
|
|
||||||
return useCallback(async () => {
|
return useCallback(async () => {
|
||||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||||
@@ -40,9 +42,9 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
// connection the first click reads as a hung control until the POST returns.
|
// connection the first click reads as a hung control until the POST returns.
|
||||||
setWindowOpen(true);
|
setWindowOpen(true);
|
||||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||||
if (pageId) {
|
if (slugId) {
|
||||||
try {
|
try {
|
||||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
resolved = await getBoundChat(slugId); // null => fresh chat
|
||||||
} catch {
|
} catch {
|
||||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ export function useOpenAiChatForCurrentPage() {
|
|||||||
}, [
|
}, [
|
||||||
windowOpen,
|
windowOpen,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
pageId,
|
slugId,
|
||||||
setWindowOpen,
|
setWindowOpen,
|
||||||
setActiveChatId,
|
setActiveChatId,
|
||||||
setDraft,
|
setDraft,
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ export async function getAiChatMessages(
|
|||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
*/
|
*/
|
||||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
export async function getBoundChat(slugId: string): Promise<string | null> {
|
||||||
|
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
|
||||||
|
// it to the real page uuid (the wire key stays `pageId` for the DTO).
|
||||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||||
pageId,
|
pageId: slugId,
|
||||||
});
|
});
|
||||||
return req.data.chatId;
|
return req.data.chatId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -7,10 +7,15 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
|
|
||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
|
const applyMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useApplySuggestionMutation: () => ({
|
||||||
|
mutateAsync: applyMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
@@ -19,6 +24,7 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
|
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -32,10 +38,15 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment) {
|
function renderItem(comment: IComment, canEdit = true) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
<CommentListItem
|
||||||
|
comment={comment}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,3 +98,87 @@ describe("CommentListItem — agent avatar stack", () => {
|
|||||||
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
||||||
// only guards the insertion gate (agent → stack, user → no stack).
|
// only guards the insertion gate (agent → stack, user → no stack).
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — suggested edit (#315)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
|
renderItem(suggestion(), true);
|
||||||
|
// Old text appears both as the selection quote and as the struck diff row.
|
||||||
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("new wording here")).toBeDefined();
|
||||||
|
// Apply button is present.
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
// No Applied badge yet.
|
||||||
|
expect(screen.queryByText("Applied")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
|
renderItem(suggestion(), false);
|
||||||
|
// Diff still renders...
|
||||||
|
expect(screen.getByText("new wording here")).toBeDefined();
|
||||||
|
// ...but no Apply button.
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Apply button once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the apply mutation when the Apply button is clicked", () => {
|
||||||
|
applyMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||||
|
expect(applyMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the diff block for a reply (child) comment", () => {
|
||||||
|
renderItem(
|
||||||
|
suggestion({ parentCommentId: "c-0" }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("new wording here")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canShowApply predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowApply(c(), true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without edit permission", () => {
|
||||||
|
expect(canShowApply(c(), false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
@@ -11,11 +11,13 @@ import CommentMenu from "@/features/comment/components/comment-menu";
|
|||||||
import ResolveComment from "@/features/comment/components/resolve-comment";
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,6 +26,10 @@ interface CommentListItemProps {
|
|||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
|
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
|
||||||
|
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
|
||||||
|
// allowed to comment cannot apply edits).
|
||||||
|
canEdit?: boolean;
|
||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ function CommentListItem({
|
|||||||
comment,
|
comment,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: CommentListItemProps) {
|
}: CommentListItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -43,6 +50,7 @@ function CommentListItem({
|
|||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
@@ -95,6 +103,18 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleApplySuggestion() {
|
||||||
|
try {
|
||||||
|
await applySuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Errors surface via the mutation's onError notification (incl. 409).
|
||||||
|
console.error("Failed to apply suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -211,6 +231,47 @@ function CommentListItem({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
||||||
|
carrying a suggestion. Old text struck-through/red, new text green. */}
|
||||||
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
|
<Box className={classes.suggestionBlock}>
|
||||||
|
{comment.selection && (
|
||||||
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
|
{comment.selection}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
|
{comment.suggestedText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{comment.suggestionAppliedAt ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
mt={6}
|
||||||
|
aria-label={t("Applied")}
|
||||||
|
>
|
||||||
|
{t("Applied")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
canShowApply(comment, canEdit) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
mt={6}
|
||||||
|
onClick={handleApplySuggestion}
|
||||||
|
loading={applySuggestionMutation.isPending}
|
||||||
|
disabled={applySuggestionMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentEditor defaultContent={content} editable={false} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
|
|
||||||
const canComment =
|
const canComment =
|
||||||
(page?.permissions?.canEdit ?? false) ||
|
canEdit ||
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
(space?.settings?.comments?.allowViewerComments === true);
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
@@ -137,6 +139,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
comment={comment}
|
comment={comment}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
@@ -144,6 +147,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +164,14 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
[
|
||||||
|
comments,
|
||||||
|
handleAddReply,
|
||||||
|
isLoading,
|
||||||
|
space?.membership?.role,
|
||||||
|
canComment,
|
||||||
|
canEdit,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
@@ -300,6 +311,7 @@ interface ChildCommentsProps {
|
|||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
@@ -307,6 +319,7 @@ const ChildComments = ({
|
|||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const getChildComments = useCallback(
|
||||||
@@ -325,6 +338,7 @@ const ChildComments = ({
|
|||||||
comment={childComment}
|
comment={childComment}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
@@ -332,6 +346,7 @@ const ChildComments = ({
|
|||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,38 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suggested-edit (#315) "было → стало" diff block. */
|
||||||
|
.suggestionBlock {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionOld {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--mantine-color-red-7);
|
||||||
|
background: var(--mantine-color-red-light);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionNew {
|
||||||
|
color: var(--mantine-color-green-9);
|
||||||
|
background: var(--mantine-color-green-light);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
InfiniteData,
|
InfiniteData,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
@@ -176,6 +177,63 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useApplySuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
||||||
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
|
// so we only mutate the cache once the server confirms.
|
||||||
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
const cache = queryClient.getQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||||
|
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
||||||
|
...comment,
|
||||||
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
|
// The server auto-resolves the thread on apply — carry that through.
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
|
// a specific message (with the current text) rather than a generic error.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
const currentText = err?.response?.data?.currentText;
|
||||||
|
if (status === 409 && typeof currentText === "string") {
|
||||||
|
const shortText =
|
||||||
|
currentText.length > 80
|
||||||
|
? `${currentText.slice(0, 80)}…`
|
||||||
|
: currentText;
|
||||||
|
notifications.show({
|
||||||
|
title: t(
|
||||||
|
"The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
),
|
||||||
|
message: shortText,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applySuggestion(commentId: string): Promise<IComment> {
|
||||||
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export interface IComment {
|
|||||||
createdSource?: string;
|
createdSource?: string;
|
||||||
aiChatId?: string | null;
|
aiChatId?: string | null;
|
||||||
resolvedSource?: string | null;
|
resolvedSource?: string | null;
|
||||||
|
// Suggested-edit (#315): when an agent proposes a replacement for the
|
||||||
|
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
|
||||||
|
// applies it server-side the backend stamps `suggestionAppliedAt` /
|
||||||
|
// `suggestionAppliedById` and auto-resolves the thread.
|
||||||
|
suggestedText?: string | null;
|
||||||
|
suggestionAppliedAt?: Date | string | null;
|
||||||
|
suggestionAppliedById?: string | null;
|
||||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
||||||
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
||||||
// human behind it (null for an external MCP agent).
|
// human behind it (null for an external MCP agent).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
|
// comment: it must carry a suggestion, not already be applied or resolved, be a
|
||||||
|
// top-level comment, and the viewer must be able to edit the page.
|
||||||
|
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canEdit &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// A disabled mic must explain WHY it is unavailable rather than silently saying
|
||||||
|
// "Start dictation". This renders MicButton in its idle+disabled state with a
|
||||||
|
// forwarded reason and asserts the accessible label resolves to that reason's
|
||||||
|
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// Pass i18n keys through verbatim so we assert the exact resolved string.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (s: string) => s }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep both controllers inert and idle so MicButton renders the idle branch.
|
||||||
|
const idleCtl = {
|
||||||
|
status: "idle" as const,
|
||||||
|
start: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
audioLevel: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
|
||||||
|
useDictation: () => idleCtl,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
|
||||||
|
useStreamingDictation: () => idleCtl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MicButton } from "./mic-button";
|
||||||
|
|
||||||
|
function renderButton(props: React.ComponentProps<typeof MicButton>) {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MicButton {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MicButton — disabled reason label", () => {
|
||||||
|
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
|
||||||
|
// report "unsupported" and mask the forwarded reason. Stub both so the button
|
||||||
|
// is considered supported and the availability reason is what surfaces.
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
|
||||||
|
class {};
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia: vi.fn() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
|
||||||
|
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
|
||||||
|
const expected =
|
||||||
|
"No connection to the collaboration server — dictation unavailable";
|
||||||
|
// The reason surfaces as the accessible label (and the tooltip text).
|
||||||
|
const button = screen.getByRole("button", { name: expected });
|
||||||
|
expect(button).toBeDefined();
|
||||||
|
// It is marked disabled the Mantine way (data-disabled), NOT the native
|
||||||
|
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
|
||||||
|
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||||
|
expect(button.hasAttribute("disabled")).toBe(false);
|
||||||
|
// And it no longer silently reads "Start dictation".
|
||||||
|
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'Start dictation' when enabled with no reason", () => {
|
||||||
|
renderButton({ onText: () => {} });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Start dictation" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not advertise 'Start dictation' when disabled with no reason", () => {
|
||||||
|
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
|
||||||
|
// unavailableReason must not get a hoverable mic whose tooltip invites
|
||||||
|
// "Start dictation" on a click that is rejected.
|
||||||
|
renderButton({ onText: () => {}, disabled: true });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Start dictation" }),
|
||||||
|
).toBeNull();
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||||
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||||
|
import {
|
||||||
|
isDictationSupported,
|
||||||
|
resolveUnavailableLabel,
|
||||||
|
type DictationUnavailableReason,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
import classes from "./mic-button.module.css";
|
import classes from "./mic-button.module.css";
|
||||||
|
|
||||||
interface MicButtonProps {
|
interface MicButtonProps {
|
||||||
@@ -21,6 +26,9 @@ interface MicButtonProps {
|
|||||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||||
// text progressively as the user pauses; otherwise use the batch controller.
|
// text progressively as the user pauses; otherwise use the batch controller.
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
// When the mic is disabled for an availability reason, this is the cause the
|
||||||
|
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
|
||||||
|
unavailableReason?: DictationUnavailableReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +45,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
color,
|
color,
|
||||||
iconSize,
|
iconSize,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
|
unavailableReason,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||||
@@ -46,7 +55,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
const batchCtl = useDictation({ onText, onStart });
|
const batchCtl = useDictation({ onText, onStart });
|
||||||
const streamingCtl = useStreamingDictation({ onText, onStart });
|
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||||
const ctl = streaming ? streamingCtl : batchCtl;
|
const ctl = streaming ? streamingCtl : batchCtl;
|
||||||
const { status, start, stop, audioLevel } = ctl;
|
const { status, start, stop, audioLevel, errorMessage } = ctl;
|
||||||
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||||
|
|
||||||
if (status === "recording") {
|
if (status === "recording") {
|
||||||
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
) {
|
) {
|
||||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||||
// a confusing second click can't fire while the model loads.
|
// a confusing second click can't fire while the model loads. The error case
|
||||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
// explains the failure via the hook's resolved errorMessage instead of the
|
||||||
|
// transient "Transcribing…" label.
|
||||||
|
const label =
|
||||||
|
status === "error"
|
||||||
|
? (errorMessage ?? t("Transcription failed"))
|
||||||
|
: status === "loading"
|
||||||
|
? t("Preparing…")
|
||||||
|
: t("Transcribing…");
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} withArrow>
|
<Tooltip label={label} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={size}
|
size={size}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={color}
|
color={color}
|
||||||
disabled
|
// Mark disabled the Mantine way (data-disabled/aria-disabled) rather
|
||||||
|
// than the native `disabled` attribute: native `disabled` sets
|
||||||
|
// `pointer-events:none`, which suppresses hover so the Tooltip never
|
||||||
|
// fires. This is a status display with no click action to guard, so
|
||||||
|
// keeping it hoverable simply lets the error reason be read on hover.
|
||||||
|
data-disabled
|
||||||
|
aria-disabled
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
@@ -99,18 +121,56 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
|
||||||
|
// unsupported browser/context is detected here; otherwise the parent forwards
|
||||||
|
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
|
||||||
|
// renders `<button disabled>` with `pointer-events:none`, which suppresses
|
||||||
|
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
|
||||||
|
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
|
||||||
|
// and guard the click ourselves.
|
||||||
|
const unsupported = !isDictationSupported();
|
||||||
|
const isDisabled = disabled || unsupported;
|
||||||
|
const reason: DictationUnavailableReason | undefined = unsupported
|
||||||
|
? "unsupported"
|
||||||
|
: unavailableReason;
|
||||||
|
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
|
||||||
|
// A disabled mic with a known reason surfaces it on hover; an enabled mic
|
||||||
|
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
|
||||||
|
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
|
||||||
|
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
|
||||||
|
// tooltip on a control that rejects the click. In that case we render the icon
|
||||||
|
// without a Tooltip and give it a neutral accessible label instead.
|
||||||
|
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
|
||||||
|
const icon = (
|
||||||
|
<ActionIcon
|
||||||
|
size={size}
|
||||||
|
variant="subtle"
|
||||||
|
color={color}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isDisabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void start();
|
||||||
|
}}
|
||||||
|
data-disabled={isDisabled || undefined}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<IconMicrophone size={resolvedIconSize} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
|
||||||
|
// a grey, unclickable mic should not advertise "Start dictation".
|
||||||
|
if (isDisabled && !reasonLabel) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("Start dictation")} withArrow>
|
<Tooltip
|
||||||
<ActionIcon
|
label={reasonLabel ?? t("Start dictation")}
|
||||||
size={size}
|
withArrow
|
||||||
variant="subtle"
|
>
|
||||||
color={color}
|
{icon}
|
||||||
onClick={() => void start()}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={t("Start dictation")}
|
|
||||||
>
|
|
||||||
<IconMicrophone size={resolvedIconSize} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
resolveUnavailableLabel,
|
||||||
|
isDictationSupported,
|
||||||
|
} from "./dictation-status";
|
||||||
|
|
||||||
|
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
|
||||||
|
// Both dictation hooks and the mic button form their user-facing strings here,
|
||||||
|
// so a regression in the classification or message mapping would silently swap
|
||||||
|
// what a user reads when the mic is grey or a recording fails. A fake `t`
|
||||||
|
// returns its key verbatim so we assert the exact i18n key each branch selects.
|
||||||
|
const t = (k: string) => k;
|
||||||
|
|
||||||
|
describe("classifyGetUserMediaError", () => {
|
||||||
|
it("maps NotAllowedError / SecurityError to mic-denied", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
|
||||||
|
"mic-denied",
|
||||||
|
);
|
||||||
|
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
|
||||||
|
"mic-denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
|
||||||
|
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
|
||||||
|
"no-mic",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps NotReadableError / AbortError to mic-in-use", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
|
||||||
|
"mic-in-use",
|
||||||
|
);
|
||||||
|
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
|
||||||
|
"mic-in-use",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps anything else / undefined to unknown", () => {
|
||||||
|
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
|
||||||
|
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
|
||||||
|
expect(classifyGetUserMediaError({})).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyTranscriptionError", () => {
|
||||||
|
it("returns the verbatim server message when present", () => {
|
||||||
|
const err = { response: { status: 500, data: { message: "provider 404" } } };
|
||||||
|
expect(classifyTranscriptionError(err)).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
serverMessage: "provider 404",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
|
||||||
|
code: "stt-not-configured",
|
||||||
|
});
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
|
||||||
|
code: "stt-not-configured",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to transcription-failed with no server message otherwise", () => {
|
||||||
|
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
});
|
||||||
|
expect(classifyTranscriptionError(new Error("network"))).toEqual({
|
||||||
|
code: "transcription-failed",
|
||||||
|
});
|
||||||
|
// Blank server message is ignored (does not win as verbatim text).
|
||||||
|
expect(
|
||||||
|
classifyTranscriptionError({ response: { data: { message: " " } } }),
|
||||||
|
).toEqual({ code: "transcription-failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dictationErrorMessage", () => {
|
||||||
|
it("maps each code to the expected i18n key", () => {
|
||||||
|
expect(dictationErrorMessage("mic-denied", t)).toBe(
|
||||||
|
"Microphone access denied",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
|
||||||
|
expect(dictationErrorMessage("mic-in-use", t)).toBe(
|
||||||
|
"Microphone is unavailable or already in use",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("no-media-devices", t)).toBe(
|
||||||
|
"Audio recording is not available in this browser/context",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
|
||||||
|
"Voice dictation is not configured",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("transcription-failed", t)).toBe(
|
||||||
|
"Transcription failed",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("recorder-failed", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
expect(dictationErrorMessage("unknown", t)).toBe(
|
||||||
|
"Could not start recording",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("transcription-failed", t, {
|
||||||
|
serverMessage: "quota exceeded",
|
||||||
|
}),
|
||||||
|
).toBe("quota exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the detail to recorder-failed / unknown", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
|
||||||
|
).toBe("Could not start recording: boom");
|
||||||
|
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
|
||||||
|
"Could not start recording: nope",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the detail to transcription-failed when there is no server message", () => {
|
||||||
|
expect(
|
||||||
|
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
|
||||||
|
).toBe("Transcription failed: timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveUnavailableLabel", () => {
|
||||||
|
it("maps each reason to its expected i18n key", () => {
|
||||||
|
expect(resolveUnavailableLabel("connecting", t)).toBe(
|
||||||
|
"Dictation becomes available once the page finishes connecting",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("offline", t)).toBe(
|
||||||
|
"No connection to the collaboration server — dictation unavailable",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("read-only", t)).toBe(
|
||||||
|
"This page is read-only",
|
||||||
|
);
|
||||||
|
expect(resolveUnavailableLabel("unsupported", t)).toBe(
|
||||||
|
"Audio recording is not available in this browser/context",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDictationSupported", () => {
|
||||||
|
it("returns a boolean", () => {
|
||||||
|
expect(typeof isDictationSupported()).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Single source of truth for "why dictation is unavailable" and "why it failed".
|
||||||
|
// Both dictation hooks and the mic button pull their user-facing strings from
|
||||||
|
// the resolvers here so the wording lives in exactly one place.
|
||||||
|
|
||||||
|
export type DictationUnavailableReason =
|
||||||
|
| "connecting"
|
||||||
|
| "offline"
|
||||||
|
| "read-only"
|
||||||
|
| "unsupported";
|
||||||
|
|
||||||
|
export type DictationErrorCode =
|
||||||
|
| "no-media-devices"
|
||||||
|
| "mic-denied"
|
||||||
|
| "no-mic"
|
||||||
|
| "mic-in-use"
|
||||||
|
| "recorder-failed"
|
||||||
|
| "vad-init-failed"
|
||||||
|
| "stt-not-configured"
|
||||||
|
| "transcription-failed"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
// True if this browser/context can record audio.
|
||||||
|
export function isDictationSupported(): boolean {
|
||||||
|
return (
|
||||||
|
typeof MediaRecorder !== "undefined" &&
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
!!navigator.mediaDevices?.getUserMedia
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
|
||||||
|
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
|
||||||
|
const name = (err as { name?: string })?.name;
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError")
|
||||||
|
return "mic-denied";
|
||||||
|
if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||||
|
return "no-mic";
|
||||||
|
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcription HTTP failure -> code (+ verbatim server message when present).
|
||||||
|
export function classifyTranscriptionError(err: unknown): {
|
||||||
|
code: DictationErrorCode;
|
||||||
|
serverMessage?: string;
|
||||||
|
} {
|
||||||
|
const resp = (
|
||||||
|
err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
)?.response;
|
||||||
|
const serverMessage = resp?.data?.message;
|
||||||
|
if (serverMessage && serverMessage.trim().length > 0)
|
||||||
|
return { code: "transcription-failed", serverMessage };
|
||||||
|
if (resp?.status === 503 || resp?.status === 403)
|
||||||
|
return { code: "stt-not-configured" };
|
||||||
|
return { code: "transcription-failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFn = (key: string) => string;
|
||||||
|
|
||||||
|
// Code -> user text. The ONE place runtime error strings are formed.
|
||||||
|
// serverMessage (verbatim) wins for transcription-failed; detail is appended
|
||||||
|
// to the generic "could not start"/"transcription failed" strings.
|
||||||
|
export function dictationErrorMessage(
|
||||||
|
code: DictationErrorCode,
|
||||||
|
t: TFn,
|
||||||
|
extra?: { serverMessage?: string; detail?: string },
|
||||||
|
): string {
|
||||||
|
const detail = extra?.detail;
|
||||||
|
switch (code) {
|
||||||
|
case "mic-denied":
|
||||||
|
return t("Microphone access denied");
|
||||||
|
case "no-mic":
|
||||||
|
return t("No microphone found");
|
||||||
|
case "mic-in-use":
|
||||||
|
return t("Microphone is unavailable or already in use");
|
||||||
|
case "no-media-devices":
|
||||||
|
return t("Audio recording is not available in this browser/context");
|
||||||
|
case "stt-not-configured":
|
||||||
|
return t("Voice dictation is not configured");
|
||||||
|
case "transcription-failed":
|
||||||
|
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
|
||||||
|
return extra.serverMessage;
|
||||||
|
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
|
||||||
|
case "recorder-failed":
|
||||||
|
case "vad-init-failed":
|
||||||
|
case "unknown":
|
||||||
|
default:
|
||||||
|
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
|
||||||
|
export function resolveUnavailableLabel(
|
||||||
|
r: DictationUnavailableReason,
|
||||||
|
t: TFn,
|
||||||
|
): string {
|
||||||
|
switch (r) {
|
||||||
|
case "connecting":
|
||||||
|
return t("Dictation becomes available once the page finishes connecting");
|
||||||
|
case "offline":
|
||||||
|
return t(
|
||||||
|
"No connection to the collaboration server — dictation unavailable",
|
||||||
|
);
|
||||||
|
case "read-only":
|
||||||
|
return t("This page is read-only");
|
||||||
|
case "unsupported":
|
||||||
|
default:
|
||||||
|
return t("Audio recording is not available in this browser/context");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||||
@@ -26,6 +31,8 @@ interface UseDictationResult {
|
|||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||||
audioLevel: number;
|
audioLevel: number;
|
||||||
|
// The last error shown to the user (null until one occurs / on a new start).
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate container/codec combinations in preference order. The first one the
|
// Candidate container/codec combinations in preference order. The first one the
|
||||||
@@ -67,6 +74,8 @@ export function useDictation(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||||
// calls the current handlers without re-creating the recorder.
|
// calls the current handlers without re-creating the recorder.
|
||||||
@@ -194,15 +203,16 @@ export function useDictation(
|
|||||||
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
||||||
if (status !== "idle") return;
|
if (status !== "idle") return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
|
// Clear any stale error from a previous attempt.
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
const reason =
|
const reason =
|
||||||
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
||||||
console.error("[dictation] " + reason);
|
console.error("[dictation] " + reason);
|
||||||
notifications.show({
|
const message = dictationErrorMessage("no-media-devices", t);
|
||||||
color: "red",
|
notifications.show({ color: "red", message });
|
||||||
message: t("Audio recording is not available in this browser/context"),
|
setErrorMessage(message);
|
||||||
});
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -215,19 +225,16 @@ export function useDictation(
|
|||||||
// Always log the full error for diagnosis (name, message, stack).
|
// Always log the full error for diagnosis (name, message, stack).
|
||||||
console.error("[dictation] getUserMedia failed", err);
|
console.error("[dictation] getUserMedia failed", err);
|
||||||
const name = (err as { name?: string })?.name;
|
const name = (err as { name?: string })?.name;
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const rawDetail = (err as { message?: string })?.message ?? String(err);
|
||||||
let message: string;
|
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
|
||||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
// resolver branch reproduces this hook's original "Could not start
|
||||||
message = t("Microphone access denied");
|
// recording: <name>: <detail>" text. Each caller owns its own detail; the
|
||||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
// streaming hook intentionally does not add the name.
|
||||||
message = t("No microphone found");
|
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
|
||||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
const code = classifyGetUserMediaError(err);
|
||||||
message = t("Microphone is unavailable or already in use");
|
const message = dictationErrorMessage(code, t, { detail });
|
||||||
} else {
|
|
||||||
// Unknown failure: show the real reason instead of a generic string.
|
|
||||||
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -249,10 +256,10 @@ export function useDictation(
|
|||||||
// The stream was acquired but the recorder failed to construct; stop the
|
// The stream was acquired but the recorder failed to construct; stop the
|
||||||
// tracks so the MediaStream does not leak before bailing out.
|
// tracks so the MediaStream does not leak before bailing out.
|
||||||
stopTracks();
|
stopTracks();
|
||||||
notifications.show({
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
color: "red",
|
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
notifications.show({ color: "red", message });
|
||||||
});
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -293,21 +300,14 @@ export function useDictation(
|
|||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// Log the full error for diagnosis (status + body + stack).
|
// Log the full error for diagnosis (status + body + stack).
|
||||||
console.error("[dictation] transcription failed", err);
|
console.error("[dictation] transcription failed", err);
|
||||||
const resp = (
|
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||||
err as { response?: { status?: number; data?: { message?: string } } }
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
)?.response;
|
const message = dictationErrorMessage(code, t, {
|
||||||
const serverMsg = resp?.data?.message;
|
serverMessage,
|
||||||
let message: string;
|
detail,
|
||||||
if (serverMsg && serverMsg.trim().length > 0) {
|
});
|
||||||
// The server already explains the cause (e.g. provider 404, bad
|
|
||||||
// format, STT not configured) — show it verbatim.
|
|
||||||
message = serverMsg;
|
|
||||||
} else if (resp?.status === 503 || resp?.status === 403) {
|
|
||||||
message = t("Voice dictation is not configured");
|
|
||||||
} else {
|
|
||||||
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
if (errorTimerRef.current !== null) {
|
if (errorTimerRef.current !== null) {
|
||||||
clearTimeout(errorTimerRef.current);
|
clearTimeout(errorTimerRef.current);
|
||||||
@@ -332,10 +332,10 @@ export function useDictation(
|
|||||||
stopTracks();
|
stopTracks();
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
notifications.show({
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
color: "red",
|
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
notifications.show({ color: "red", message });
|
||||||
});
|
setErrorMessage(message);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -405,5 +405,5 @@ export function useDictation(
|
|||||||
};
|
};
|
||||||
}, [clearTimer, stopTracks, stopMeter]);
|
}, [clearTimer, stopTracks, stopMeter]);
|
||||||
|
|
||||||
return { status, start, stop, cancel, audioLevel };
|
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||||
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||||
|
import {
|
||||||
|
classifyGetUserMediaError,
|
||||||
|
classifyTranscriptionError,
|
||||||
|
dictationErrorMessage,
|
||||||
|
} from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||||
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
|
|||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||||
audioLevel: number;
|
audioLevel: number;
|
||||||
|
// The last error shown to the user (null until one occurs / on a new start).
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||||
@@ -60,6 +67,8 @@ export function useStreamingDictation(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||||
// current handlers without re-creating the VAD.
|
// current handlers without re-creating the VAD.
|
||||||
@@ -158,26 +167,6 @@ export function useStreamingDictation(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
|
||||||
const transcriptionErrorMessage = useCallback(
|
|
||||||
(err: unknown): string => {
|
|
||||||
const resp = (
|
|
||||||
err as { response?: { status?: number; data?: { message?: string } } }
|
|
||||||
)?.response;
|
|
||||||
const serverMsg = resp?.data?.message;
|
|
||||||
if (serverMsg && serverMsg.trim().length > 0) {
|
|
||||||
// The server already explains the cause (e.g. provider 404, bad format,
|
|
||||||
// STT not configured) — show it verbatim.
|
|
||||||
return serverMsg;
|
|
||||||
}
|
|
||||||
if (resp?.status === 503 || resp?.status === 403) {
|
|
||||||
return t("Voice dictation is not configured");
|
|
||||||
}
|
|
||||||
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||||
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||||
// the session: log + one notification, then advance past that seq so later
|
// the session: log + one notification, then advance past that seq so later
|
||||||
@@ -204,10 +193,14 @@ export function useStreamingDictation(
|
|||||||
if (epoch !== epochRef.current) return;
|
if (epoch !== epochRef.current) return;
|
||||||
// Log the full error for diagnosis (status + body + stack).
|
// Log the full error for diagnosis (status + body + stack).
|
||||||
console.error("[dictation] segment transcription failed", err);
|
console.error("[dictation] segment transcription failed", err);
|
||||||
notifications.show({
|
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||||
color: "red",
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
message: transcriptionErrorMessage(err),
|
const message = dictationErrorMessage(code, t, {
|
||||||
|
serverMessage,
|
||||||
|
detail,
|
||||||
});
|
});
|
||||||
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
// Skip this seq so later segments can still flush in order.
|
// Skip this seq so later segments can still flush in order.
|
||||||
if (nextEmitSeqRef.current === seq) {
|
if (nextEmitSeqRef.current === seq) {
|
||||||
nextEmitSeqRef.current += 1;
|
nextEmitSeqRef.current += 1;
|
||||||
@@ -226,7 +219,7 @@ export function useStreamingDictation(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[drainResults, transcriptionErrorMessage],
|
[drainResults, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async (): Promise<void> => {
|
const start = useCallback(async (): Promise<void> => {
|
||||||
@@ -236,6 +229,8 @@ export function useStreamingDictation(
|
|||||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||||
if (status !== "idle") return;
|
if (status !== "idle") return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
|
// Clear any stale error from a previous attempt.
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
// Notify the caller right when dictation begins (before any async work) so the
|
// Notify the caller right when dictation begins (before any async work) so the
|
||||||
// editor can snapshot the caret position.
|
// editor can snapshot the caret position.
|
||||||
@@ -354,10 +349,9 @@ export function useStreamingDictation(
|
|||||||
// actually runs.)
|
// actually runs.)
|
||||||
console.error("[dictation] VAD init failed", err);
|
console.error("[dictation] VAD init failed", err);
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
notifications.show({
|
const message = dictationErrorMessage("vad-init-failed", t, { detail });
|
||||||
color: "red",
|
notifications.show({ color: "red", message });
|
||||||
message: `${t("Could not start recording")}: ${detail}`,
|
setErrorMessage(message);
|
||||||
});
|
|
||||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||||
// don't leak it.
|
// don't leak it.
|
||||||
destroyVad();
|
destroyVad();
|
||||||
@@ -379,19 +373,11 @@ export function useStreamingDictation(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Always log the full error for diagnosis (name, message, stack).
|
// Always log the full error for diagnosis (name, message, stack).
|
||||||
console.error("[dictation] VAD.start failed", err);
|
console.error("[dictation] VAD.start failed", err);
|
||||||
const name = (err as { name?: string })?.name;
|
|
||||||
const detail = (err as { message?: string })?.message ?? String(err);
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
let message: string;
|
const code = classifyGetUserMediaError(err);
|
||||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
const message = dictationErrorMessage(code, t, { detail });
|
||||||
message = t("Microphone access denied");
|
|
||||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
|
||||||
message = t("No microphone found");
|
|
||||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
|
||||||
message = t("Microphone is unavailable or already in use");
|
|
||||||
} else {
|
|
||||||
message = `${t("Could not start recording")}: ${detail}`;
|
|
||||||
}
|
|
||||||
notifications.show({ color: "red", message });
|
notifications.show({ color: "red", message });
|
||||||
|
setErrorMessage(message);
|
||||||
activeRef.current = false;
|
activeRef.current = false;
|
||||||
destroyVad();
|
destroyVad();
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
@@ -470,5 +456,5 @@ export function useStreamingDictation(
|
|||||||
};
|
};
|
||||||
}, [clearTimer, destroyVad]);
|
}, [clearTimer, destroyVad]);
|
||||||
|
|
||||||
return { status, start, stop, cancel, audioLevel };
|
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
@@ -15,3 +16,15 @@ export const showLinkMenuAtom = atom(false);
|
|||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
// first load, can be toggled locally without persisting to the server.
|
// first load, can be toggled locally without persisting to the server.
|
||||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
||||||
|
|
||||||
|
// Whether the dictation mic can start, and (when it can't) the cause-specific
|
||||||
|
// reason the mic button surfaces as a tooltip. Published by the page editor,
|
||||||
|
// consumed by DictationGroup -> MicButton.
|
||||||
|
export type DictationAvailability = {
|
||||||
|
isEditable: boolean;
|
||||||
|
reason: DictationUnavailableReason | null;
|
||||||
|
};
|
||||||
|
export const dictationAvailabilityAtom = atom<DictationAvailability>({
|
||||||
|
isEditable: false,
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
|
||||||
|
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
|
||||||
|
// page the user can edit, the mic must un-grey once the body becomes editable.
|
||||||
|
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
|
||||||
|
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
|
||||||
|
// publishes (carrying both the editable gate AND the unavailable reason). The mic
|
||||||
|
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
|
||||||
|
// re-render the group and flip the disabled state (jotai drives the subscription).
|
||||||
|
|
||||||
|
// Detectable stand-in that surfaces the `disabled` prop the component computes.
|
||||||
|
vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||||
|
MicButton: ({ disabled }: any) => (
|
||||||
|
<button data-testid="mic" disabled={disabled} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DictationGroup } from "./dictation-group";
|
||||||
|
|
||||||
|
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
|
||||||
|
// handleText). The disabled gate no longer reads this — it reads the atom.
|
||||||
|
function makeFakeEditor() {
|
||||||
|
return {
|
||||||
|
isEditable: false,
|
||||||
|
isDestroyed: false,
|
||||||
|
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
|
||||||
|
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
|
||||||
|
const editor = makeFakeEditor();
|
||||||
|
const store = createStore();
|
||||||
|
// Pre-sync: page editor publishes not-editable (with a reason).
|
||||||
|
store.set(dictationAvailabilityAtom, {
|
||||||
|
isEditable: false,
|
||||||
|
reason: "connecting",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<DictationGroup editor={editor} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
|
||||||
|
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
|
||||||
|
|
||||||
|
// Collab sync -> page editor republishes editable; the atom change must
|
||||||
|
// re-render the group and enable the mic.
|
||||||
|
act(() => {
|
||||||
|
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FC, useRef } from "react";
|
import { FC, useRef } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,6 +17,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
|||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const streamingDictation =
|
const streamingDictation =
|
||||||
workspace?.settings?.ai?.dictationStreaming === true;
|
workspace?.settings?.ai?.dictationStreaming === true;
|
||||||
|
// Cause-specific reason the mic is unavailable (published by the page editor).
|
||||||
|
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
|
||||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||||
// Running insertion point: after each inserted segment we remember the caret
|
// Running insertion point: after each inserted segment we remember the caret
|
||||||
@@ -80,7 +83,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
|||||||
streaming={streamingDictation}
|
streaming={streamingDictation}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
onText={handleText}
|
onText={handleText}
|
||||||
disabled={!editor.isEditable}
|
disabled={!dictationAvailability.isEditable}
|
||||||
|
unavailableReason={dictationAvailability.reason ?? undefined}
|
||||||
color={color}
|
color={color}
|
||||||
iconSize={iconSize}
|
iconSize={iconSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
import {
|
||||||
|
isCollabSynced,
|
||||||
|
isBodyEditable,
|
||||||
|
computeDictationAvailability,
|
||||||
|
} from "./editor-sync-state";
|
||||||
|
|
||||||
describe("isCollabSynced", () => {
|
describe("isCollabSynced", () => {
|
||||||
it("is true only when Connected and synced", () => {
|
it("is true only when Connected and synced", () => {
|
||||||
@@ -30,3 +34,77 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
|||||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
|
||||||
|
const base = {
|
||||||
|
editable: true,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: false,
|
||||||
|
isDisconnected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is available with no reason once synced (showStatic false)", () => {
|
||||||
|
expect(computeDictationAvailability(base)).toEqual({
|
||||||
|
isEditable: true,
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'offline' during pre-sync while disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
...base,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "offline" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'connecting' during pre-sync while still connecting", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
...base,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "connecting" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'read-only' without edit permission", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({ ...base, editable: false }),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports 'read-only' when not in edit mode", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({ ...base, inEditMode: false }),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lack of edit permission takes precedence over the pre-sync reason: a
|
||||||
|
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
|
||||||
|
// still read "read-only", never "offline"/"connecting". This pins the
|
||||||
|
// `opts.editable &&` guard on the pre-sync branch.
|
||||||
|
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable: false,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
|
||||||
|
expect(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable: false,
|
||||||
|
inEditMode: true,
|
||||||
|
showStatic: true,
|
||||||
|
isDisconnected: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ isEditable: false, reason: "read-only" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collab document is usable only once the provider is Connected AND has
|
* The collab document is usable only once the provider is Connected AND has
|
||||||
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether dictation can start and, when it can't, the cause-specific reason the
|
||||||
|
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
|
||||||
|
* tested gate) so the published `isEditable` can never diverge from the actual
|
||||||
|
* body-editable state and make the tooltip lie (#309).
|
||||||
|
*
|
||||||
|
* `isDisconnected` is the caller's own boolean (collab connection is in the
|
||||||
|
* Disconnected state), passed in so this module stays free of the collab enum.
|
||||||
|
*/
|
||||||
|
export function computeDictationAvailability(opts: {
|
||||||
|
editable: boolean;
|
||||||
|
inEditMode: boolean;
|
||||||
|
showStatic: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
|
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
|
||||||
|
const isEditable = isBodyEditable({
|
||||||
|
editable: opts.editable,
|
||||||
|
inEditMode: opts.inEditMode,
|
||||||
|
showStatic: opts.showStatic,
|
||||||
|
});
|
||||||
|
if (isEditable) return { isEditable, reason: null };
|
||||||
|
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
|
||||||
|
if (opts.editable && opts.inEditMode && opts.showStatic) {
|
||||||
|
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
|
||||||
|
}
|
||||||
|
// No edit permission or not in edit mode.
|
||||||
|
return { isEditable, reason: "read-only" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,10 +93,9 @@ describe("useScrollPosition", () => {
|
|||||||
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
|
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
|
||||||
// If the capture were moved into an effect (after handlers register), it
|
// If the capture were moved into an effect (after handlers register), it
|
||||||
// would read the clobbered 0 and this assertion would fail.
|
// would read the clobbered 0 and this assertion would fail.
|
||||||
setScrollHeight(2000); // maxScroll = 1200 >= 500, held steady -> settles
|
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
});
|
});
|
||||||
@@ -104,12 +103,11 @@ describe("useScrollPosition", () => {
|
|||||||
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||||
setScrollHeight(2000); // tall enough + steady -> settles
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("once"));
|
const { result } = renderHook(() => useScrollPosition("once"));
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -121,7 +119,6 @@ describe("useScrollPosition", () => {
|
|||||||
// the window is already at the target and does nothing.
|
// the window is already at the target and does nothing.
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -129,10 +126,10 @@ describe("useScrollPosition", () => {
|
|||||||
it("(b) does not restore when the URL has a #hash anchor", () => {
|
it("(b) does not restore when the URL has a #hash anchor", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
|
||||||
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500) and
|
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500), so
|
||||||
// steady, so without the hash guard the wait would settle and scroll to the
|
// without the hash guard tryRestore would call scrollTo synchronously on the
|
||||||
// target. The assertion below therefore genuinely proves the hash guard
|
// first tick. The assertion below therefore genuinely proves the hash guard
|
||||||
// short-circuits before any scroll (not just that the wait has not fired).
|
// short-circuits before any scroll (not just that the poll has not fired).
|
||||||
setScrollHeight(2000);
|
setScrollHeight(2000);
|
||||||
window.location.hash = "#some-heading";
|
window.location.hash = "#some-heading";
|
||||||
|
|
||||||
@@ -171,7 +168,7 @@ describe("useScrollPosition", () => {
|
|||||||
|
|
||||||
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
||||||
setScrollHeight(2000); // tall enough (would settle and restore, absent the wheel)
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("g1"));
|
const { result } = renderHook(() => useScrollPosition("g1"));
|
||||||
|
|
||||||
@@ -213,9 +210,8 @@ describe("useScrollPosition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
||||||
vi.useFakeTimers();
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
||||||
setScrollHeight(2000); // tall enough + steady -> settles
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("i1"));
|
const { result } = renderHook(() => useScrollPosition("i1"));
|
||||||
|
|
||||||
@@ -225,7 +221,6 @@ describe("useScrollPosition", () => {
|
|||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore still happens: the innocuous keypress did not disable it.
|
// Restore still happens: the innocuous keypress did not disable it.
|
||||||
@@ -234,7 +229,7 @@ describe("useScrollPosition", () => {
|
|||||||
|
|
||||||
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
||||||
setScrollHeight(2000); // tall enough (would settle and restore, absent the scroll key)
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("j1"));
|
const { result } = renderHook(() => useScrollPosition("j1"));
|
||||||
|
|
||||||
@@ -266,7 +261,7 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(d) scrolls to the saved Y once the height settles tall enough", () => {
|
it("(d) scrolls to the saved Y once the content is tall enough", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
|
||||||
setInnerHeight(800);
|
setInnerHeight(800);
|
||||||
@@ -275,50 +270,17 @@ describe("useScrollPosition", () => {
|
|||||||
const { result } = renderHook(() => useScrollPosition("p4"));
|
const { result } = renderHook(() => useScrollPosition("p4"));
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Still waiting: content not laid out tall enough yet.
|
// Still polling: content not laid out yet.
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Content becomes tall enough and then holds steady past the stable window:
|
// Content becomes tall enough: maxScroll = 2000 - 800 = 1200 >= 500.
|
||||||
// maxScroll = 2000 - 800 = 1200 >= 500.
|
|
||||||
setScrollHeight(2000);
|
setScrollHeight(2000);
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(d3) waits for the height to STOP changing before restoring", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}p4b`, "500");
|
|
||||||
setInnerHeight(800);
|
|
||||||
setScrollHeight(2000); // reachable from the start (maxScroll 1200 >= 500)...
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("p4b"));
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ...but the height keeps changing every tick, so it never settles.
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
setScrollHeight(2500);
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
setScrollHeight(3000);
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
setScrollHeight(3500);
|
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
});
|
});
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled(); // reachable, but not settled
|
|
||||||
|
|
||||||
// Height now holds steady past HEIGHT_STABLE_MS -> restore fires (to the
|
|
||||||
// fixed target, unaffected by the taller document).
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,39 +303,53 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(k) a re-trigger while the wait is running does not start a second concurrent poll", () => {
|
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
|
||||||
// Both triggers (early on-mount + post-swap) call restore. The
|
// The static->live editor swap re-invokes restore. The shared budget
|
||||||
// `if (pollTimerRef.current !== null) return` guard makes a re-trigger during
|
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
|
||||||
// an in-flight wait a no-op, so exactly ONE poll runs and scrolls exactly once.
|
// from the FIRST trigger, not restart it on every re-trigger. This pins
|
||||||
// A mutant dropping that guard would start a second parallel poll; since the
|
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
|
||||||
// stubbed scrollTo never moves window.scrollY, the second poll would scroll
|
// `restoreStartRef.current = Date.now()` on every trigger would push the
|
||||||
// again (redundancy guard sees scrollY still 0 != target) -> two calls, and
|
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
|
||||||
// this assertion would fail.
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "500");
|
vi.setSystemTime(0);
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
|
||||||
setInnerHeight(800);
|
setInnerHeight(800);
|
||||||
setScrollHeight(100); // too short -> the first wait keeps polling (no scroll yet)
|
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("k1"));
|
const { result } = renderHook(() => useScrollPosition("k1"));
|
||||||
|
|
||||||
// First trigger: starts the wait.
|
// First trigger at t=0: starts the shared budget and begins polling.
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
});
|
});
|
||||||
// Second trigger while the first wait is still running: the guard suppresses it.
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance to t=3000 (still polling: content short, not yet timed out).
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Second trigger at t=3000 (the swap re-assert). Under the real code the
|
||||||
|
// budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Content becomes reachable and holds steady past the stable window.
|
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
|
||||||
setScrollHeight(2000);
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(1900);
|
||||||
});
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Exactly one scroll — the guard prevented a second concurrent poll.
|
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
// from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
|
||||||
|
// NOT have scrolled here -> this assertion fails against that mutant.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(e) never throws when storage access throws", () => {
|
it("(e) never throws when storage access throws", () => {
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ const SAVE_THROTTLE_MS = 250;
|
|||||||
const MAX_RESTORE_WAIT_MS = 5000;
|
const MAX_RESTORE_WAIT_MS = 5000;
|
||||||
// How often to re-check the document height while waiting for content to load.
|
// How often to re-check the document height while waiting for content to load.
|
||||||
const RESTORE_POLL_MS = 100;
|
const RESTORE_POLL_MS = 100;
|
||||||
// The document height must stay unchanged this long before we treat the layout
|
|
||||||
// as settled and safe to restore against — restoring while content is still
|
|
||||||
// rendering in lets scroll-anchoring drift the saved offset (and re-fire, which
|
|
||||||
// is the residual reload "jitter" this replaces).
|
|
||||||
const HEIGHT_STABLE_MS = 400;
|
|
||||||
|
|
||||||
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
|
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
|
||||||
// is cleared on tab close, which is exactly the lifetime we want for an MVP
|
// is cleared on tab close, which is exactly the lifetime we want for an MVP
|
||||||
@@ -78,13 +73,10 @@ export function hasSavedReadingPosition(pageId: string): boolean {
|
|||||||
* their place across a reload (F5) or reopening the document.
|
* their place across a reload (F5) or reopening the document.
|
||||||
*
|
*
|
||||||
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
||||||
* (early on mount + after the static->live editor swap). It WAITS for the
|
* (early, while the static/cached content is laid out, and again after the
|
||||||
* document height to stop changing (the layout to settle) and then scrolls once
|
* static->live editor swap); it is idempotent, so re-asserting the same target is
|
||||||
* to the saved offset — so it never fires mid-render, where scroll-anchoring
|
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
|
||||||
* would drift the position. It is idempotent: a running wait suppresses a second
|
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
|
||||||
* trigger, and once positioned re-asserting is a no-op. The two scroll mechanisms
|
|
||||||
* are mutually exclusive: if the URL has a `#hash` anchor, the existing
|
|
||||||
* anchor-scroll logic wins and restore is a no-op.
|
|
||||||
*/
|
*/
|
||||||
export function useScrollPosition(pageId: string): {
|
export function useScrollPosition(pageId: string): {
|
||||||
restoreScrollPosition: () => void;
|
restoreScrollPosition: () => void;
|
||||||
@@ -112,6 +104,9 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
||||||
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
|
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
|
||||||
|
// editor swap) share ONE bounded timeout budget instead of restarting it.
|
||||||
|
const restoreStartRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Capture the previously-saved value synchronously during render, before the
|
// Capture the previously-saved value synchronously during render, before the
|
||||||
// effect below registers handlers that would persist the current (0) scrollY.
|
// effect below registers handlers that would persist the current (0) scrollY.
|
||||||
@@ -214,43 +209,36 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// Nothing meaningful to restore to.
|
// Nothing meaningful to restore to.
|
||||||
if (targetY <= 0) return;
|
if (targetY <= 0) return;
|
||||||
|
|
||||||
// Idempotent: if a restore poll is already running, do not start a second.
|
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
|
||||||
// Both triggers (early + post-swap) share it; a running poll suppresses the
|
// never run two concurrent polls against the same target.
|
||||||
// second, and once positioned the redundancy guard makes it a no-op.
|
if (pollTimerRef.current !== null) {
|
||||||
if (pollTimerRef.current !== null) return;
|
window.clearTimeout(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
// Share one timeout budget across re-triggers instead of restarting it.
|
||||||
let lastHeight = -1;
|
if (restoreStartRef.current === null) {
|
||||||
let stableSince = start;
|
restoreStartRef.current = Date.now();
|
||||||
|
}
|
||||||
|
const start = restoreStartRef.current;
|
||||||
|
|
||||||
// Restore ONCE the document height has been stable for HEIGHT_STABLE_MS AND
|
const tryRestore = () => {
|
||||||
// the target is reachable, so the saved pixel offset lands on the same content
|
// Bail mid-poll if the reader started scrolling while we were waiting.
|
||||||
// the reader left. Restoring earlier — while the doc is still rendering in
|
|
||||||
// (progressive content / static->live swap) — lets scroll-anchoring drift the
|
|
||||||
// position and makes the restore re-fire (the reload jitter). The timeout is
|
|
||||||
// the only fallback that clamps to the furthest reachable position.
|
|
||||||
const tick = () => {
|
|
||||||
// Bail mid-wait if the reader started scrolling.
|
|
||||||
if (userInteractedRef.current) {
|
if (userInteractedRef.current) {
|
||||||
pollTimerRef.current = null;
|
pollTimerRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const maxScroll =
|
||||||
const height = document.documentElement.scrollHeight;
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
if (height !== lastHeight) {
|
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||||
lastHeight = height;
|
|
||||||
stableSince = now;
|
|
||||||
}
|
|
||||||
const maxScroll = height - window.innerHeight;
|
|
||||||
const settled = now - stableSince >= HEIGHT_STABLE_MS;
|
|
||||||
const reachable = maxScroll >= targetY;
|
|
||||||
const timedOut = now - start >= MAX_RESTORE_WAIT_MS;
|
|
||||||
|
|
||||||
if ((settled && reachable) || timedOut) {
|
// Restore once the content is tall enough to reach the target, or bail out
|
||||||
|
// after the timeout and scroll as far as currently possible.
|
||||||
|
if (maxScroll >= targetY || timedOut) {
|
||||||
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
||||||
// No-op when already there — avoids a redundant scroll and keeps the two
|
// Redundancy guard: re-asserting the SAME target when already positioned
|
||||||
// triggers from double-scrolling.
|
// is a no-op, so this hook can be called from multiple triggers safely.
|
||||||
if (Math.abs(window.scrollY - top) > 1) {
|
if (Math.abs(window.scrollY - top) > 1) {
|
||||||
window.scrollTo({ top, behavior: "auto" });
|
window.scrollTo({ top, behavior: "auto" });
|
||||||
}
|
}
|
||||||
@@ -259,10 +247,10 @@ export function useScrollPosition(pageId: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stored in a ref so the effect cleanup can cancel it on unmount.
|
// Stored in a ref so the effect cleanup can cancel it on unmount.
|
||||||
pollTimerRef.current = window.setTimeout(tick, RESTORE_POLL_MS);
|
pollTimerRef.current = window.setTimeout(tryRestore, RESTORE_POLL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
tick();
|
tryRestore();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { restoreScrollPosition };
|
return { restoreScrollPosition };
|
||||||
@@ -273,9 +261,8 @@ export function useScrollPosition(pageId: string): {
|
|||||||
*
|
*
|
||||||
* Extracted from PageEditor so the exact restore triggers (their deps and the
|
* Extracted from PageEditor so the exact restore triggers (their deps and the
|
||||||
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
|
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
|
||||||
* `restoreScrollPosition` waits for the layout to settle and is idempotent, so
|
* Behaviour is unchanged: `restoreScrollPosition` is idempotent, so re-asserting
|
||||||
* both triggers together produce a single restore (a running wait suppresses the
|
* the same target from either trigger is a no-op.
|
||||||
* second; once positioned re-asserting is a no-op).
|
|
||||||
*
|
*
|
||||||
* @param pageId the page whose scroll position is persisted/restored.
|
* @param pageId the page whose scroll position is persisted/restored.
|
||||||
* @param editor the tiptap editor instance, or `null` until it is ready.
|
* @param editor the tiptap editor instance, or `null` until it is ready.
|
||||||
@@ -288,18 +275,16 @@ export function useScrollRestoreOnSwap(
|
|||||||
): void {
|
): void {
|
||||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||||
|
|
||||||
// Early trigger: start the restore wait on mount, so a reload that never
|
// Restore as early as the static (cached) content is laid out, before paint,
|
||||||
// reaches the live editor (offline / collab never syncs — the static cache
|
// so the reader's position is applied without a visible jump. Aborts itself if
|
||||||
// stays shown) still restores once the static layout settles. The wait itself
|
// the reader has already started scrolling (handled inside the hook).
|
||||||
// holds off scrolling until the height is stable, and aborts if the reader
|
|
||||||
// starts scrolling (handled inside the hook).
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
restoreScrollPosition();
|
restoreScrollPosition();
|
||||||
}, [restoreScrollPosition]);
|
}, [restoreScrollPosition]);
|
||||||
|
|
||||||
// Post-swap trigger: after the static -> live swap, (re)start the wait so it
|
// Re-assert once after the static -> live editor swap in case the swap reset
|
||||||
// measures the final live layout. Idempotent: a no-op while the early wait is
|
// the window scroll. Idempotent: a no-op when the position is already correct,
|
||||||
// still running, and a no-op once already positioned or the reader interacted.
|
// and a no-op after the reader has interacted.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!showStatic && editor) restoreScrollPosition();
|
if (!showStatic && editor) restoreScrollPosition();
|
||||||
}, [showStatic, editor, restoreScrollPosition]);
|
}, [showStatic, editor, restoreScrollPosition]);
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useSwapHeightReservation } from "./use-swap-height-reservation";
|
||||||
|
|
||||||
|
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
|
||||||
|
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
|
||||||
|
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
|
||||||
|
// the NEXT tick), letting each test advance the release loop frame by frame.
|
||||||
|
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
|
||||||
|
let nextRafId = 1;
|
||||||
|
let realRaf: typeof globalThis.requestAnimationFrame;
|
||||||
|
let realCancel: typeof globalThis.cancelAnimationFrame;
|
||||||
|
|
||||||
|
function tickRaf(): void {
|
||||||
|
const current = rafQueue;
|
||||||
|
rafQueue = [];
|
||||||
|
for (const { cb } of current) cb(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mutable stand-in for the live-content container. The hook only reads
|
||||||
|
// `scrollHeight`, so tests drive the release condition by mutating this.
|
||||||
|
function makeMenuRef(): {
|
||||||
|
ref: RefObject<HTMLElement | null>;
|
||||||
|
setScrollHeight: (h: number) => void;
|
||||||
|
} {
|
||||||
|
const el = { scrollHeight: 0 };
|
||||||
|
return {
|
||||||
|
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
|
||||||
|
setScrollHeight: (h: number) => {
|
||||||
|
el.scrollHeight = h;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const H = 1000;
|
||||||
|
|
||||||
|
describe("useSwapHeightReservation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rafQueue = [];
|
||||||
|
nextRafId = 1;
|
||||||
|
realRaf = globalThis.requestAnimationFrame;
|
||||||
|
realCancel = globalThis.cancelAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||||
|
const id = nextRafId++;
|
||||||
|
rafQueue.push({ id, cb });
|
||||||
|
return id;
|
||||||
|
}) as typeof globalThis.requestAnimationFrame;
|
||||||
|
globalThis.cancelAnimationFrame = ((id: number) => {
|
||||||
|
rafQueue = rafQueue.filter((e) => e.id !== id);
|
||||||
|
}) as typeof globalThis.cancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.requestAnimationFrame = realRaf;
|
||||||
|
globalThis.cancelAnimationFrame = realCancel;
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
|
||||||
|
// that drives the swap wrapper's minHeight. Captured while static is still up,
|
||||||
|
// then the swap flips showStatic; before any release frame runs the reservation
|
||||||
|
// is held at exactly H.
|
||||||
|
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture happens synchronously at the swap point (static still shown).
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
// The swap flips to the live branch.
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBe(H);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (b) release when the live content is tall enough. Guard is `>=`: with
|
||||||
|
// liveHeight === H the reservation releases. This FAILS if the guard direction
|
||||||
|
// were `<` (liveHeight === H is not `< H`, so it would never release).
|
||||||
|
it("(b) releases once live content reaches the reserved height", () => {
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(0);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
|
||||||
|
|
||||||
|
// Live editor finishes laying out to the reserved height.
|
||||||
|
setScrollHeight(H);
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (c) cap escape: the live content never reaches the reserved height, so the
|
||||||
|
// height match never fires; the reservation must still release at the 4000ms
|
||||||
|
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
|
||||||
|
// loop would poll forever while scrollHeight stays below H.
|
||||||
|
it("(c) releases at the 4000ms cap when live content stays too short", () => {
|
||||||
|
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
|
||||||
|
// manual rAF queue in place (default fake timers would replace it).
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
const { ref, setScrollHeight } = makeMenuRef();
|
||||||
|
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||||
|
{ initialProps: { showStatic: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.captureReservation(H);
|
||||||
|
});
|
||||||
|
rerender({ showStatic: false });
|
||||||
|
|
||||||
|
// A few frames pass but time has not reached the cap: still reserved.
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
expect(result.current.reservedHeight).toBe(H);
|
||||||
|
|
||||||
|
// Advance past the cap; the next frame releases even though the live content
|
||||||
|
// is still shorter than the reservation.
|
||||||
|
vi.setSystemTime(4001);
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// (d) non-swap: without a capture (and while static is shown) there is no
|
||||||
|
// reservation and the release loop never arms, so no rAF is scheduled.
|
||||||
|
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
|
||||||
|
const { ref } = makeMenuRef();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSwapHeightReservation(true, ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
expect(rafQueue.length).toBe(0); // release loop never armed
|
||||||
|
act(() => {
|
||||||
|
tickRaf();
|
||||||
|
});
|
||||||
|
expect(result.current.reservedHeight).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// Last-resort release deadline. The primary release is the live-content height
|
||||||
|
// match below; this cap only exists so a slow/short live doc can never pin the
|
||||||
|
// reservation forever. It is generous (well past when the live content normally
|
||||||
|
// reaches the reserved height — it renders the SAME content as the static copy)
|
||||||
|
// so a slow load doesn't release mid-render and reintroduce the collapse.
|
||||||
|
const RELEASE_CAP_MS = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves the document height across the static -> live editor swap.
|
||||||
|
*
|
||||||
|
* The live editor lays out its content over a few frames, so replacing the
|
||||||
|
* (full-height) static copy with it momentarily shrinks the document; the
|
||||||
|
* browser then clamps window scroll to the top, which yanked the reader off
|
||||||
|
* their restored reading position (and threw their scroll to 0 if they were
|
||||||
|
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
|
||||||
|
* document tall through the swap so the scroll position simply survives (#266).
|
||||||
|
* `reservedHeight === null` means no reservation is active.
|
||||||
|
*
|
||||||
|
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
|
||||||
|
* this hook derives by watching `showStatic`. The height MUST be read
|
||||||
|
* synchronously while the static content is still mounted (full natural height),
|
||||||
|
* right before the flip to the live branch. By the time any post-transition
|
||||||
|
* effect here could run, `showStatic` is already false and the wrapper shows the
|
||||||
|
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
|
||||||
|
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
|
||||||
|
* before `setShowStatic(false)`, preserving that exact timing.
|
||||||
|
*
|
||||||
|
* @param showStatic whether the static (cached) content is still shown.
|
||||||
|
* @param menuContainerRef the live-branch content container. It is a descendant
|
||||||
|
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
|
||||||
|
* content height (not inflated by the ancestor min-height reservation).
|
||||||
|
*/
|
||||||
|
export function useSwapHeightReservation(
|
||||||
|
showStatic: boolean,
|
||||||
|
menuContainerRef: RefObject<HTMLElement | null>,
|
||||||
|
): {
|
||||||
|
reservedHeight: number | null;
|
||||||
|
captureReservation: (height: number | null) => void;
|
||||||
|
} {
|
||||||
|
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Capture the current (static, full-height) content height BEFORE the swap so
|
||||||
|
// the wrapper can reserve it while the live editor lays out — otherwise the
|
||||||
|
// transient shrink clamps window scroll to the top. The caller reads
|
||||||
|
// `offsetHeight` synchronously at the swap point and hands it here.
|
||||||
|
const captureReservation = useCallback(
|
||||||
|
(height: number | null) => setReservedHeight(height),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release the reserved height once the live editor's content has laid out to
|
||||||
|
// at least the reserved height (so removing the reservation cannot collapse
|
||||||
|
// the document). The primary release is that height match; the cap is only a
|
||||||
|
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
|
||||||
|
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
|
||||||
|
// space until then.
|
||||||
|
useEffect(() => {
|
||||||
|
if (showStatic || reservedHeight == null) return;
|
||||||
|
let raf = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const check = () => {
|
||||||
|
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
|
||||||
|
if (
|
||||||
|
liveHeight >= reservedHeight ||
|
||||||
|
Date.now() - startedAt > RELEASE_CAP_MS
|
||||||
|
) {
|
||||||
|
setReservedHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(check);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [showStatic, reservedHeight, menuContainerRef]);
|
||||||
|
|
||||||
|
return { reservedHeight, captureReservation };
|
||||||
|
}
|
||||||
@@ -28,11 +28,9 @@ const KEY_PREFIX = "gitmost:scroll-position:";
|
|||||||
// guard the production code directly (verified: removing `&& editor` reddens the
|
// guard the production code directly (verified: removing `&& editor` reddens the
|
||||||
// first test).
|
// first test).
|
||||||
//
|
//
|
||||||
// Both tests observe the real effect via `window.scrollTo`. Restore is NOT
|
// Both tests observe the real effect via `window.scrollTo`. The stubbed
|
||||||
// synchronous: it waits for the document height to settle (HEIGHT_STABLE_MS)
|
// `window.scrollTo` never mutates `window.scrollY`, and the target is left
|
||||||
// before scrolling, so the tests use fake timers and advance them with a steady,
|
// unreached, so every restore invocation that passes the guard yields exactly one
|
||||||
// reachable height to let the wait fire. The stubbed `window.scrollTo` never
|
|
||||||
// mutates `window.scrollY`, so every restore that settles yields exactly one
|
|
||||||
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
|
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
|
||||||
|
|
||||||
function setScrollY(value: number): void {
|
function setScrollY(value: number): void {
|
||||||
@@ -82,69 +80,61 @@ describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
|
|||||||
window.location.hash = "";
|
window.location.hash = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("early trigger restores once the layout settles; post-swap re-assert gated by && editor", () => {
|
it("re-invokes restore after the swap, with the [showStatic, editor] deps/guard", () => {
|
||||||
// Restore WAITS for the document height to settle (HEIGHT_STABLE_MS), so tests
|
// Target is immediately reachable, so each restore that passes the guard
|
||||||
// advance fake timers. `window.scrollY` stays 0 (stubbed scrollTo never updates
|
// scrolls synchronously. `window.scrollY` stays 0 (stubbed scrollTo never
|
||||||
// it), so scrollTo's call count proxies the number of effective restores.
|
// updates it), so scrollTo is called once per effective restore — a proxy for
|
||||||
vi.useFakeTimers();
|
// the restore invocation count.
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
|
||||||
setInnerHeight(800);
|
setInnerHeight(800);
|
||||||
setScrollHeight(2000); // reachable + held steady -> the wait settles
|
setScrollHeight(2000); // maxScroll = 1200 >= 500: reachable, no polling.
|
||||||
|
|
||||||
// Pre-swap: the early on-mount trigger's wait settles and restores once — this
|
// Pre-swap: static content shown, live editor not ready. Only the early
|
||||||
// is the offline / collab-never-syncs path (no swap needed).
|
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<Host pageId="guard" showStatic={true} editor={null} />,
|
<Host pageId="guard" showStatic={true} editor={null} />,
|
||||||
);
|
);
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// showStatic flips false but the editor is still null: the post-swap effect
|
// Collab reports synced (showStatic flips false) but the editor is not ready
|
||||||
// re-runs (deps [showStatic, editor] changed) but its `&& editor` guard must
|
// yet: the swap effect re-runs (deps [showStatic, editor] changed) but the
|
||||||
// keep it a no-op. (Dropping `&& editor` would start a fresh wait against a
|
// `&& editor` guard must keep it a no-op. The early effect does NOT re-fire
|
||||||
// null editor and produce a 2nd scrollTo, failing this expectation.)
|
// (its dep [restoreScrollPosition] is a stable useCallback([])).
|
||||||
|
// (Pins the guard: dropping `&& editor` would restore against a null editor,
|
||||||
|
// producing a 2nd scrollTo and failing this expectation.)
|
||||||
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
|
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// The static -> live swap completes (showStatic false AND editor present): the
|
// The static -> live swap completes (showStatic false AND editor present): the
|
||||||
// post-swap effect re-invokes restore, whose fresh wait settles and re-asserts.
|
// post-swap effect re-asserts the restore exactly once more, driven solely by
|
||||||
|
// the [showStatic, editor] deps changing.
|
||||||
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
|
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restore waits for the height to settle before scrolling (end-to-end via the hook)", () => {
|
it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
|
||||||
|
// End-to-end through the real useScrollPosition (inside the hook): the swap
|
||||||
|
// re-invocation is the CAUSE of the scroll (nothing scrolls before it).
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
||||||
setInnerHeight(800);
|
setInnerHeight(800);
|
||||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet.
|
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
|
||||||
|
|
||||||
// Mount + swap while the content is still too short: nothing scrolls, even as
|
// Pre-swap: the early restore runs but content is too short, so it starts
|
||||||
// time passes — restore never fires against an unsettled/unreachable layout.
|
// polling (a pending timer) without scrolling. We never advance timers, so the
|
||||||
|
// early poll cannot fire on its own — isolating the swap as the sole cause.
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<Host pageId="peg" showStatic={true} editor={null} />,
|
<Host pageId="peg" showStatic={true} editor={null} />,
|
||||||
);
|
);
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
|
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// The live content finally lays out tall enough and holds steady past the
|
// The live content is now laid out tall enough to reach the target.
|
||||||
// stable window -> restore fires exactly to the saved target.
|
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||||
setScrollHeight(2000);
|
|
||||||
|
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
|
||||||
|
// hook, whose synchronous tryRestore now reaches the target and scrolls.
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(500);
|
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
|
||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ import {
|
|||||||
collabExtensions,
|
collabExtensions,
|
||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
@@ -79,6 +80,7 @@ import { jwtDecode } from "jwt-decode";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||||
|
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
||||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
@@ -87,6 +89,7 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb
|
|||||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
computeDictationAvailability,
|
||||||
isBodyEditable,
|
isBodyEditable,
|
||||||
isCollabSynced,
|
isCollabSynced,
|
||||||
} from "@/features/editor/editor-sync-state";
|
} from "@/features/editor/editor-sync-state";
|
||||||
@@ -138,6 +141,7 @@ export default function PageEditor({
|
|||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
|
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
|
||||||
const canScroll = useCallback(
|
const canScroll = useCallback(
|
||||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
@@ -449,6 +453,22 @@ export default function PageEditor({
|
|||||||
const hasConnectedOnceRef = useRef(false);
|
const hasConnectedOnceRef = useRef(false);
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
const [showStatic, setShowStatic] = useState(true);
|
||||||
|
|
||||||
|
// Reserved height held across the static -> live editor swap. The live editor
|
||||||
|
// lays out its content over a few frames, so replacing the (full-height) static
|
||||||
|
// copy with it momentarily shrinks the document; the browser then clamps window
|
||||||
|
// scroll to the top, which yanked the reader off their restored reading position
|
||||||
|
// (and threw their scroll to 0 if they were scrolling at that moment). Pinning a
|
||||||
|
// min-height on the swap wrapper keeps the document tall through the swap so the
|
||||||
|
// scroll position simply survives. `null` = no reservation active.
|
||||||
|
const swapWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Reserve/release wiring lives in the hook so its capture trigger and release
|
||||||
|
// guard/cap are directly unit-testable. Capture stays synchronous at the swap
|
||||||
|
// point (see the collab-sync effect below); the hook only owns the release.
|
||||||
|
const { reservedHeight, captureReservation } = useSwapHeightReservation(
|
||||||
|
showStatic,
|
||||||
|
menuContainerRef,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||||
@@ -471,12 +491,36 @@ export default function PageEditor({
|
|||||||
);
|
);
|
||||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||||
|
|
||||||
|
// Publish whether dictation can start and, if not, the cause-specific reason
|
||||||
|
// the mic button surfaces. Recomputed on the same signals that drive body
|
||||||
|
// editability so the tooltip never lies about the current state.
|
||||||
|
useEffect(() => {
|
||||||
|
setDictationAvailability(
|
||||||
|
computeDictationAvailability({
|
||||||
|
editable,
|
||||||
|
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||||
|
showStatic,
|
||||||
|
isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
editable,
|
||||||
|
currentPageEditMode,
|
||||||
|
showStatic,
|
||||||
|
yjsConnectionStatus,
|
||||||
|
setDictationAvailability,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
|
// Capture the current (static, full-height) content height BEFORE the swap
|
||||||
|
// so the wrapper can reserve it while the live editor lays out — otherwise
|
||||||
|
// the transient shrink clamps window scroll to the top.
|
||||||
|
captureReservation(swapWrapperRef.current?.offsetHeight ?? null);
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
@@ -490,6 +534,12 @@ export default function PageEditor({
|
|||||||
<TransclusionLookupProvider>
|
<TransclusionLookupProvider>
|
||||||
<PageEmbedLookupProvider>
|
<PageEmbedLookupProvider>
|
||||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||||
|
<div
|
||||||
|
ref={swapWrapperRef}
|
||||||
|
style={
|
||||||
|
reservedHeight != null ? { minHeight: reservedHeight } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{showStatic ? (
|
{showStatic ? (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
{/* Surface the pre-sync read-only window so edits typed before the
|
{/* Surface the pre-sync read-only window so edits typed before the
|
||||||
@@ -577,6 +627,7 @@ export default function PageEditor({
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</PageEmbedAncestryProvider>
|
</PageEmbedAncestryProvider>
|
||||||
</PageEmbedLookupProvider>
|
</PageEmbedLookupProvider>
|
||||||
</TransclusionLookupProvider>
|
</TransclusionLookupProvider>
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||||
|
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||||
|
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||||
|
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||||
|
// the contract we care about — is deterministically queryable.
|
||||||
|
vi.mock("@mantine/core", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||||
|
const Tooltip = ({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<span data-testid="tooltip-label">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
);
|
||||||
|
return { ...actual, Tooltip };
|
||||||
|
});
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!window.matchMedia) {
|
||||||
|
window.matchMedia = (query: string) =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||||
|
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||||
|
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||||
|
// test, so we stub everything else down to inert, deterministic renders and
|
||||||
|
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||||
|
|
||||||
|
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||||
|
// letting us assert provenance attribution without a real i18n backend.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, vars?: Record<string, unknown>) =>
|
||||||
|
vars && typeof vars.name !== "undefined"
|
||||||
|
? key.replace("{{name}}", String(vars.name))
|
||||||
|
: key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||||
|
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||||
|
// does NOT wire any of them.
|
||||||
|
const setAiChatWindowOpen = vi.fn();
|
||||||
|
const setActiveChatId = vi.fn();
|
||||||
|
const setDraft = vi.fn();
|
||||||
|
const setHistoryModalOpen = vi.fn();
|
||||||
|
vi.mock("jotai", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useSetAtom: (atom: unknown) => {
|
||||||
|
switch (atom) {
|
||||||
|
case aiChatWindowOpenAtom:
|
||||||
|
return setAiChatWindowOpen;
|
||||||
|
case activeAiChatIdAtom:
|
||||||
|
return setActiveChatId;
|
||||||
|
case aiChatDraftAtom:
|
||||||
|
return setDraft;
|
||||||
|
case historyAtoms:
|
||||||
|
return setHistoryModalOpen;
|
||||||
|
default:
|
||||||
|
return vi.fn();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||||
|
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||||
|
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||||
|
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||||
|
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||||
|
historyAtoms: { __tag: "historyAtoms" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||||
|
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||||
|
CustomAvatar: ({ name }: { name?: string }) => (
|
||||||
|
<span data-testid="avatar">{name}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Deterministic, locale-free date string.
|
||||||
|
vi.mock("@/lib/time", () => ({
|
||||||
|
formattedDate: () => "2026-06-21",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import HistoryItem from "./history-item";
|
||||||
|
import {
|
||||||
|
activeAiChatIdAtom,
|
||||||
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatDraftAtom,
|
||||||
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
|
||||||
|
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||||
|
return {
|
||||||
|
id: "h1",
|
||||||
|
pageId: "p1",
|
||||||
|
title: "Title",
|
||||||
|
slug: "slug",
|
||||||
|
icon: "",
|
||||||
|
coverPhoto: "",
|
||||||
|
version: 1,
|
||||||
|
lastUpdatedById: "u1",
|
||||||
|
workspaceId: "w1",
|
||||||
|
createdAt: "2026-06-21T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||||
|
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(item: IPageHistory) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<HistoryItem
|
||||||
|
historyItem={item}
|
||||||
|
index={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
isActive={false}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HistoryItem git-sync provenance badge", () => {
|
||||||
|
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||||
|
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||||
|
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||||
|
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["agent", "agent"],
|
||||||
|
["user", "user"],
|
||||||
|
["undefined", undefined],
|
||||||
|
])(
|
||||||
|
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||||
|
(_label, source) => {
|
||||||
|
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||||
|
expect(screen.queryByText("Git sync")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||||
|
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||||
|
renderItem(
|
||||||
|
makeItem({
|
||||||
|
lastUpdatedSource: "git-sync",
|
||||||
|
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = screen.getByText("Git sync");
|
||||||
|
|
||||||
|
// Provenance attribution: the tooltip label carries the author name (the
|
||||||
|
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||||
|
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||||
|
|
||||||
|
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||||
|
// itself is not a button, carries no role=button and no tabIndex, and
|
||||||
|
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||||
|
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||||
|
// not the badge — so we scope these checks to the badge element.)
|
||||||
|
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||||
|
badge) as HTMLElement;
|
||||||
|
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||||
|
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||||
|
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||||
|
// No interactive descendant button lives inside the badge itself.
|
||||||
|
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||||
|
|
||||||
|
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||||
|
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||||
|
expect(setDraft).not.toHaveBeenCalled();
|
||||||
|
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity contrast: the agent provenance IS interactive when it carries an
|
||||||
|
// aiChatId — proving the not-clickable assertion above is real. The old text
|
||||||
|
// `AiAgentBadge` was superseded by `AgentAvatarStack` (#300), which becomes a
|
||||||
|
// role=button deep-link (and fires the ai-chat atoms) when an aiChatId is present.
|
||||||
|
it("contrast: the agent stack is a deep-link button when it has an aiChatId", () => {
|
||||||
|
renderItem(
|
||||||
|
makeItem({
|
||||||
|
lastUpdatedSource: "agent",
|
||||||
|
agent: { name: "Zeta" },
|
||||||
|
lastUpdatedAiChatId: "chat-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The agent glyph lives inside the clickable stack; walk up to its role=button.
|
||||||
|
const root = screen.getByTestId("agent-glyph").closest("[role='button']");
|
||||||
|
expect(root).not.toBeNull();
|
||||||
|
(root as HTMLElement).dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true }),
|
||||||
|
);
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("chat-1");
|
||||||
|
expect(setAiChatWindowOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./css/history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
@@ -41,6 +42,7 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
const contributors = historyItem.contributors;
|
const contributors = historyItem.contributors;
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||||
|
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -109,6 +111,10 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onActivate={() => setHistoryModalOpen(false)}
|
onActivate={() => setHistoryModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isGitSyncEdit && (
|
||||||
|
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|||||||
return (
|
return (
|
||||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
{/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
|
||||||
|
their own row on narrow screens; flex:1 (basis 0) never wraps and
|
||||||
|
instead crushes the text into a one-word-per-line ladder. */}
|
||||||
|
<Group
|
||||||
|
gap="xs"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ flex: "1 1 16rem", minWidth: 0 }}
|
||||||
|
>
|
||||||
<IconClockHour4
|
<IconClockHour4
|
||||||
size={18}
|
size={18}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
@@ -87,28 +101,58 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Group gap="xs" wrap="nowrap">
|
<>
|
||||||
<Button
|
{/* Desktop: full labeled buttons. */}
|
||||||
size="xs"
|
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||||
variant="subtle"
|
<Button
|
||||||
color="red"
|
size="xs"
|
||||||
leftSection={<IconTrash size={16} />}
|
variant="subtle"
|
||||||
onClick={handleTrashNow}
|
color="red"
|
||||||
loading={isDeleting}
|
leftSection={<IconTrash size={16} />}
|
||||||
>
|
onClick={handleTrashNow}
|
||||||
{t("Move to trash")}
|
loading={isDeleting}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{t("Move to trash")}
|
||||||
size="xs"
|
</Button>
|
||||||
variant="light"
|
<Button
|
||||||
color="orange"
|
size="xs"
|
||||||
leftSection={<IconClockHour4 size={16} />}
|
variant="light"
|
||||||
onClick={handleMakePermanent}
|
color="orange"
|
||||||
loading={toggleTemporary.isPending}
|
leftSection={<IconClockHour4 size={16} />}
|
||||||
>
|
onClick={handleMakePermanent}
|
||||||
{t("Make permanent")}
|
loading={toggleTemporary.isPending}
|
||||||
</Button>
|
>
|
||||||
</Group>
|
{t("Make permanent")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
|
||||||
|
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||||
|
<Tooltip label={t("Move to trash")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={handleTrashNow}
|
||||||
|
loading={isDeleting}
|
||||||
|
aria-label={t("Move to trash")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t("Make permanent")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
onClick={handleMakePermanent}
|
||||||
|
loading={toggleTemporary.isPending}
|
||||||
|
aria-label={t("Make permanent")}
|
||||||
|
>
|
||||||
|
<IconClockHour4 size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeAll,
|
||||||
|
afterEach,
|
||||||
|
} from "vitest";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||||
|
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||||
|
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||||
|
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||||
|
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||||
|
|
||||||
|
// i18n: identity translator — labels stay as their English keys for queries.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
let isPending = false;
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useUpdateSpaceMutation: () => ({
|
||||||
|
mutateAsync,
|
||||||
|
get isPending() {
|
||||||
|
return isPending;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!window.matchMedia) {
|
||||||
|
window.matchMedia = (query: string) =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { EditSpaceForm } from "./edit-space-form";
|
||||||
|
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
|
||||||
|
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||||
|
return {
|
||||||
|
id: "space-1",
|
||||||
|
name: "Engineering",
|
||||||
|
description: "",
|
||||||
|
slug: "eng",
|
||||||
|
hostname: "host",
|
||||||
|
creatorId: "u1",
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-01-01"),
|
||||||
|
...overrides,
|
||||||
|
} as ISpace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||||
|
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||||
|
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||||
|
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||||
|
// human-readable label text is present alongside.
|
||||||
|
function getToggle(): HTMLInputElement {
|
||||||
|
screen.getByText("Enable Git sync");
|
||||||
|
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoMergeToggle(): HTMLInputElement {
|
||||||
|
screen.getByText("Auto-merge conflicts on push");
|
||||||
|
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mutateAsync.mockReset();
|
||||||
|
isPending = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditSpaceForm git-sync toggle", () => {
|
||||||
|
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||||
|
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||||
|
renderForm({
|
||||||
|
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||||
|
});
|
||||||
|
expect(getToggle().checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||||
|
renderForm({ space: makeSpace() });
|
||||||
|
expect(getToggle().checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||||
|
// optimistically flips the switch.
|
||||||
|
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||||
|
mutateAsync.mockResolvedValue(undefined);
|
||||||
|
renderForm({ space: makeSpace() });
|
||||||
|
|
||||||
|
const toggle = getToggle();
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
// Optimistic update: the switch reflects the new state immediately.
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
spaceId: "space-1",
|
||||||
|
gitSyncEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolution leaves the toggle on.
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: rollback on mutation error — the most valuable test.
|
||||||
|
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||||
|
mutateAsync.mockRejectedValue(new Error("network"));
|
||||||
|
renderForm({
|
||||||
|
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = getToggle();
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
// Optimistically flips on before the rejection lands.
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
spaceId: "space-1",
|
||||||
|
gitSyncEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the rejected promise settles, the component reverts to OFF so the
|
||||||
|
// user is not misled into believing sync is enabled.
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||||
|
it("disables the toggle when readOnly", () => {
|
||||||
|
renderForm({ space: makeSpace(), readOnly: true });
|
||||||
|
expect(getToggle().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the toggle while the mutation is pending", () => {
|
||||||
|
isPending = true;
|
||||||
|
renderForm({ space: makeSpace() });
|
||||||
|
expect(getToggle().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||||
|
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||||
|
renderForm({
|
||||||
|
space: makeSpace({
|
||||||
|
settings: { gitSync: { autoMergeConflicts: true } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(getAutoMergeToggle().checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||||
|
renderForm({ space: makeSpace() });
|
||||||
|
expect(getAutoMergeToggle().checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||||
|
mutateAsync.mockResolvedValue(undefined);
|
||||||
|
renderForm({ space: makeSpace() });
|
||||||
|
|
||||||
|
const toggle = getAutoMergeToggle();
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
// Optimistic update.
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
spaceId: "space-1",
|
||||||
|
autoMergeConflicts: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||||
|
mutateAsync.mockRejectedValue(new Error("network"));
|
||||||
|
renderForm({
|
||||||
|
space: makeSpace({
|
||||||
|
settings: { gitSync: { autoMergeConflicts: false } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = getAutoMergeToggle();
|
||||||
|
expect(toggle.checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle.checked).toBe(true);
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
spaceId: "space-1",
|
||||||
|
autoMergeConflicts: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the toggle when readOnly", () => {
|
||||||
|
renderForm({ space: makeSpace(), readOnly: true });
|
||||||
|
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
import {
|
||||||
import React from "react";
|
Group,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import React, { useState } from "react";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -29,6 +38,37 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||||
|
|
||||||
|
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||||
|
space?.settings?.gitSync?.enabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||||
|
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// One parameterized handler for both git-sync space toggles: they differ only by
|
||||||
|
// the local state setter, the mutation payload field, and the error label. The
|
||||||
|
// update is optimistic and reverts the local state on failure (the mutation
|
||||||
|
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
|
||||||
|
const handleToggle = async (
|
||||||
|
field: "gitSyncEnabled" | "autoMergeConflicts",
|
||||||
|
value: boolean,
|
||||||
|
previous: boolean,
|
||||||
|
setLocal: (next: boolean) => void,
|
||||||
|
errorLabel: string,
|
||||||
|
) => {
|
||||||
|
setLocal(value); // optimistic update
|
||||||
|
try {
|
||||||
|
await updateSpaceMutation.mutateAsync({
|
||||||
|
spaceId: space.id,
|
||||||
|
[field]: value,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setLocal(previous); // revert on failure
|
||||||
|
console.error(errorLabel, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zod4Resolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -104,6 +144,43 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={t("Enable Git sync")}
|
||||||
|
description={t("Sync this space's pages to a Git repository.")}
|
||||||
|
checked={gitSyncEnabled}
|
||||||
|
disabled={readOnly || updateSpaceMutation.isPending}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleToggle(
|
||||||
|
"gitSyncEnabled",
|
||||||
|
event.currentTarget.checked,
|
||||||
|
gitSyncEnabled,
|
||||||
|
setGitSyncEnabled,
|
||||||
|
"Failed to toggle git-sync for space",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
mt="md"
|
||||||
|
label={t("Auto-merge conflicts on push")}
|
||||||
|
description={t(
|
||||||
|
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||||
|
)}
|
||||||
|
checked={autoMergeConflicts}
|
||||||
|
disabled={readOnly || updateSpaceMutation.isPending}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleToggle(
|
||||||
|
"autoMergeConflicts",
|
||||||
|
event.currentTarget.checked,
|
||||||
|
autoMergeConflicts,
|
||||||
|
setAutoMergeConflicts,
|
||||||
|
"Failed to toggle git-sync auto-merge-conflicts",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ export interface ISpaceCommentsSettings {
|
|||||||
allowViewerComments?: boolean;
|
allowViewerComments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISpaceGitSyncSettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
autoMergeConflicts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISpaceSettings {
|
export interface ISpaceSettings {
|
||||||
sharing?: ISpaceSharingSettings;
|
sharing?: ISpaceSharingSettings;
|
||||||
comments?: ISpaceCommentsSettings;
|
comments?: ISpaceCommentsSettings;
|
||||||
|
gitSync?: ISpaceGitSyncSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISpace {
|
export interface ISpace {
|
||||||
@@ -35,6 +41,8 @@ export interface ISpace {
|
|||||||
// for updates
|
// for updates
|
||||||
disablePublicSharing?: boolean;
|
disablePublicSharing?: boolean;
|
||||||
allowViewerComments?: boolean;
|
allowViewerComments?: boolean;
|
||||||
|
gitSyncEnabled?: boolean;
|
||||||
|
autoMergeConflicts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IMembership {
|
interface IMembership {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
PALETTE,
|
||||||
|
avatarStyle,
|
||||||
|
avatarBackgroundCss,
|
||||||
|
normalizeName,
|
||||||
|
minPairwiseDistance,
|
||||||
|
relativeLuminance,
|
||||||
|
contrastRatio,
|
||||||
|
oklchToSrgb,
|
||||||
|
isInGamut,
|
||||||
|
} from "./avatar-palette";
|
||||||
|
|
||||||
|
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
|
||||||
|
function hexToRgb01(hex: string): [number, number, number] {
|
||||||
|
return [
|
||||||
|
parseInt(hex.slice(1, 3), 16) / 255,
|
||||||
|
parseInt(hex.slice(3, 5), 16) / 255,
|
||||||
|
parseInt(hex.slice(5, 7), 16) / 255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("avatar-palette validation", () => {
|
||||||
|
it("palette colors stay distinguishable", () => {
|
||||||
|
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
|
||||||
|
// RINGS tweak drops this, "almost identical" colors would reappear.
|
||||||
|
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
|
||||||
|
expect(PALETTE.length).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
|
||||||
|
// white text = luminance 1, black text = luminance 0 (per buildPalette).
|
||||||
|
const textLum = { white: 1, black: 0 } as const;
|
||||||
|
for (const entry of PALETTE) {
|
||||||
|
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
|
||||||
|
// (a) The chosen text color really clears the code's 3:1 threshold on the
|
||||||
|
// actual background hex — recomputed independently from the hex, not from
|
||||||
|
// the build-time luminance. A slot that picked the wrong text (or a color
|
||||||
|
// too dim for either text) would fail here.
|
||||||
|
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
|
||||||
|
const chosen = contrastRatio(textLum[entry.text], hexLum);
|
||||||
|
expect(chosen).toBeGreaterThanOrEqual(3);
|
||||||
|
// buildPalette prefers white and only falls back to black when white
|
||||||
|
// fails 3:1. Mirror that decision: black is used *only* when white would
|
||||||
|
// not clear the threshold — so a mis-assigned "black" on a dark color
|
||||||
|
// (where white was fine) fails here.
|
||||||
|
if (entry.text === "black") {
|
||||||
|
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
|
||||||
|
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
|
||||||
|
// outside [0,1] and fail here.
|
||||||
|
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avatarStyle", () => {
|
||||||
|
it("name-to-avatar mapping is frozen (golden values)", () => {
|
||||||
|
// Golden slice: if this breaks, all existing avatars change — make sure
|
||||||
|
// that is intentional (a config change in avatar-palette.ts).
|
||||||
|
const s = avatarStyle("Backend Developer");
|
||||||
|
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
|
||||||
|
expect(s.text).toBe("white");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic and normalizes the name", () => {
|
||||||
|
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
|
||||||
|
// Casing, surrounding and repeated whitespace must not change the avatar.
|
||||||
|
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
|
||||||
|
expect(avatarStyle("Backend Developer")).toEqual(
|
||||||
|
avatarStyle("backend developer"),
|
||||||
|
);
|
||||||
|
expect(normalizeName(" PM ")).toBe("pm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a valid base color, angle and matching text", () => {
|
||||||
|
const s = avatarStyle("Нарратор");
|
||||||
|
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
|
||||||
|
expect(idx).toBe(s.paletteIndex);
|
||||||
|
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
|
||||||
|
// Text color comes from the chosen palette entry.
|
||||||
|
expect(s.text).toBe(PALETTE[idx].text);
|
||||||
|
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
|
||||||
|
expect(s.angleDeg % 15).toBe(0);
|
||||||
|
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(s.angleDeg).toBeLessThan(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes the agents that used to collide as violet", () => {
|
||||||
|
// "Структурный редактор" and "Фактчекер" looked identically violet before.
|
||||||
|
expect(avatarStyle("Структурный редактор")).not.toEqual(
|
||||||
|
avatarStyle("Фактчекер"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avatarBackgroundCss", () => {
|
||||||
|
it("renders a two-stop gradient with a soft boundary", () => {
|
||||||
|
const s = avatarStyle("Backend Developer");
|
||||||
|
expect(avatarBackgroundCss(s)).toBe(
|
||||||
|
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic avatar backgrounds for agent roles.
|
||||||
|
*
|
||||||
|
* The palette is generated from scratch at module load in OKLCH (a perceptually
|
||||||
|
* uniform color space), so every value below is tunable: change the ring
|
||||||
|
* configuration or the partner shifts and the whole palette regenerates.
|
||||||
|
*
|
||||||
|
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
|
||||||
|
* - base color index (one of the validated palette colors)
|
||||||
|
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
|
||||||
|
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
|
||||||
|
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
|
||||||
|
* The same name always yields the same avatar, on any platform, forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------- Tunable configuration -------------------------
|
||||||
|
|
||||||
|
export interface RingConfig {
|
||||||
|
/** OKLCH lightness, 0..1 */
|
||||||
|
L: number;
|
||||||
|
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
|
||||||
|
C: number;
|
||||||
|
/** Hue of the first color in the ring, degrees */
|
||||||
|
hueStart: number;
|
||||||
|
/** Number of evenly spaced hues in the ring */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
|
||||||
|
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
|
||||||
|
* Don't add more hues per ring without re-checking minPairwiseDistance():
|
||||||
|
* beyond ~20-24 colors humans stop telling them apart reliably.
|
||||||
|
*/
|
||||||
|
const RINGS: readonly RingConfig[] = [
|
||||||
|
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
|
||||||
|
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Partner color: lightness shifted by this much (negative = darker) */
|
||||||
|
const PARTNER_L_SHIFT = -0.10;
|
||||||
|
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
|
||||||
|
const ANALOG_MIN_SHIFT = 20;
|
||||||
|
const ANALOG_SHIFT_STEP = 5;
|
||||||
|
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
|
||||||
|
/** Complementary scheme: fixed hue shift, degrees */
|
||||||
|
const COMPLEMENTARY_SHIFT = 180;
|
||||||
|
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
|
||||||
|
const TRIADIC_SHIFT = 120;
|
||||||
|
/** Number of split directions (24 -> 15deg per step) */
|
||||||
|
const SPLIT_ANGLE_STEPS = 24;
|
||||||
|
/** Position of the color boundary, percent of the gradient axis */
|
||||||
|
const SPLIT_PERCENT = 50;
|
||||||
|
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
|
||||||
|
const SPLIT_SOFTNESS = 16;
|
||||||
|
|
||||||
|
// ------------------------- OKLCH -> sRGB math -------------------------
|
||||||
|
// Matrices from Bjorn Ottosson's OKLab reference implementation.
|
||||||
|
|
||||||
|
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||||
|
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||||
|
return [
|
||||||
|
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gammaEncode(c: number): number {
|
||||||
|
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
|
||||||
|
const h = (hDeg * Math.PI) / 180;
|
||||||
|
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
|
||||||
|
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInGamut(rgb: readonly number[]): boolean {
|
||||||
|
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
|
||||||
|
function clampChroma(L: number, C: number, hDeg: number): number {
|
||||||
|
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
|
||||||
|
let lo = 0, hi = C;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(rgb: readonly number[]): string {
|
||||||
|
return (
|
||||||
|
"#" +
|
||||||
|
rgb
|
||||||
|
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WCAG relative luminance of an sRGB color (components 0..1). */
|
||||||
|
export function relativeLuminance(rgb: readonly number[]): number {
|
||||||
|
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastRatio(l1: number, l2: number): number {
|
||||||
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Palette generation -------------------------
|
||||||
|
|
||||||
|
export interface PaletteEntry {
|
||||||
|
/** Base background color */
|
||||||
|
hex: string;
|
||||||
|
/** OKLCH coordinates of the base color (used to derive partner colors) */
|
||||||
|
L: number;
|
||||||
|
C: number;
|
||||||
|
h: number;
|
||||||
|
/** Text/icon color with the best WCAG contrast on the base color */
|
||||||
|
text: "white" | "black";
|
||||||
|
/** OKLab coordinates of the base color (kept for validation) */
|
||||||
|
lab: readonly [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPalette(): PaletteEntry[] {
|
||||||
|
const entries: PaletteEntry[] = [];
|
||||||
|
for (const ring of RINGS) {
|
||||||
|
const step = 360 / ring.count;
|
||||||
|
for (let i = 0; i < ring.count; i++) {
|
||||||
|
const h = (ring.hueStart + i * step) % 360;
|
||||||
|
const C = clampChroma(ring.L, ring.C, h);
|
||||||
|
const rgb = oklchToSrgb(ring.L, C, h);
|
||||||
|
const lum = relativeLuminance(rgb);
|
||||||
|
entries.push({
|
||||||
|
hex: toHex(rgb),
|
||||||
|
L: ring.L,
|
||||||
|
C,
|
||||||
|
h,
|
||||||
|
// White text needs >= 3:1 contrast; otherwise fall back to black.
|
||||||
|
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
|
||||||
|
lab: [
|
||||||
|
ring.L,
|
||||||
|
C * Math.cos((h * Math.PI) / 180),
|
||||||
|
C * Math.sin((h * Math.PI) / 180),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
|
||||||
|
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
|
||||||
|
const h2 = (entry.h + shiftDeg + 360) % 360;
|
||||||
|
const L2 = entry.L + PARTNER_L_SHIFT;
|
||||||
|
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated once at module load; regenerates on every build from the config above. */
|
||||||
|
export const PALETTE: readonly PaletteEntry[] = buildPalette();
|
||||||
|
|
||||||
|
// ------------------------- Name -> avatar style -------------------------
|
||||||
|
|
||||||
|
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
|
||||||
|
export function normalizeName(name: string): string {
|
||||||
|
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cyrb53: deterministic 53-bit string hash with good avalanche.
|
||||||
|
* Pure JS, cross-platform — never use language built-in hashing here.
|
||||||
|
*/
|
||||||
|
function cyrb53(str: string, seed = 0): number {
|
||||||
|
let h1 = 0xdeadbeef ^ seed;
|
||||||
|
let h2 = 0x41c6ce57 ^ seed;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const ch = str.charCodeAt(i);
|
||||||
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||||
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||||
|
}
|
||||||
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||||
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||||
|
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarStyle {
|
||||||
|
/** Index of the base color in PALETTE */
|
||||||
|
paletteIndex: number;
|
||||||
|
/** Base color hex */
|
||||||
|
bg: string;
|
||||||
|
/** Second color hex (split partner) */
|
||||||
|
bg2: string;
|
||||||
|
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
|
||||||
|
hueShift: number;
|
||||||
|
/** Direction of the split, degrees */
|
||||||
|
angleDeg: number;
|
||||||
|
/** Text/icon color for the base color */
|
||||||
|
text: "white" | "black";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure function: the same (normalized) name always returns the same style. */
|
||||||
|
export function avatarStyle(agentName: string): AvatarStyle {
|
||||||
|
const h = cyrb53(normalizeName(agentName));
|
||||||
|
// Slice the hash into independent fields, like digits of a number:
|
||||||
|
const paletteIndex = h % PALETTE.length;
|
||||||
|
let rest = Math.floor(h / PALETTE.length);
|
||||||
|
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
|
||||||
|
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
|
||||||
|
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
|
||||||
|
const scheme = rest % 4;
|
||||||
|
rest = Math.floor(rest / 4);
|
||||||
|
let hueShift: number;
|
||||||
|
if (scheme === 2) {
|
||||||
|
hueShift = COMPLEMENTARY_SHIFT;
|
||||||
|
} else if (scheme === 3) {
|
||||||
|
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
|
||||||
|
} else {
|
||||||
|
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
|
||||||
|
hueShift = scheme === 0 ? -magnitude : magnitude;
|
||||||
|
}
|
||||||
|
const entry = PALETTE[paletteIndex];
|
||||||
|
return {
|
||||||
|
paletteIndex,
|
||||||
|
bg: entry.hex,
|
||||||
|
bg2: partnerHex(entry, hueShift),
|
||||||
|
hueShift,
|
||||||
|
angleDeg,
|
||||||
|
text: entry.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS background value: two colors with a slightly blurred boundary. */
|
||||||
|
export function avatarBackgroundCss(style: AvatarStyle): string {
|
||||||
|
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
|
||||||
|
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
|
||||||
|
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Validation -------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
|
||||||
|
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
|
||||||
|
* look alike. Intended for a unit test or a dev-time assertion.
|
||||||
|
*/
|
||||||
|
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
|
||||||
|
let min = Infinity;
|
||||||
|
let pair: [string, string] = ["", ""];
|
||||||
|
for (let i = 0; i < PALETTE.length; i++) {
|
||||||
|
for (let j = i + 1; j < PALETTE.length; j++) {
|
||||||
|
const a = PALETTE[i].lab, b = PALETTE[j].lab;
|
||||||
|
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
|
if (d < min) {
|
||||||
|
min = d;
|
||||||
|
pair = [PALETTE[i].hex, PALETTE[j].hex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { distance: min, pair };
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/git-sync": "workspace:*",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -189,7 +190,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||||
@@ -199,11 +205,17 @@
|
|||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
"setupFiles": [
|
||||||
|
"<rootDir>/../test/jest.setup.ts"
|
||||||
|
],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@docmost/git-sync$": "<rootDir>/../../../packages/git-sync/src/index.ts",
|
||||||
|
"^@docmost/git-sync/(.*)$": "<rootDir>/../../../packages/git-sync/src/$1",
|
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ClsModule } from 'nestjs-cls';
|
|||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { McpModule } from './integrations/mcp/mcp.module';
|
import { McpModule } from './integrations/mcp/mcp.module';
|
||||||
|
import { GitSyncModule } from './integrations/git-sync/git-sync.module';
|
||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
import { AiModule } from './integrations/ai/ai.module';
|
import { AiModule } from './integrations/ai/ai.module';
|
||||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||||
@@ -90,6 +91,7 @@ try {
|
|||||||
TelemetryModule,
|
TelemetryModule,
|
||||||
ThrottleModule,
|
ThrottleModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
|
GitSyncModule,
|
||||||
SandboxModule,
|
SandboxModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AiChatModule,
|
AiChatModule,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused test for the COLLAB_DISABLE_REDIS fallback in handleYjsEvent.
|
||||||
|
*
|
||||||
|
* With Redis disabled the gateway builds no RedisSyncExtension, so the old code
|
||||||
|
* (`return this.redisSync?.handleEvent(...)`) returned undefined and every
|
||||||
|
* doc-mutation event silently no-opped. The fallback must instead invoke the
|
||||||
|
* handler locally against the single hocuspocus instance and return its verdict.
|
||||||
|
*
|
||||||
|
* We construct the gateway with stub extensions and an EnvironmentService whose
|
||||||
|
* isCollabDisableRedis() returns true (redisSync stays null, real hocuspocus is
|
||||||
|
* still built), then spy getHandlers so no real direct connection is opened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stubExtension = {} as any;
|
||||||
|
|
||||||
|
function makeEnv() {
|
||||||
|
return {
|
||||||
|
getRedisUrl: () => 'redis://localhost:6379',
|
||||||
|
isCollabDisableRedis: () => true,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationGateway.handleYjsEvent (no-Redis fallback)', () => {
|
||||||
|
it('invokes the handler locally and returns its verdict instead of undefined', async () => {
|
||||||
|
const collabHandler = new CollaborationHandler();
|
||||||
|
const verdict = { applied: true, currentText: 'new' };
|
||||||
|
const fakeHandler = jest.fn().mockResolvedValue(verdict);
|
||||||
|
// Bypass the real direct-connection code path — assert dispatch only.
|
||||||
|
jest
|
||||||
|
.spyOn(collabHandler, 'getHandlers')
|
||||||
|
.mockReturnValue({ applyCommentSuggestion: fakeHandler } as any);
|
||||||
|
|
||||||
|
const gateway = new CollaborationGateway(
|
||||||
|
stubExtension,
|
||||||
|
stubExtension,
|
||||||
|
stubExtension,
|
||||||
|
makeEnv(),
|
||||||
|
collabHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'old',
|
||||||
|
newText: 'new',
|
||||||
|
user: { id: 'u1' } as any,
|
||||||
|
};
|
||||||
|
const result = await gateway.handleYjsEvent(
|
||||||
|
'applyCommentSuggestion' as any,
|
||||||
|
'doc-1',
|
||||||
|
payload as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fakeHandler).toHaveBeenCalledWith('doc-1', payload);
|
||||||
|
expect(result).toEqual(verdict);
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,14 +147,86 @@ export class CollaborationGateway {
|
|||||||
eventName: TName,
|
eventName: TName,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: Parameters<CollabEventHandlers[TName]>[1],
|
payload: Parameters<CollabEventHandlers[TName]>[1],
|
||||||
) {
|
): ReturnType<CollabEventHandlers[TName]> {
|
||||||
return this.redisSync?.handleEvent(eventName, documentName, payload);
|
if (this.redisSync) {
|
||||||
|
// Normal path: the Redis bridge routes the event to the instance that owns
|
||||||
|
// the document (local or another worker) and carries the handler's return
|
||||||
|
// value back to us (customEventComplete + replyId).
|
||||||
|
return this.redisSync.handleEvent(
|
||||||
|
eventName,
|
||||||
|
documentName,
|
||||||
|
payload,
|
||||||
|
) as ReturnType<CollabEventHandlers[TName]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLLAB_DISABLE_REDIS: there is no cross-process bridge, so a single local
|
||||||
|
// hocuspocus instance owns every document. Invoke the handler directly
|
||||||
|
// against it instead of returning undefined — otherwise doc-mutation events
|
||||||
|
// (setCommentMark / resolveCommentMark / applyCommentSuggestion) would
|
||||||
|
// silently no-op and, for suggestions, the caller could never learn the
|
||||||
|
// verdict. openDirectConnection loads the doc via the persistence extension
|
||||||
|
// if it is not already in memory.
|
||||||
|
if (this.hocuspocus) {
|
||||||
|
const handlers = this.collabEventsService.getHandlers(this.hocuspocus);
|
||||||
|
const handler = handlers[eventName] as (
|
||||||
|
documentName: string,
|
||||||
|
payload: unknown,
|
||||||
|
) => ReturnType<CollabEventHandlers[TName]>;
|
||||||
|
return handler(documentName, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collaboration was never initialized (no live instance). Fail loudly rather
|
||||||
|
// than silently dropping a mutation; phase 4's caller maps this to a 5xx.
|
||||||
|
throw new Error(
|
||||||
|
`Cannot handle collaboration event "${String(
|
||||||
|
eventName,
|
||||||
|
)}": requires a live collaboration instance`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openDirectConnection(documentName: string, context?: any) {
|
openDirectConnection(documentName: string, context?: any) {
|
||||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a git-originated body into a page, applying the merge on the instance
|
||||||
|
* that OWNS the live Y.Doc so a connected editor CONVERGES on the change.
|
||||||
|
*
|
||||||
|
* git-sync must NOT use openDirectConnection directly for this: that opens the
|
||||||
|
* document on whichever instance/process runs git-sync (the API/worker). When
|
||||||
|
* an editor is connected to a DIFFERENT collab instance/process, that is a
|
||||||
|
* SEPARATE, detached Y.Doc — the merge lands in the detached doc and the DB,
|
||||||
|
* but the live editor never receives the Yjs update; its next debounced
|
||||||
|
* autosave then overwrites the DB with its stale state and SILENTLY REVERTS
|
||||||
|
* the git change (the data-loss bug). Routing through the custom-event channel
|
||||||
|
* runs the merge on the owning instance's shared Document, whose update is
|
||||||
|
* broadcast to every connection (handleUpdate), so the editor's CRDT converges
|
||||||
|
* on the merged result.
|
||||||
|
*
|
||||||
|
* Without redis there is a single instance, so the write runs locally — which
|
||||||
|
* is already the owning (and only) instance the editor is connected to.
|
||||||
|
*/
|
||||||
|
async writePageBody(
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
prosemirrorJson: unknown;
|
||||||
|
baseProsemirrorJson?: unknown;
|
||||||
|
userId: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.redisSync) {
|
||||||
|
await this.handleYjsEvent(
|
||||||
|
'gitSyncWriteBody',
|
||||||
|
documentName,
|
||||||
|
payload as any,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.collabEventsService
|
||||||
|
.getHandlers(this.hocuspocus)
|
||||||
|
.gitSyncWriteBody(documentName, payload as any);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
*Can be used before calling openDirectConnection directly
|
*Can be used before calling openDirectConnection directly
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Exercises the REAL `gitSyncWriteBody` collab handler (the owner-routed body
|
||||||
|
// write the data-loss fix introduces). The handler imports the editor graph via
|
||||||
|
// collaboration.util / yjs.util (tiptapExtensions -> editor-ext -> react-dom,
|
||||||
|
// unloadable under jest's node env, same coupling noted in
|
||||||
|
// gitmost-datasource.service.spec.ts), so we stub those + the transformer. The
|
||||||
|
// stubbed toYdoc builds paragraph blocks straight from the ProseMirror JSON so
|
||||||
|
// we can assert convergence on real text.
|
||||||
|
jest.mock('./collaboration.util', () => ({
|
||||||
|
tiptapExtensions: [],
|
||||||
|
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||||
|
prosemirrorNodeToYElement: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('./yjs.util', () => ({
|
||||||
|
setYjsMark: jest.fn(),
|
||||||
|
updateYjsMarkAttribute: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@hocuspocus/transformer', () => {
|
||||||
|
const Yjs = require('yjs');
|
||||||
|
return {
|
||||||
|
TiptapTransformer: {
|
||||||
|
toYdoc: (json: any) => {
|
||||||
|
if (json?.__throw) throw new Error('boom: malformed doc');
|
||||||
|
const d = new Yjs.Doc();
|
||||||
|
const frag = d.getXmlFragment('default');
|
||||||
|
const blocks = (json?.content ?? []).map((node: any) => {
|
||||||
|
const el = new Yjs.XmlElement(node.type || 'paragraph');
|
||||||
|
const text = (node.content ?? [])
|
||||||
|
.map((t: any) => t.text ?? '')
|
||||||
|
.join('');
|
||||||
|
const t = new Yjs.XmlText();
|
||||||
|
if (text) t.insert(0, text);
|
||||||
|
el.insert(0, [t]);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
if (blocks.length) frag.insert(0, blocks);
|
||||||
|
return d;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
|
|
||||||
|
const pmDoc = (...paras: string[]) => ({
|
||||||
|
type: 'doc',
|
||||||
|
content: paras.map((text) => ({
|
||||||
|
type: 'paragraph',
|
||||||
|
content: text ? [{ type: 'text', text }] : [],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const texts = (frag: Y.XmlFragment): string[] =>
|
||||||
|
frag.toArray().map((el) =>
|
||||||
|
(el as Y.XmlElement)
|
||||||
|
.toArray()
|
||||||
|
.map((c) => (c as Y.XmlText).toString())
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a fake Hocuspocus whose openDirectConnection yields a DirectConnection
|
||||||
|
// over a REAL shared Document, with a connected "editor" doc that receives the
|
||||||
|
// shared doc's updates (modelling Document.handleUpdate's broadcast on the
|
||||||
|
// OWNING instance). Initial content carries live block ids; the editor starts
|
||||||
|
// fully synced with the shared doc.
|
||||||
|
function fakeHocuspocus(initial: { text: string; id: string }[]) {
|
||||||
|
const shared = new Y.Doc();
|
||||||
|
const frag = shared.getXmlFragment('default');
|
||||||
|
shared.transact(() => {
|
||||||
|
frag.insert(
|
||||||
|
0,
|
||||||
|
initial.map((s) => {
|
||||||
|
const el = new Y.XmlElement('paragraph');
|
||||||
|
el.setAttribute('id', s.id);
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
if (s.text) t.insert(0, s.text);
|
||||||
|
el.insert(0, [t]);
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const editor = new Y.Doc();
|
||||||
|
Y.applyUpdate(editor, Y.encodeStateAsUpdate(shared));
|
||||||
|
// Broadcast relay: server-originated updates flow to the connected editor.
|
||||||
|
shared.on('update', (u: Uint8Array, origin: any) => {
|
||||||
|
if (origin !== 'editor') Y.applyUpdate(editor, u, 'server');
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDirectConnection = jest.fn(async () => ({
|
||||||
|
// DirectConnection.transact runs the fn directly against the Document (no
|
||||||
|
// wrapping Y transaction), exactly like @hocuspocus/server.
|
||||||
|
transact: async (fn: (doc: Y.Doc) => void) => fn(shared),
|
||||||
|
disconnect: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { hocuspocus: { openDirectConnection } as any, shared, editor };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () => {
|
||||||
|
it('converges a connected editor on the git change (no silent revert)', async () => {
|
||||||
|
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||||
|
{ text: 'alpha', id: 'p1' },
|
||||||
|
{ text: 'beta', id: 'p2' },
|
||||||
|
]);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
// git changed block 1 beta -> beta2; base is the pre-change content.
|
||||||
|
await handlers.gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||||
|
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||||
|
userId: 'svc-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shared (owning-instance) doc holds the merge...
|
||||||
|
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||||
|
// ...and the connected editor CONVERGED via the broadcast (the bug would
|
||||||
|
// leave it on 'beta' and revert the page on its next autosave).
|
||||||
|
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a concurrent edit to a DIFFERENT block (3-way, finding #2)', async () => {
|
||||||
|
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||||
|
{ text: 'alpha', id: 'p1' },
|
||||||
|
{ text: 'beta', id: 'p2' },
|
||||||
|
]);
|
||||||
|
// The editor is actively editing block 0 while the push arrives.
|
||||||
|
const eFrag = editor.getXmlFragment('default');
|
||||||
|
editor.transact(
|
||||||
|
() => (eFrag.get(0) as Y.XmlElement).get(0) instanceof Y.XmlText &&
|
||||||
|
((eFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||||
|
'editor',
|
||||||
|
);
|
||||||
|
Y.applyUpdate(shared, Y.encodeStateAsUpdate(editor), 'editor');
|
||||||
|
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||||
|
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||||
|
userId: 'svc-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Human's block-0 edit AND git's block-1 change both survive on the editor.
|
||||||
|
expect(texts(editor.getXmlFragment('default'))).toEqual([
|
||||||
|
'alpha EDIT',
|
||||||
|
'beta2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FLUSHES the pending debounced store BEFORE merging so an in-flight edit survives (finding #2)', async () => {
|
||||||
|
// QA #119 finding #2: the 3-way merge must run against the latest live-doc
|
||||||
|
// state. A concurrent UI edit that is still in-flight (the store is debounced)
|
||||||
|
// must be drained into the live doc BEFORE git merges, or git clean-applies and
|
||||||
|
// the edit is silently dropped — even on a DIFFERENT block. Model the drain via
|
||||||
|
// the pending-store flush: when it runs, the in-flight block-0 edit lands.
|
||||||
|
const shared = new Y.Doc();
|
||||||
|
const frag = shared.getXmlFragment('default');
|
||||||
|
shared.transact(() => {
|
||||||
|
frag.insert(
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
{ text: 'alpha', id: 'p1' },
|
||||||
|
{ text: 'beta', id: 'p2' },
|
||||||
|
].map((s) => {
|
||||||
|
const el = new Y.XmlElement('paragraph');
|
||||||
|
el.setAttribute('id', s.id);
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
t.insert(0, s.text);
|
||||||
|
el.insert(0, [t]);
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
const debouncer = {
|
||||||
|
isDebounced: jest.fn(() => true),
|
||||||
|
executeNow: jest.fn(async () => {
|
||||||
|
order.push('flush');
|
||||||
|
// The in-flight client edit to block 0 only lands once the pending store
|
||||||
|
// is flushed (i.e. the event loop is drained) — BEFORE the merge.
|
||||||
|
shared.transact(() =>
|
||||||
|
((frag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const openDirectConnection = jest.fn(async () => ({
|
||||||
|
transact: async (fn: (doc: Y.Doc) => void) => {
|
||||||
|
order.push('merge');
|
||||||
|
fn(shared);
|
||||||
|
},
|
||||||
|
disconnect: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
const hocuspocus = { openDirectConnection, debouncer } as any;
|
||||||
|
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: pmDoc('alpha', 'beta2'), // git changes block 1
|
||||||
|
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||||
|
userId: 'svc-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The flush ran, and it ran BEFORE the merge transaction.
|
||||||
|
expect(debouncer.executeNow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(order).toEqual(['flush', 'merge']);
|
||||||
|
// Both the in-flight block-0 edit and git's block-1 change survive — the
|
||||||
|
// pre-flush bug would have produced ['alpha', 'beta2'] (UI edit dropped).
|
||||||
|
expect(texts(shared.getXmlFragment('default'))).toEqual([
|
||||||
|
'alpha EDIT',
|
||||||
|
'beta2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flush when no store is pending (isDebounced false)', async () => {
|
||||||
|
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'a', id: 'p1' }]);
|
||||||
|
const executeNow = jest.fn();
|
||||||
|
(hocuspocus as any).debouncer = {
|
||||||
|
isDebounced: jest.fn(() => false),
|
||||||
|
executeNow,
|
||||||
|
};
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: pmDoc('a', 'b'),
|
||||||
|
userId: 'svc-user',
|
||||||
|
});
|
||||||
|
expect(executeNow).not.toHaveBeenCalled();
|
||||||
|
expect(texts(shared.getXmlFragment('default'))).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
|
||||||
|
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
|
||||||
|
const before = texts(shared.getXmlFragment('default'));
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: { __throw: true } as any,
|
||||||
|
userId: 'svc-user',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
|
||||||
|
// The incoming doc is built BEFORE opening the connection, so the throw
|
||||||
|
// happens first: the live doc is untouched and no connection was opened.
|
||||||
|
expect(hocuspocus.openDirectConnection).not.toHaveBeenCalled();
|
||||||
|
expect(texts(shared.getXmlFragment('default'))).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a 2-way merge when no base is supplied', async () => {
|
||||||
|
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||||
|
{ text: 'alpha', id: 'p1' },
|
||||||
|
]);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
|
||||||
|
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||||
|
prosemirrorJson: pmDoc('alpha', 'gamma'),
|
||||||
|
userId: 'svc-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||||
|
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
|
import * as yjsUtil from './yjs.util';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
||||||
|
*
|
||||||
|
* The handler runs `replaceYjsMarkedText` inside the owning instance's Y
|
||||||
|
* transaction and returns the verdict to the caller. We exercise it against a
|
||||||
|
* REAL in-memory Y.Doc carrying a marked comment run, driven through a FAKE
|
||||||
|
* hocuspocus whose openDirectConnection().transact(fn) simply runs fn(doc) —
|
||||||
|
* mirroring how the real hocuspocus DirectConnection invokes the callback with
|
||||||
|
* the shared document (it does not forward the callback's return value, which is
|
||||||
|
* exactly why withYdocConnection captures it via a closure).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Build a Y.Doc with a single paragraph whose text carries a `comment` mark for
|
||||||
|
// the given commentId — the shape `replaceYjsMarkedText` walks in production.
|
||||||
|
function buildDocWithComment(text: string, commentId: string) {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
const paragraph = new Y.XmlElement('paragraph');
|
||||||
|
const xmlText = new Y.XmlText();
|
||||||
|
xmlText.insert(0, text);
|
||||||
|
xmlText.format(0, text.length, { comment: { commentId, resolved: false } });
|
||||||
|
paragraph.insert(0, [xmlText]);
|
||||||
|
fragment.insert(0, [paragraph]);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake hocuspocus exposing only what withYdocConnection needs: a direct
|
||||||
|
// connection whose transact() runs the callback against `doc`.
|
||||||
|
function fakeHocuspocus(doc: Y.Doc) {
|
||||||
|
const connection = {
|
||||||
|
transact: jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||||
|
fn(doc);
|
||||||
|
}),
|
||||||
|
disconnect: jest.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const hocuspocus = {
|
||||||
|
openDirectConnection: jest.fn(async () => connection),
|
||||||
|
} as any;
|
||||||
|
return { hocuspocus, connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { id: 'u1' } as unknown as User;
|
||||||
|
|
||||||
|
describe('CollaborationHandler.applyCommentSuggestion', () => {
|
||||||
|
it('applies the replacement and returns the verdict when the marked text matches', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'Hello world',
|
||||||
|
newText: 'Goodbye world',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Goodbye world' });
|
||||||
|
// The mutation ran inside the transaction and hit the real doc.
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
||||||
|
'Goodbye world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects (applied=false) and returns the current text when it changed', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c1',
|
||||||
|
expectedText: 'Stale expected text',
|
||||||
|
newText: 'Goodbye world',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
|
||||||
|
// Nothing was replaced.
|
||||||
|
expect(doc.getXmlFragment('default').toString()).toContain(
|
||||||
|
'Hello world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the exact args to replaceYjsMarkedText and returns its result', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(yjsUtil, 'replaceYjsMarkedText')
|
||||||
|
.mockReturnValue({ applied: true, currentText: 'xyz' });
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
const result = await handlers.applyCommentSuggestion('doc-1', {
|
||||||
|
commentId: 'c9',
|
||||||
|
expectedText: 'abc',
|
||||||
|
newText: 'xyz',
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'c9',
|
||||||
|
'abc',
|
||||||
|
'xyz',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'xyz' });
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withYdocConnection returns the callback result (transact does not forward it)', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
|
||||||
|
const value = await handler.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
'doc-1',
|
||||||
|
{},
|
||||||
|
() => 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(value).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,18 @@ import {
|
|||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
import {
|
||||||
|
replaceYjsMarkedText,
|
||||||
|
setYjsMark,
|
||||||
|
updateYjsMarkAttribute,
|
||||||
|
YjsSelection,
|
||||||
|
} from './yjs.util';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
mergeXmlFragments,
|
||||||
|
mergeXmlFragments3WayWithStats,
|
||||||
|
} from './merge/yjs-body-merge';
|
||||||
|
|
||||||
export type CollabEventHandlers = ReturnType<
|
export type CollabEventHandlers = ReturnType<
|
||||||
CollaborationHandler['getHandlers']
|
CollaborationHandler['getHandlers']
|
||||||
@@ -73,6 +82,35 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
applyCommentSuggestion: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
expectedText: string;
|
||||||
|
newText: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
): Promise<{ applied: boolean; currentText: string | null }> => {
|
||||||
|
const { commentId, expectedText, newText, user } = payload;
|
||||||
|
// Run the check-and-replace inside the owning instance's Y transaction so
|
||||||
|
// the delete+insert are atomic. The verdict from replaceYjsMarkedText is
|
||||||
|
// returned to the API-server caller (cross-process via the Redis bridge,
|
||||||
|
// or locally when Redis is disabled — see collaboration.gateway.ts).
|
||||||
|
return this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
return replaceYjsMarkedText(
|
||||||
|
fragment,
|
||||||
|
commentId,
|
||||||
|
expectedText,
|
||||||
|
newText,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
updatePageContent: async (
|
updatePageContent: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -112,21 +150,152 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Git-sync body write, applied as a block-level MERGE into the LIVE doc on
|
||||||
|
* the instance that OWNS it (routed here via the custom-event channel —
|
||||||
|
* see CollaborationGateway.writePageBody). Running on the owning instance
|
||||||
|
* is what makes a connected editor CONVERGE: the merge mutates the shared
|
||||||
|
* Document, whose update is broadcast to every connection, so the editor's
|
||||||
|
* CRDT applies the git change instead of silently reverting it on its next
|
||||||
|
* autosave (the data-loss bug this fixes).
|
||||||
|
*
|
||||||
|
* With a `baseProsemirrorJson` (the last-synced common ancestor) it does a
|
||||||
|
* THREE-WAY merge — a block only the human changed is kept, a block only
|
||||||
|
* git changed is taken (conflicts -> git). Without a base it falls back to
|
||||||
|
* the 2-way merge.
|
||||||
|
*/
|
||||||
|
gitSyncWriteBody: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
prosemirrorJson: any;
|
||||||
|
baseProsemirrorJson?: any;
|
||||||
|
userId: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { prosemirrorJson, baseProsemirrorJson, userId } = payload;
|
||||||
|
|
||||||
|
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
|
||||||
|
// touching the live doc. If a transform throws (a malformed/unsupported
|
||||||
|
// doc) we must NOT have mutated the live body — otherwise a conversion
|
||||||
|
// failure could leave the page empty (crash-safe conversion).
|
||||||
|
const targetDoc = TiptapTransformer.toYdoc(
|
||||||
|
prosemirrorJson,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
const baseDoc =
|
||||||
|
baseProsemirrorJson != null
|
||||||
|
? TiptapTransformer.toYdoc(
|
||||||
|
baseProsemirrorJson,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
|
||||||
|
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
|
||||||
|
// is already part of that doc. A user's edit is debounced before it lands
|
||||||
|
// (the editor batches; the collab store is debounced up to 10s), so the
|
||||||
|
// merge could otherwise run against a PRE-EDIT doc: git would then
|
||||||
|
// clean-apply (no same-block conflict detected) and the in-flight UI edit
|
||||||
|
// — even on a DIFFERENT block — would be silently dropped.
|
||||||
|
//
|
||||||
|
// Flushing the pending debounced store here (a) drains the event loop so a
|
||||||
|
// just-arrived client Yjs update is applied to the live doc BEFORE we
|
||||||
|
// merge, and (b) persists the live doc so the merge baseline is current
|
||||||
|
// even on the doc-reload-from-DB path. After the flush the merge sees the
|
||||||
|
// latest state, so an edit on a different block is MERGED (not overwritten)
|
||||||
|
// and a genuine same-block edit is detected as a conflict -> the
|
||||||
|
// boundary-snapshot in PersistenceExtension pins it to page history
|
||||||
|
// (recoverable) instead of vanishing silently.
|
||||||
|
await this.flushPendingStore(hocuspocus, documentName);
|
||||||
|
|
||||||
|
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||||
|
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ actor: 'git-sync', user: { id: userId } },
|
||||||
|
(doc) => {
|
||||||
|
const liveFrag = doc.getXmlFragment('default');
|
||||||
|
const targetFrag = targetDoc.getXmlFragment('default');
|
||||||
|
if (baseDoc) {
|
||||||
|
const { conflicts } = mergeXmlFragments3WayWithStats(
|
||||||
|
liveFrag,
|
||||||
|
targetFrag,
|
||||||
|
baseDoc.getXmlFragment('default'),
|
||||||
|
);
|
||||||
|
// SAME-BLOCK conflict contract (SPEC §9): a block both the human
|
||||||
|
// and git changed resolves to GIT (deterministic). Make that
|
||||||
|
// OBSERVABLE rather than silent — log it. The losing human content
|
||||||
|
// is NOT destroyed: the persistence extension's boundary snapshot
|
||||||
|
// pins the pre-merge page state to history on this user->git-sync
|
||||||
|
// transition, so it stays recoverable.
|
||||||
|
if (conflicts > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`git-sync merge for ${documentName}: ${conflicts} same-block ` +
|
||||||
|
`conflict(s) resolved to the git version; the prior page ` +
|
||||||
|
`state is preserved in page history (recoverable).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergeXmlFragments(liveFrag, targetFrag);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async withYdocConnection(
|
/**
|
||||||
|
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
|
||||||
|
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
|
||||||
|
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
|
||||||
|
* flush: only acts when a store is actually pending (`isDebounced`), runs the
|
||||||
|
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
|
||||||
|
* never throws — a flush failure must not abort the git-sync write. Awaiting it
|
||||||
|
* also drains the event loop, so a client Yjs update sitting in the socket buffer
|
||||||
|
* is applied to the live doc before the merge transaction runs.
|
||||||
|
*/
|
||||||
|
private async flushPendingStore(
|
||||||
|
hocuspocus: Hocuspocus,
|
||||||
|
documentName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const debounceId = `onStoreDocument-${documentName}`;
|
||||||
|
try {
|
||||||
|
const debouncer = (hocuspocus as any)?.debouncer;
|
||||||
|
if (!debouncer?.isDebounced?.(debounceId)) return;
|
||||||
|
await debouncer.executeNow(debounceId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`git-sync pre-merge flush failed for ${documentName}: ` +
|
||||||
|
(err instanceof Error ? err.message : String(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async withYdocConnection<T>(
|
||||||
hocuspocus: Hocuspocus,
|
hocuspocus: Hocuspocus,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
context: any = {},
|
context: any = {},
|
||||||
fn: (doc: Document) => void,
|
// `fn` MUST be synchronous: hocuspocus `connection.transact(fn)` runs fn
|
||||||
): Promise<void> {
|
// synchronously and does NOT await it, so any mutations after an `await`
|
||||||
|
// inside fn would execute OUTSIDE the Yjs transaction and lose atomicity.
|
||||||
|
fn: (doc: Document) => T,
|
||||||
|
): Promise<T> {
|
||||||
const connection = await hocuspocus.openDirectConnection(
|
const connection = await hocuspocus.openDirectConnection(
|
||||||
documentName,
|
documentName,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await connection.transact(fn);
|
// hocuspocus `connection.transact(fn)` invokes fn(document) but does NOT
|
||||||
|
// forward fn's return value, so we capture it in a closure and return it
|
||||||
|
// after the transaction (and its storeDocument hooks) resolve.
|
||||||
|
let result: T;
|
||||||
|
await connection.transact((doc) => {
|
||||||
|
result = fn(doc);
|
||||||
|
});
|
||||||
|
return result!;
|
||||||
} finally {
|
} finally {
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { PersistenceExtension } from './persistence.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the QA #119 "loss-on-fast-close" data loss: editing a page then
|
||||||
|
* closing the tab within the collab debounce window (~3-18s) lost the edit
|
||||||
|
* because, with `unloadImmediately: false`, Hocuspocus does NOT flush the
|
||||||
|
* debounced onStoreDocument on a last-client disconnect. PersistenceExtension
|
||||||
|
* now flushes the pending store on the LAST disconnect (and only then).
|
||||||
|
*/
|
||||||
|
describe('PersistenceExtension.onDisconnect flush (loss-on-fast-close)', () => {
|
||||||
|
function makeExt(): PersistenceExtension {
|
||||||
|
// onDisconnect touches none of the injected deps; pass casts.
|
||||||
|
return new PersistenceExtension(
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
null as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeData(opts: {
|
||||||
|
clientsCount: number;
|
||||||
|
isDebounced: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const executeNow = jest.fn(async () => undefined);
|
||||||
|
const isDebounced = jest.fn(() => opts.isDebounced);
|
||||||
|
return {
|
||||||
|
executeNow,
|
||||||
|
isDebounced,
|
||||||
|
payload: {
|
||||||
|
clientsCount: opts.clientsCount,
|
||||||
|
context: {},
|
||||||
|
document: { isLoading: opts.isLoading ?? false } as any,
|
||||||
|
documentName: 'page.abc',
|
||||||
|
instance: { debouncer: { isDebounced, executeNow } } as any,
|
||||||
|
requestHeaders: {},
|
||||||
|
requestParameters: new URLSearchParams(),
|
||||||
|
socketId: 's',
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('flushes the pending store when the LAST client disconnects', async () => {
|
||||||
|
const ext = makeExt();
|
||||||
|
const { executeNow, payload } = makeData({
|
||||||
|
clientsCount: 0,
|
||||||
|
isDebounced: true,
|
||||||
|
});
|
||||||
|
await ext.onDisconnect(payload);
|
||||||
|
expect(executeNow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executeNow).toHaveBeenCalledWith('onStoreDocument-page.abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flush while other editors remain connected', async () => {
|
||||||
|
const ext = makeExt();
|
||||||
|
const { executeNow, payload } = makeData({
|
||||||
|
clientsCount: 2,
|
||||||
|
isDebounced: true,
|
||||||
|
});
|
||||||
|
await ext.onDisconnect(payload);
|
||||||
|
expect(executeNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT write when nothing is pending (already persisted)', async () => {
|
||||||
|
const ext = makeExt();
|
||||||
|
const { executeNow, payload } = makeData({
|
||||||
|
clientsCount: 0,
|
||||||
|
isDebounced: false,
|
||||||
|
});
|
||||||
|
await ext.onDisconnect(payload);
|
||||||
|
expect(executeNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flush a doc that is still loading (load error guard)', async () => {
|
||||||
|
const ext = makeExt();
|
||||||
|
const { executeNow, payload } = makeData({
|
||||||
|
clientsCount: 0,
|
||||||
|
isDebounced: true,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
await ext.onDisconnect(payload);
|
||||||
|
expect(executeNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Stub collaboration.util so importing the extension does not drag in the
|
||||||
|
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||||
|
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||||
|
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||||
|
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||||
|
jest.mock('../collaboration.util', () => ({
|
||||||
|
tiptapExtensions: [],
|
||||||
|
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||||
|
jsonToText: () => 'text',
|
||||||
|
isEmptyParagraphDoc: () => false,
|
||||||
|
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||||
|
// return a node-like stub with no descendants so no mentions are produced
|
||||||
|
// (mention handling is out of scope here — we only assert provenance).
|
||||||
|
jsonToNode: () => ({ descendants: () => undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||||
|
// is storing. We keep it distinct from the page's persisted content so the
|
||||||
|
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||||
|
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||||
|
jest.mock('@hocuspocus/transformer', () => ({
|
||||||
|
TiptapTransformer: {
|
||||||
|
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||||
|
toYdoc: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Run the executeTx callback inline with a passthrough trx.
|
||||||
|
jest.mock('@docmost/db/utils', () => ({
|
||||||
|
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { PersistenceExtension } from './persistence.extension';
|
||||||
|
import {
|
||||||
|
onChangePayload,
|
||||||
|
onStoreDocumentPayload,
|
||||||
|
} from '@hocuspocus/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||||
|
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||||
|
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||||
|
* snapshot. We drive the precedence through the real public method (onChange to
|
||||||
|
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||||
|
* Yjs bridge so no real database or collab server is needed. The store's
|
||||||
|
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||||
|
* outputs.
|
||||||
|
*/
|
||||||
|
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||||
|
const DOCUMENT_NAME = 'page.page-1';
|
||||||
|
const PAGE_ID = 'page-1';
|
||||||
|
|
||||||
|
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||||
|
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||||
|
id: PAGE_ID,
|
||||||
|
slugId: 'slug-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'creator-1',
|
||||||
|
contributorIds: ['creator-1'],
|
||||||
|
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||||
|
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||||
|
const pageRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||||
|
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||||
|
};
|
||||||
|
const pageHistoryRepo = {
|
||||||
|
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||||
|
// gating the boundary snapshot in these tests is the source precedence.
|
||||||
|
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||||
|
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const collabHistory = {
|
||||||
|
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const transclusionService = {
|
||||||
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ext = new PersistenceExtension(
|
||||||
|
pageRepo as any,
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
{} as any, // db
|
||||||
|
aiQueue as any,
|
||||||
|
historyQueue as any,
|
||||||
|
notificationQueue as any,
|
||||||
|
collabHistory as any,
|
||||||
|
transclusionService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||||
|
};
|
||||||
|
|
||||||
|
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||||
|
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||||
|
// irrelevant to the JSON path.
|
||||||
|
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||||
|
({
|
||||||
|
documentName: DOCUMENT_NAME,
|
||||||
|
document: Object.assign(new Y.Doc(), {
|
||||||
|
broadcastStateless: jest.fn(),
|
||||||
|
}),
|
||||||
|
context,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const makeChangePayload = (actor: string): onChangePayload =>
|
||||||
|
({
|
||||||
|
documentName: DOCUMENT_NAME,
|
||||||
|
context: { user: { id: 'user-1' }, actor },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||||
|
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||||
|
|
||||||
|
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||||
|
const { ext, pageRepo } = build();
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceOf(pageRepo)).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||||
|
const { ext, pageRepo } = build();
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 'git-sync' for an explicit git-sync store even with a sticky agent marker (#14 loop-guard)", async () => {
|
||||||
|
const { ext, pageRepo } = build();
|
||||||
|
|
||||||
|
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||||
|
// then a git-sync writer performs the store. Red-team finding #14: an
|
||||||
|
// EXPLICIT current-write actor is authoritative for THIS write, so the
|
||||||
|
// store must stay 'git-sync' — otherwise the PageChangeListener loop-guard
|
||||||
|
// (keyed on lastUpdatedSource === 'git-sync') fails to recognize git-sync's
|
||||||
|
// own write and re-exports it. Explicit 'agent' still wins (see below); the
|
||||||
|
// sticky marker only promotes a plain human writer to 'agent'.
|
||||||
|
await ext.onChange(makeChangePayload('agent'));
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||||
|
const { ext, pageRepo } = build();
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceOf(pageRepo)).toBe('agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- boundary snapshot for a git-sync store over a HUMAN baseline -----------
|
||||||
|
// SPEC §9 observable-loss guard (bug #2): a git-sync body write is a block-level
|
||||||
|
// 3-way merge whose same-block rule is "git wins". To keep a concurrent human
|
||||||
|
// edit RECOVERABLE rather than silently overwritten, a git-sync store over a
|
||||||
|
// prior NON-git-sync baseline pins that prior state to page history first —
|
||||||
|
// exactly like the agent path. So saveHistory MUST be called here.
|
||||||
|
it('DOES pin a boundary snapshot for a git-sync store over a prior human state', async () => {
|
||||||
|
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- negative: a git-sync store over a git-sync baseline does NOT re-pin -----
|
||||||
|
// The boundary is pinned once on the transition INTO git-sync; a subsequent
|
||||||
|
// git-sync store over an already-git-sync baseline must not churn history.
|
||||||
|
it('does NOT re-pin a boundary snapshot for a git-sync store over a git-sync baseline', async () => {
|
||||||
|
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'git-sync' });
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||||
|
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||||
|
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||||
|
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||||
|
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||||
|
|
||||||
|
await ext.onStoreDocument(
|
||||||
|
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
afterUnloadDocumentPayload,
|
afterUnloadDocumentPayload,
|
||||||
Extension,
|
Extension,
|
||||||
onChangePayload,
|
onChangePayload,
|
||||||
|
onDisconnectPayload,
|
||||||
onLoadDocumentPayload,
|
onLoadDocumentPayload,
|
||||||
onStatelessPayload,
|
onStatelessPayload,
|
||||||
onStoreDocumentPayload,
|
onStoreDocumentPayload,
|
||||||
@@ -82,7 +83,17 @@ export function resolveSource(
|
|||||||
stickyTouched: boolean,
|
stickyTouched: boolean,
|
||||||
contextActor?: string,
|
contextActor?: string,
|
||||||
): ProvenanceSource {
|
): ProvenanceSource {
|
||||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
// An EXPLICIT current-write actor is authoritative for THIS write and wins
|
||||||
|
// over the sticky-agent fallback. Order: explicit 'agent' > explicit
|
||||||
|
// 'git-sync' > sticky agent marker > plain human 'user'. The git-sync case
|
||||||
|
// must NOT be masked by the sticky marker, or the PageChangeListener
|
||||||
|
// loop-guard (which keys on lastUpdatedSource === 'git-sync') would re-export
|
||||||
|
// git-sync's own writes (#14). Explicit agent still wins so a window that
|
||||||
|
// mixed an agent edit stays tagged 'agent'.
|
||||||
|
if (contextActor === 'agent') return 'agent';
|
||||||
|
if (contextActor === 'git-sync') return 'git-sync';
|
||||||
|
if (stickyTouched) return 'agent';
|
||||||
|
return 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +202,40 @@ export class PersistenceExtension implements Extension {
|
|||||||
return new Y.Doc();
|
return new Y.Doc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOSS-ON-FAST-CLOSE FIX (QA #119). When the LAST editor disconnects, FLUSH any
|
||||||
|
* pending (debounced) store to the DB IMMEDIATELY instead of waiting out the
|
||||||
|
* up-to-10s `debounce` window.
|
||||||
|
*
|
||||||
|
* The collab server runs with `unloadImmediately: false` (collaboration.gateway),
|
||||||
|
* so on a last-client disconnect Hocuspocus does NOT flush the debounced
|
||||||
|
* onStoreDocument — it relies on the timer firing later. A quick edit-then-close
|
||||||
|
* (closing the tab within the debounce window, ~3-18s) therefore left the edit
|
||||||
|
* only in the soon-to-be-unloaded in-memory Y.Doc; meanwhile git-sync mirrored
|
||||||
|
* the STALE/empty DB body to the vault (the reported "59-byte frontmatter-only"
|
||||||
|
* data loss). Running the already-scheduled store now closes that window.
|
||||||
|
*
|
||||||
|
* Gated tightly so it never adds a redundant write: only on the LAST disconnect
|
||||||
|
* (`clientsCount === 0`), only for a fully-loaded doc, and only when a store is
|
||||||
|
* actually pending (`isDebounced`). `executeNow` runs the SAME payload Hocuspocus
|
||||||
|
* scheduled (preserving the edit's context/actor) and clears the timer.
|
||||||
|
*/
|
||||||
|
async onDisconnect(data: onDisconnectPayload) {
|
||||||
|
const { instance, document, documentName, clientsCount } = data;
|
||||||
|
if (clientsCount > 0) return;
|
||||||
|
if (!document || document.isLoading) return;
|
||||||
|
const debounceId = `onStoreDocument-${documentName}`;
|
||||||
|
if (!instance?.debouncer?.isDebounced(debounceId)) return;
|
||||||
|
try {
|
||||||
|
await instance.debouncer.executeNow(debounceId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`onDisconnect flush failed for ${documentName}: ` +
|
||||||
|
(err instanceof Error ? err.message : String(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||||
const { documentName, document, context } = data;
|
const { documentName, document, context } = data;
|
||||||
|
|
||||||
@@ -213,6 +258,11 @@ export class PersistenceExtension implements Extension {
|
|||||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||||
// if the current writer is the agent (covers a store with no prior onChange
|
// if the current writer is the agent (covers a store with no prior onChange
|
||||||
// agent event in the same window). §15 H2.
|
// agent event in the same window). §15 H2.
|
||||||
|
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||||
|
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||||
|
// debounced like a human edit (a git-sync write is a block-level merge into
|
||||||
|
// the live doc, so it reads like an incremental human edit, not a bulk
|
||||||
|
// import that would warrant its own immediate snapshot).
|
||||||
const lastUpdatedSource = resolveSource(
|
const lastUpdatedSource = resolveSource(
|
||||||
this.consumeAgentTouched(documentName),
|
this.consumeAgentTouched(documentName),
|
||||||
context?.actor,
|
context?.actor,
|
||||||
@@ -279,17 +329,25 @@ export class PersistenceExtension implements Extension {
|
|||||||
// flag via that same hoisted consume (a "cleared then retyped"
|
// flag via that same hoisted consume (a "cleared then retyped"
|
||||||
// sequence can't leave a usable one behind).
|
// sequence can't leave a usable one behind).
|
||||||
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
|
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
|
||||||
|
// A git-sync write is authoritative and its content IS the vault file:
|
||||||
|
// an empty incoming doc there means the user DELIBERATELY cleared the
|
||||||
|
// page's markdown in git (there is no "transient glitch empty" for a
|
||||||
|
// file-sourced write). Honor it, otherwise the empty-guard rejects the
|
||||||
|
// clear, the vault ref has already advanced past the empty commit, and
|
||||||
|
// vault<->Docmost diverge permanently (review warning). This mirrors the
|
||||||
|
// #251 intentional-clear allowance for a different authoritative source.
|
||||||
|
const gitSyncClear = lastUpdatedSource === 'git-sync';
|
||||||
if (
|
if (
|
||||||
incomingEmpty &&
|
incomingEmpty &&
|
||||||
page.content &&
|
page.content &&
|
||||||
!isEmptyParagraphDoc(page.content as any)
|
!isEmptyParagraphDoc(page.content as any)
|
||||||
) {
|
) {
|
||||||
if (allowIntentionalClear) {
|
if (allowIntentionalClear || gitSyncClear) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Intentional clear for ${pageId}: persisting empty doc over ` +
|
`Intentional clear for ${pageId}: persisting empty doc over ` +
|
||||||
`non-empty content (user-signalled)`,
|
`non-empty content (${gitSyncClear ? 'git-sync' : 'user-signalled'})`,
|
||||||
);
|
);
|
||||||
// fall through — the empty write is allowed exactly once.
|
// fall through — the empty write is allowed.
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Skipping store for ${pageId}: empty live doc would overwrite ` +
|
`Skipping store for ${pageId}: empty live doc would overwrite ` +
|
||||||
@@ -314,21 +372,30 @@ export class PersistenceExtension implements Extension {
|
|||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approach A — boundary snapshot before the agent's first edit.
|
// Approach A — boundary snapshot before a MACHINE write overwrites a
|
||||||
// When this store is the agent's and the page's currently persisted
|
// human (or other-source) baseline. When this store is from a machine
|
||||||
// state was authored by a human, pin that human state as its own
|
// source — the AGENT or GIT-SYNC — and the page's currently persisted
|
||||||
// history version BEFORE the agent overwrites it. `page` still holds
|
// state was authored by a DIFFERENT source, pin that prior state as its
|
||||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
// own history version BEFORE the machine write overwrites it. `page`
|
||||||
// pre-agent state tagged 'user'. The agent's new content is
|
// still holds the OLD content/provenance here, so saveHistory(page)
|
||||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
// captures the pre-write state. The machine's new content is snapshotted
|
||||||
// if the prior state is already agent-authored (boundary already
|
// later by the debounced PAGE_HISTORY job.
|
||||||
// pinned on the user->agent transition), if the page is effectively
|
//
|
||||||
// empty, or if the latest existing snapshot already equals this human
|
// For GIT-SYNC this is the OBSERVABLE-LOSS guard (SPEC §9 conflict
|
||||||
// state (avoid duplicates).
|
// contract): a git-sync body write is a block-level 3-way merge whose
|
||||||
if (
|
// same-block rule is "git wins". Without this pin, a concurrent human
|
||||||
lastUpdatedSource === 'agent' &&
|
// edit to a block git also changed would be overwritten with NO trace.
|
||||||
page.lastUpdatedSource !== 'agent'
|
// Pinning the pre-merge state here means the human's content is always
|
||||||
) {
|
// RECOVERABLE via page history rather than silently lost — git still
|
||||||
|
// wins the live doc deterministically, but nothing is destroyed.
|
||||||
|
//
|
||||||
|
// Skip if the prior state was already authored by THIS machine source
|
||||||
|
// (boundary already pinned on the transition into it), if the page is
|
||||||
|
// effectively empty, or if the latest existing snapshot already equals
|
||||||
|
// the prior state (avoid duplicates).
|
||||||
|
const isMachineWrite =
|
||||||
|
lastUpdatedSource === 'agent' || lastUpdatedSource === 'git-sync';
|
||||||
|
if (isMachineWrite && page.lastUpdatedSource !== lastUpdatedSource) {
|
||||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Regression coverage for the custom-event request/reply protocol in the
|
||||||
|
// RedisSyncExtension. git-sync routes its body write through a custom event
|
||||||
|
// (`gitSyncWriteBody`) which, when the target doc is owned by a DIFFERENT collab
|
||||||
|
// instance, runs REMOTELY inside `handleRedisMessage` on the owning instance. The
|
||||||
|
// remote handler can THROW (markdown->ProseMirror transform on a malformed body).
|
||||||
|
//
|
||||||
|
// Before the fix the throw was uncaught: (1) no `customEventComplete` reply was
|
||||||
|
// published, so the origin's awaiting promise only rejected after `customEventTTL`
|
||||||
|
// (~30s) as a generic 'TIMEOUT', and (2) an unhandledRejection escaped the async
|
||||||
|
// `messageBuffer` listener on the owning instance. These tests assert the throw is
|
||||||
|
// turned into an error-carrying reply that rejects the origin PROMPTLY with the
|
||||||
|
// real message, with the no-throw and local paths unchanged.
|
||||||
|
|
||||||
|
import { RedisSyncExtension } from './redis-sync.extension';
|
||||||
|
|
||||||
|
type Listener = (channel: Buffer, message: Buffer) => unknown;
|
||||||
|
|
||||||
|
// Minimal in-memory pub/sub + lock store shared across FakeRedis duplicates,
|
||||||
|
// modelling the two-instance topology (origin + owner) over one Redis.
|
||||||
|
class FakeRedisBus {
|
||||||
|
instances: FakeRedis[] = [];
|
||||||
|
locks = new Map<string, string>();
|
||||||
|
published: { channel: string; message: Buffer }[] = [];
|
||||||
|
|
||||||
|
register(inst: FakeRedis) {
|
||||||
|
this.instances.push(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(channel: string, message: Buffer) {
|
||||||
|
this.published.push({ channel, message });
|
||||||
|
for (const inst of this.instances) {
|
||||||
|
if (!inst.subscribed.has(channel)) continue;
|
||||||
|
for (const listener of inst.messageListeners) {
|
||||||
|
// ioredis delivers async; `void` mirrors the production listener
|
||||||
|
// registration (`sub.on('messageBuffer', ...)`), whose rejection would
|
||||||
|
// surface as an unhandledRejection if the handler did not catch.
|
||||||
|
void listener(Buffer.from(channel), message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRedis {
|
||||||
|
subscribed = new Set<string>();
|
||||||
|
messageListeners: Listener[] = [];
|
||||||
|
|
||||||
|
constructor(private bus: FakeRedisBus) {
|
||||||
|
bus.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate() {
|
||||||
|
return new FakeRedis(this.bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(...channels: string[]) {
|
||||||
|
for (const c of channels) this.subscribed.add(c);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, cb: any) {
|
||||||
|
if (event === 'messageBuffer') this.messageListeners.push(cb as Listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(channel: string, message: Buffer) {
|
||||||
|
this.bus.publish(channel, message);
|
||||||
|
return Promise.resolve(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models `SET key val PX ttl NX GET`: only writes when absent (NX); returns the
|
||||||
|
// previous value (GET) so the origin observes the owner already holding the lock.
|
||||||
|
set(key: string, val: string, ...args: any[]) {
|
||||||
|
const hasNX = args.includes('NX');
|
||||||
|
const hasGET = args.includes('GET');
|
||||||
|
const old = this.bus.locks.get(key) ?? null;
|
||||||
|
if (!hasNX || old === null) this.bus.locks.set(key, val);
|
||||||
|
return Promise.resolve(hasGET ? old : 'OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
del(key: string) {
|
||||||
|
this.bus.locks.delete(key);
|
||||||
|
return Promise.resolve(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pack = (m: any) => Buffer.from(JSON.stringify(m));
|
||||||
|
const unpack = (b: Buffer) => JSON.parse(b.toString());
|
||||||
|
|
||||||
|
function makeExtension(
|
||||||
|
bus: FakeRedisBus,
|
||||||
|
serverId: string,
|
||||||
|
customEvents: Record<string, (doc: string, payload: any) => Promise<any>>,
|
||||||
|
) {
|
||||||
|
const ext = new RedisSyncExtension({
|
||||||
|
redis: new FakeRedis(bus) as any,
|
||||||
|
pack: pack as any,
|
||||||
|
unpack: unpack as any,
|
||||||
|
serverId,
|
||||||
|
customEvents: customEvents as any,
|
||||||
|
customEventTTL: 30_000,
|
||||||
|
});
|
||||||
|
// Doc is NOT loaded on this instance -> handleEvent takes the remote/proxy path.
|
||||||
|
(ext as any).instance = { documents: new Map() };
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RedisSyncExtension custom-event error propagation', () => {
|
||||||
|
let unhandled: unknown[];
|
||||||
|
let onUnhandled: (e: unknown) => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Fake timers so the 30s TTL fallback timer never fires (and never dangles).
|
||||||
|
jest.useFakeTimers();
|
||||||
|
unhandled = [];
|
||||||
|
onUnhandled = (e) => unhandled.push(e);
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('owner publishes an error-carrying reply (no unhandledRejection) when the remote handler throws', async () => {
|
||||||
|
const bus = new FakeRedisBus();
|
||||||
|
const owner = makeExtension(bus, 'owner', {
|
||||||
|
boom: async () => {
|
||||||
|
throw new Error('kaboom');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive the remote branch directly, as if the origin's customEventStart arrived.
|
||||||
|
await (owner as any).handleRedisMessage(
|
||||||
|
Buffer.from('collabMsg:owner'),
|
||||||
|
pack({
|
||||||
|
type: 'customEventStart',
|
||||||
|
documentName: 'page.x',
|
||||||
|
eventName: 'boom',
|
||||||
|
payload: {},
|
||||||
|
replyTo: 'collabMsg:origin',
|
||||||
|
replyId: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const replies = bus.published
|
||||||
|
.filter((p) => p.channel === 'collabMsg:origin')
|
||||||
|
.map((p) => unpack(p.message));
|
||||||
|
expect(replies).toHaveLength(1);
|
||||||
|
expect(replies[0]).toMatchObject({
|
||||||
|
type: 'customEventComplete',
|
||||||
|
replyId: 7,
|
||||||
|
error: 'kaboom',
|
||||||
|
});
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('origin rejects PROMPTLY with the real error (not a TTL TIMEOUT) when the remote handler throws', async () => {
|
||||||
|
const bus = new FakeRedisBus();
|
||||||
|
// Owner already holds the document lock.
|
||||||
|
bus.locks.set('collabLock:page.x', 'owner');
|
||||||
|
makeExtension(bus, 'owner', {
|
||||||
|
boom: async () => {
|
||||||
|
throw new Error('kaboom');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const origin = makeExtension(bus, 'origin', {
|
||||||
|
boom: async () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = (origin as any).handleEvent('boom', 'page.x', { foo: 1 });
|
||||||
|
// Attach a catch immediately so a rejection is never momentarily unhandled.
|
||||||
|
const settled = promise.then(
|
||||||
|
() => ({ ok: true as const }),
|
||||||
|
(e: unknown) => ({ ok: false as const, error: e }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
// Resolves WITHOUT advancing any timer -> the 30s TIMEOUT fallback did not fire.
|
||||||
|
const result = await settled;
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect((result as any).error).toBeInstanceOf(Error);
|
||||||
|
expect(((result as any).error as Error).message).toBe('kaboom');
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('origin resolves with the payload when the remote handler succeeds (unchanged behavior)', async () => {
|
||||||
|
const bus = new FakeRedisBus();
|
||||||
|
bus.locks.set('collabLock:page.x', 'owner');
|
||||||
|
makeExtension(bus, 'owner', {
|
||||||
|
ok: async (_doc: string, payload: any) => ({ echoed: payload }),
|
||||||
|
});
|
||||||
|
const origin = makeExtension(bus, 'origin', {
|
||||||
|
ok: async () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = (origin as any).handleEvent('ok', 'page.x', { foo: 1 });
|
||||||
|
await flush();
|
||||||
|
await expect(promise).resolves.toEqual({ echoed: { foo: 1 } });
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,9 +51,15 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
|||||||
private instance!: Hocuspocus;
|
private instance!: Hocuspocus;
|
||||||
private readonly customEvents: TCE;
|
private readonly customEvents: TCE;
|
||||||
private replyIdCounter: number = 0;
|
private replyIdCounter: number = 0;
|
||||||
// @ts-ignore
|
private pendingReplies: Record<
|
||||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
number,
|
||||||
{};
|
{
|
||||||
|
// @ts-ignore
|
||||||
|
resolve: PromiseWithResolvers<any>['resolve'];
|
||||||
|
// @ts-ignore
|
||||||
|
reject: PromiseWithResolvers<any>['reject'];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
constructor(configuration: Configuration<TCE>) {
|
constructor(configuration: Configuration<TCE>) {
|
||||||
const {
|
const {
|
||||||
@@ -176,25 +182,45 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
|||||||
}
|
}
|
||||||
if (type === 'customEventStart') {
|
if (type === 'customEventStart') {
|
||||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||||
const res = await this.handleEventLocally(
|
let reply: RSAMessageCustomEventComplete;
|
||||||
eventName as Extract<keyof TCE, string>,
|
try {
|
||||||
documentName,
|
const res = await this.handleEventLocally(
|
||||||
payload,
|
eventName as Extract<keyof TCE, string>,
|
||||||
);
|
documentName,
|
||||||
const reply: RSAMessageCustomEventComplete = {
|
payload,
|
||||||
type: 'customEventComplete',
|
);
|
||||||
replyId,
|
reply = {
|
||||||
payload: res,
|
type: 'customEventComplete',
|
||||||
};
|
replyId,
|
||||||
|
payload: res,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// The remote handler threw (e.g. the markdown->ProseMirror transform in
|
||||||
|
// gitSyncWriteBody can throw on a malformed body). Reply with the error on
|
||||||
|
// the SAME correlation channel so the origin rejects promptly with the real
|
||||||
|
// message instead of waiting out customEventTTL as a generic 'TIMEOUT'.
|
||||||
|
// Catching here also keeps the throw from escaping this async messageBuffer
|
||||||
|
// listener as an unhandledRejection on the owning instance.
|
||||||
|
reply = {
|
||||||
|
type: 'customEventComplete',
|
||||||
|
replyId,
|
||||||
|
payload: undefined,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'customEventComplete') {
|
if (type === 'customEventComplete') {
|
||||||
const { replyId, payload } = msg;
|
const { replyId, payload, error } = msg;
|
||||||
const resolveFn = this.pendingReplies[replyId];
|
const pending = this.pendingReplies[replyId];
|
||||||
if (!resolveFn) return;
|
if (!pending) return;
|
||||||
delete this.pendingReplies[replyId];
|
delete this.pendingReplies[replyId];
|
||||||
resolveFn(payload);
|
if (error !== undefined) {
|
||||||
|
pending.reject(new Error(error));
|
||||||
|
} else {
|
||||||
|
pending.resolve(payload);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { socketId } = msg;
|
const { socketId } = msg;
|
||||||
@@ -273,11 +299,22 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
|||||||
};
|
};
|
||||||
const msg = this.pack(proxyMessage);
|
const msg = this.pack(proxyMessage);
|
||||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||||
// @ts-ignore
|
// Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
|
||||||
const { promise, resolve, reject } = Promise.withResolvers();
|
let resolve!: (v: unknown) => void;
|
||||||
this.pendingReplies[replyId] = resolve;
|
let reject!: (e: unknown) => void;
|
||||||
|
const promise = new Promise((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
this.pendingReplies[replyId] = { resolve, reject };
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reject('TIMEOUT');
|
// Fallback for a genuinely lost reply. A handler that threw now rejects
|
||||||
|
// promptly via the error-carrying customEventComplete above; this TIMEOUT
|
||||||
|
// only fires when no reply ever comes back.
|
||||||
|
if (this.pendingReplies[replyId]) {
|
||||||
|
delete this.pendingReplies[replyId];
|
||||||
|
reject('TIMEOUT');
|
||||||
|
}
|
||||||
}, this.customEventTTL);
|
}, this.customEventTTL);
|
||||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export type RSAMessageCustomEventComplete = {
|
|||||||
type: 'customEventComplete';
|
type: 'customEventComplete';
|
||||||
replyId: number;
|
replyId: number;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
|
// When the remote handler THREW, the owner sends back the error message here
|
||||||
|
// instead of a payload, so the origin can reject its awaiting promise promptly
|
||||||
|
// (with the real error) rather than waiting out the customEventTTL timeout.
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RSAMessage =
|
export type RSAMessage =
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
/**
|
||||||
|
* JEST CONFIG NOTE (#119 ESM refactor): this is the one spec that needs the REAL
|
||||||
|
* `@docmost/git-sync` converter (not a mock). The package is now ESM, which jest
|
||||||
|
* cannot `require()` nor `import()` without --experimental-vm-modules, so the
|
||||||
|
* server jest config `moduleNameMapper`s `@docmost/git-sync` to its TS SOURCE and
|
||||||
|
* strips the ESM `.js` import suffixes. ts-jest then type-checks that source under
|
||||||
|
* the server's (looser) tsconfig and trips a benign narrowing; the global
|
||||||
|
* `isolatedModules: true` on the ts-jest transform (apps/server/package.json)
|
||||||
|
* makes it transpile-only so this spec loads. Full type-checking of the package
|
||||||
|
* is still enforced by its own `tsc`/vitest gates and the server `tsc --noEmit`.
|
||||||
|
*
|
||||||
|
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
|
||||||
|
*
|
||||||
|
* Proves the `@docmost/git-sync` pure converter is schema-compatible
|
||||||
|
* with the server's REAL editor-ext document schema: a representative corpus of
|
||||||
|
* editor-ext ProseMirror documents must survive a full round trip through the
|
||||||
|
* actual server write path without losing any node / mark / attribute.
|
||||||
|
*
|
||||||
|
* Pipeline per document (issue #194 §13.1):
|
||||||
|
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
|
||||||
|
* 2. doc = await markdownToProseMirror(md) // git-sync import
|
||||||
|
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
|
||||||
|
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
|
||||||
|
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
|
||||||
|
* This is exactly what PersistenceExtension does on store
|
||||||
|
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
|
||||||
|
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
|
||||||
|
* `@hocuspocus/transformer`, so the gate exercises the real schema
|
||||||
|
* validation that runs on a git-sync write (issue #194 §3.3).
|
||||||
|
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
|
||||||
|
*
|
||||||
|
* Any node / mark / attr that editor-ext drops (because the git-sync
|
||||||
|
* docmost-schema named it differently, or declares a different default) makes
|
||||||
|
* the gate FAIL for that document — exactly the schema-divergence issue #194 §3.3 /
|
||||||
|
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
|
||||||
|
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
|
||||||
|
*
|
||||||
|
* Requires the workspace packages built first:
|
||||||
|
* pnpm --filter @docmost/editor-ext build
|
||||||
|
* pnpm --filter @docmost/git-sync build
|
||||||
|
*/
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
|
||||||
|
// built CJS `dist` (its `main`). The ESM-only `@docmost/git-sync` package is
|
||||||
|
// mapped to its TS SOURCE by the jest `moduleNameMapper` (the built ESM cannot
|
||||||
|
// be `require()`d nor dynamically `import()`ed under jest's node VM), so ts-jest
|
||||||
|
// transpiles the real converter to CJS here — exercising the actual converter
|
||||||
|
// the server ships, not a stub.
|
||||||
|
import { tiptapExtensions } from './collaboration.util';
|
||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirror,
|
||||||
|
canonicalizeContent,
|
||||||
|
docsCanonicallyEqual,
|
||||||
|
} from '@docmost/git-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single editor-ext document through the full gate pipeline and return
|
||||||
|
* the canonical original vs the canonical doc as it lands after the real Yjs
|
||||||
|
* write path, plus the intermediate markdown for diagnostics.
|
||||||
|
*/
|
||||||
|
async function runGate(original: any): Promise<{
|
||||||
|
md: string;
|
||||||
|
imported: any;
|
||||||
|
normalized: any;
|
||||||
|
canonOriginal: any;
|
||||||
|
canonNormalized: any;
|
||||||
|
}> {
|
||||||
|
// 1) editor-ext JSON -> markdown (git-sync export).
|
||||||
|
const md = convertProseMirrorToMarkdown(original);
|
||||||
|
|
||||||
|
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
|
||||||
|
const imported = await markdownToProseMirror(md);
|
||||||
|
|
||||||
|
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
|
||||||
|
// toYdoc validates `imported` against tiptapExtensions (throws on an
|
||||||
|
// unknown node, drops unknown attrs); fromYdoc reads it back as the
|
||||||
|
// normalized editor-ext JSON the server would persist.
|
||||||
|
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
|
||||||
|
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
|
||||||
|
|
||||||
|
return {
|
||||||
|
md,
|
||||||
|
imported,
|
||||||
|
normalized,
|
||||||
|
canonOriginal: canonicalizeContent(original),
|
||||||
|
canonNormalized: canonicalizeContent(normalized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||||
|
const text = (t: string, marks?: any[]) =>
|
||||||
|
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||||
|
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
|
||||||
|
// Node / mark / attr names and DEFAULTS are taken from the real schema —
|
||||||
|
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
|
||||||
|
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
|
||||||
|
// non-null default on import (e.g. image.align="center", callout.type, list
|
||||||
|
// start) the fixture pre-authors that materialized value so the round trip is
|
||||||
|
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CORPUS: Record<string, any> = {
|
||||||
|
'paragraphs + headings (h1-h3)': doc(
|
||||||
|
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
|
||||||
|
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
|
||||||
|
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
|
||||||
|
para(text('A plain paragraph of text.')),
|
||||||
|
para(text('Second paragraph.')),
|
||||||
|
),
|
||||||
|
|
||||||
|
// A non-default paragraph alignment now round-trips (item #7 fix): it exports
|
||||||
|
// as `<p style="text-align:center">` and the schema's paragraph parseHTML
|
||||||
|
// reads `style="text-align"` back onto `textAlign` on import, so the alignment
|
||||||
|
// survives the full editor-ext write path. Promoted from the old KNOWN
|
||||||
|
// DIVERGENCE block (which only heading alignment still occupies).
|
||||||
|
'aligned paragraph (textAlign center)': doc({
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { textAlign: 'center' },
|
||||||
|
content: [text('centered')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'inline marks (bold/italic/strike/code)': doc(
|
||||||
|
para(
|
||||||
|
text('normal '),
|
||||||
|
text('bold', [{ type: 'bold' }]),
|
||||||
|
text(' '),
|
||||||
|
text('italic', [{ type: 'italic' }]),
|
||||||
|
text(' '),
|
||||||
|
text('struck', [{ type: 'strike' }]),
|
||||||
|
text(' '),
|
||||||
|
text('code', [{ type: 'code' }]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'links': doc(
|
||||||
|
para(
|
||||||
|
text('see '),
|
||||||
|
text('the site', [
|
||||||
|
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||||
|
]),
|
||||||
|
text(' for more'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'bullet list': doc({
|
||||||
|
type: 'bulletList',
|
||||||
|
content: [
|
||||||
|
{ type: 'listItem', content: [para(text('first'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('second'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('third'))] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'ordered list': doc({
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 1 },
|
||||||
|
content: [
|
||||||
|
{ type: 'listItem', content: [para(text('one'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('two'))] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'task list (checkbox)': doc({
|
||||||
|
type: 'taskList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'taskItem',
|
||||||
|
attrs: { checked: true },
|
||||||
|
content: [para(text('done item'))],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'taskItem',
|
||||||
|
attrs: { checked: false },
|
||||||
|
content: [para(text('todo item'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'blockquote': doc({
|
||||||
|
type: 'blockquote',
|
||||||
|
content: [para(text('a quoted line')), para(text('second quoted line'))],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'callout (info)': doc({
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: 'info' },
|
||||||
|
content: [para(text('an informational callout'))],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'callout (warning)': doc({
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: 'warning' },
|
||||||
|
content: [para(text('a warning callout'))],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'code block (with language)': doc({
|
||||||
|
type: 'codeBlock',
|
||||||
|
attrs: { language: 'typescript' },
|
||||||
|
// A fenced code block's body is stored with a trailing newline (the form a
|
||||||
|
// markdown ``` fence round-trips to: marked normalizes the code text to end
|
||||||
|
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
|
||||||
|
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
|
||||||
|
content: [text('const a: number = 1;\nconsole.log(a);\n')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'horizontal rule': doc(
|
||||||
|
para(text('before')),
|
||||||
|
{ type: 'horizontalRule' },
|
||||||
|
para(text('after')),
|
||||||
|
),
|
||||||
|
|
||||||
|
'table (header row + cells)': doc({
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableHeader',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('Name'))],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tableHeader',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('Value'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableCell',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('alpha'))],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tableCell',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('1'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// #8 — a table with a MULTI-BLOCK cell (two paragraphs). A GFM pipe table
|
||||||
|
// cannot hold two blocks without flattening them; the converter emits a
|
||||||
|
// lossless HTML <table> instead, and the two blocks must survive the round trip.
|
||||||
|
'table (multi-block cell, #8)': doc({
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableHeader',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('H'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableCell',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('first')), para(text('second'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// #7 — a table nested inside a column. Columns render as HTML containers, and a
|
||||||
|
// table inside one must stay an HTML <table> (a GFM pipe table cannot live
|
||||||
|
// inside an HTML block), round-tripping without being unwrapped or lost.
|
||||||
|
// `widthMode` is pre-authored at its materialized `normal` default (SPEC §11).
|
||||||
|
'table inside a column (#7)': doc({
|
||||||
|
type: 'columns',
|
||||||
|
attrs: { layout: 'two', widthMode: 'normal' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'column',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableRow',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tableHeader',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [para(text('C7'))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'column', content: [para(text('right'))] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||||
|
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||||
|
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||||
|
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||||
|
// details carries the materialized `open:false`, and color marks use the
|
||||||
|
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||||
|
|
||||||
|
'mention (user)': doc(
|
||||||
|
para(
|
||||||
|
text('hi '),
|
||||||
|
{
|
||||||
|
type: 'mention',
|
||||||
|
attrs: {
|
||||||
|
id: 'user-123',
|
||||||
|
label: 'Alice',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: 'user-123',
|
||||||
|
creatorId: 'creator-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text(' there'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'inline math': doc(
|
||||||
|
para(
|
||||||
|
text('inline '),
|
||||||
|
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||||
|
text(' math'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||||
|
|
||||||
|
'details (collapsible)': doc({
|
||||||
|
type: 'details',
|
||||||
|
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||||
|
// it puts the fixture at its round-trip fixpoint.
|
||||||
|
attrs: { open: false },
|
||||||
|
content: [
|
||||||
|
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||||
|
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
'highlight (mark, no color)': doc(
|
||||||
|
para(
|
||||||
|
text('a '),
|
||||||
|
text('highlighted', [{ type: 'highlight' }]),
|
||||||
|
text(' word'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'highlight (mark, with color)': doc(
|
||||||
|
para(
|
||||||
|
text('a '),
|
||||||
|
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||||
|
text(' word'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
'subscript': doc(
|
||||||
|
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||||
|
),
|
||||||
|
|
||||||
|
'superscript': doc(
|
||||||
|
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||||
|
),
|
||||||
|
|
||||||
|
'text color (textStyle)': doc(
|
||||||
|
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||||
|
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||||
|
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||||
|
// the KNOWN DIVERGENCE block below).
|
||||||
|
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||||
|
),
|
||||||
|
|
||||||
|
'nested / mixed document': doc(
|
||||||
|
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||||
|
para(
|
||||||
|
text('intro with '),
|
||||||
|
text('bold', [{ type: 'bold' }]),
|
||||||
|
text(' and a '),
|
||||||
|
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||||
|
text('.'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
type: 'bulletList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
para(text('item with '), text('code', [{ type: 'code' }])),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
para(text('item with sublist')),
|
||||||
|
{
|
||||||
|
type: 'bulletList',
|
||||||
|
content: [
|
||||||
|
{ type: 'listItem', content: [para(text('nested a'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('nested b'))] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: 'success' },
|
||||||
|
content: [
|
||||||
|
para(text('callout body')),
|
||||||
|
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'blockquote',
|
||||||
|
content: [para(text('quote at the end'))],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Atom embeds that carry no inline text: they must round-trip via their
|
||||||
|
// schema-matching HTML (data-type div), NOT a literal that re-imports as plain
|
||||||
|
// text. `subpages` used to export as the literal "{{SUBPAGES}}" and came back
|
||||||
|
// as visible text on the page (red-team round-trip data loss) — this locks it.
|
||||||
|
// editor-ext materializes the `recursive: false` default on import, so the
|
||||||
|
// fixture pre-authors it to sit at the round-trip fixpoint (matches the other
|
||||||
|
// default-materializing fixtures above).
|
||||||
|
'subpages embed': doc({ type: 'subpages', attrs: { recursive: false } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
|
||||||
|
for (const [name, original] of Object.entries(CORPUS)) {
|
||||||
|
it(`round-trips losslessly: ${name}`, async () => {
|
||||||
|
const { md, canonOriginal, canonNormalized } = await runGate(original);
|
||||||
|
|
||||||
|
const equal = docsCanonicallyEqual(original, canonNormalized);
|
||||||
|
if (!equal) {
|
||||||
|
// Surface a readable diff so a real divergence is actionable.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
|
||||||
|
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
|
||||||
|
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(equal).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
|
||||||
|
//
|
||||||
|
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
|
||||||
|
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
|
||||||
|
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
|
||||||
|
//
|
||||||
|
// 1. `convertProseMirrorToMarkdown` emits a standard `` image
|
||||||
|
// (markdown-converter.ts case "image"). Standard markdown image syntax has
|
||||||
|
// no way to express `width` / `height` / `align`, so those attrs are
|
||||||
|
// DROPPED on export and cannot be recovered on import.
|
||||||
|
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
|
||||||
|
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
|
||||||
|
// documented in packages/git-sync/test/fixtures/known-limitations).
|
||||||
|
//
|
||||||
|
// The gate documents the EXACT lossy shape below. If the converter is ever
|
||||||
|
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
|
||||||
|
// data-* attrs, as it already does for video/diagrams), these assertions flip
|
||||||
|
// and the image fixture should be promoted into the green CORPUS above.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERGENCE)', () => {
|
||||||
|
const imageDoc = doc({
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: 'https://example.com/pic.png',
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves width/height/align by exporting an HTML <img> (PR #119 round-trip fix)', async () => {
|
||||||
|
const { md, canonNormalized } = await runGate(imageDoc);
|
||||||
|
|
||||||
|
// A top-level image carrying layout attrs is now exported as a schema-
|
||||||
|
// matching HTML <img> (the same path video/diagrams already use), so the
|
||||||
|
// dimensions and alignment survive the round trip instead of collapsing to
|
||||||
|
// bare ``.
|
||||||
|
expect(md.trim()).toBe(
|
||||||
|
'<img src="https://example.com/pic.png" width="640" height="480" align="center">',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The round-tripped image keeps src + the layout attrs. width/height are
|
||||||
|
// re-imported as strings (matching the video/audio/pdf string convention),
|
||||||
|
// so assert the values rather than the JS type.
|
||||||
|
const imgAttrs = (canonNormalized as any).content[0].attrs;
|
||||||
|
expect((canonNormalized as any).content[0].type).toBe('image');
|
||||||
|
expect(imgAttrs.src).toBe('https://example.com/pic.png');
|
||||||
|
expect(imgAttrs.align).toBe('center');
|
||||||
|
expect(String(imgAttrs.width)).toBe('640');
|
||||||
|
expect(String(imgAttrs.height)).toBe('480');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HEADING text alignment — now round-trips (item A1; formerly a KNOWN DIVERGENCE).
|
||||||
|
// Symmetric with the paragraph fix: a heading's non-default `textAlign` is
|
||||||
|
// exported as a styled `<hN style="text-align:…">` (was a bare ATX `## text`
|
||||||
|
// that dropped it) and re-parsed by the heading + textAlign parseHTML on import,
|
||||||
|
// so a non-default heading alignment SURVIVES a full round trip.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('git-sync converter §13.1 heading text alignment round-trips', () => {
|
||||||
|
it('preserves a heading textAlign across the markdown round trip', async () => {
|
||||||
|
const alignedHeading = doc({
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 2, textAlign: 'center' },
|
||||||
|
content: [text('centered heading')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||||
|
|
||||||
|
// Export is a styled <h2> (was a lossy bare `## centered heading`).
|
||||||
|
expect(md.trim()).toBe(
|
||||||
|
'<h2 style="text-align:center">centered heading</h2>',
|
||||||
|
);
|
||||||
|
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||||
|
//
|
||||||
|
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||||
|
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||||
|
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||||
|
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||||
|
// though the color is semantically preserved. Locked here so the boundary is
|
||||||
|
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||||
|
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||||
|
const hexDoc = doc(
|
||||||
|
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { canonNormalized } = await runGate(hexDoc);
|
||||||
|
|
||||||
|
// Color survives, but as the normalized rgb() string.
|
||||||
|
expect(canonNormalized).toEqual({
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'green',
|
||||||
|
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Not string-identical to the #hex original.
|
||||||
|
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Backward-filled LCS length table for sequences `a` and `b`: `dp[i][j]` is the
|
||||||
|
* length of the longest common subsequence of the suffixes `a[i:]` and `b[j:]`.
|
||||||
|
* O(n*m) time/space — fine for page block counts.
|
||||||
|
*
|
||||||
|
* Shared by the two-way block diff (`yjs-body-merge.diffBlocks`) and the
|
||||||
|
* three-way merge planner (`three-way-merge.lcsPairs`) so the (identical) table
|
||||||
|
* construction lives in ONE place; each caller does its own traceback over the
|
||||||
|
* returned table.
|
||||||
|
*/
|
||||||
|
export function buildLcsTable(a: string[], b: string[]): number[][] {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
new Array(m + 1).fill(0),
|
||||||
|
);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] =
|
||||||
|
a[i] === b[j]
|
||||||
|
? dp[i + 1][j + 1] + 1
|
||||||
|
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { diff3Plan, type Pick } from './three-way-merge';
|
||||||
|
|
||||||
|
// Materialize a plan into the merged key sequence for assertion.
|
||||||
|
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||||
|
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||||
|
apply(diff3Plan(o, a, b), a, b);
|
||||||
|
|
||||||
|
describe('diff3Plan red-team #9 (human edit + adjacent git insert)', () => {
|
||||||
|
it('keeps human block-2 edit AND applies git insert of 2.5', () => {
|
||||||
|
// base: 1 2 3
|
||||||
|
// live: 1 H 3 (human rewrote block 2)
|
||||||
|
// target: 1 2 2.5 3 (git inserted 2.5 after block 2)
|
||||||
|
expect(
|
||||||
|
merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '2.5', '3']),
|
||||||
|
).toEqual(['1', 'H', '2.5', '3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
diff3Plan,
|
||||||
|
diff3PlanWithConflicts,
|
||||||
|
type Pick,
|
||||||
|
} from './three-way-merge';
|
||||||
|
|
||||||
|
// Materialize a plan into the merged key sequence for assertion.
|
||||||
|
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||||
|
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||||
|
apply(diff3Plan(o, a, b), a, b);
|
||||||
|
|
||||||
|
describe('diff3Plan (block-level three-way merge)', () => {
|
||||||
|
it('identical on all three sides -> unchanged (all from live)', () => {
|
||||||
|
const plan = diff3Plan(['1', '2', '3'], ['1', '2', '3'], ['1', '2', '3']);
|
||||||
|
expect(plan.every((p) => p.src === 'live')).toBe(true);
|
||||||
|
expect(apply(plan, ['1', '2', '3'], ['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git changed a block the human did not -> takes git', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '9', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'9',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human changed a block git did not -> KEEPS the human edit (the core 3-way win)', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'H',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bug #2 observability: diff3PlanWithConflicts reports SAME-BLOCK conflicts so
|
||||||
|
// the caller can surface the "git wins" loss (log + history pin) instead of
|
||||||
|
// dropping the human side silently.
|
||||||
|
describe('diff3PlanWithConflicts (same-block conflict reporting)', () => {
|
||||||
|
it('reports 0 conflicts when sides changed DIFFERENT blocks (clean merge)', () => {
|
||||||
|
const r = diff3PlanWithConflicts(
|
||||||
|
['1', '2', '3'],
|
||||||
|
['H', '2', '3'],
|
||||||
|
['1', '2', 'G'],
|
||||||
|
);
|
||||||
|
expect(r.conflicts).toBe(0);
|
||||||
|
expect(apply(r.picks, ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||||
|
'H',
|
||||||
|
'2',
|
||||||
|
'G',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports 1 conflict and git wins when BOTH rewrote the SAME block', () => {
|
||||||
|
const r = diff3PlanWithConflicts(
|
||||||
|
['1', '2', '3'],
|
||||||
|
['1', 'H', '3'], // human rewrote block 2
|
||||||
|
['1', 'G', '3'], // git rewrote block 2
|
||||||
|
);
|
||||||
|
expect(r.conflicts).toBe(1);
|
||||||
|
// Git wins the contested block; the human 'H' is NOT in the picks.
|
||||||
|
expect(apply(r.picks, ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'G',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT count a git-only region (no human content to lose) as a conflict', () => {
|
||||||
|
const r = diff3PlanWithConflicts(
|
||||||
|
['1', '2', '3'],
|
||||||
|
['1', '2', '3'], // human unchanged
|
||||||
|
['1', '9', '3'], // git rewrote block 2
|
||||||
|
);
|
||||||
|
expect(r.conflicts).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human and git changed DIFFERENT blocks -> both preserved', () => {
|
||||||
|
// human rewrote block 1, git rewrote block 3.
|
||||||
|
expect(merge(['1', '2', '3'], ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||||
|
'H',
|
||||||
|
'2',
|
||||||
|
'G',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human inserted a block AND git changed a different block -> both preserved', () => {
|
||||||
|
expect(
|
||||||
|
merge(['1', '2', '3'], ['1', '1.5', '2', '3'], ['1', '2', 'G']),
|
||||||
|
).toEqual(['1', '1.5', '2', 'G']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both changed the SAME block -> conflict resolves to git', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'G',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both made the SAME edit -> that edit (no duplication)', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', 'X', '3'], ['1', 'X', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'X',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human deleted a block git left alone -> deletion preserved', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '2', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git deleted a block the human left alone -> deletion applied', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both deleted the same block -> gone (no conflict)', () => {
|
||||||
|
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '3'])).toEqual(['1', '3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git appended a trailing block -> appended', () => {
|
||||||
|
expect(merge(['1', '2'], ['1', '2'], ['1', '2', '3'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human appended a trailing block git did not -> kept', () => {
|
||||||
|
expect(merge(['1', '2'], ['1', '2', '3'], ['1', '2'])).toEqual([
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty base, git provides content (brand-new page body) -> git content', () => {
|
||||||
|
expect(merge([], [], ['1', '2'])).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git changed block 1, human edited block 3, far apart -> both kept', () => {
|
||||||
|
expect(
|
||||||
|
merge(
|
||||||
|
['a', 'b', 'c', 'd', 'e'],
|
||||||
|
['a', 'b', 'c', 'd', 'E'],
|
||||||
|
['A', 'b', 'c', 'd', 'e'],
|
||||||
|
),
|
||||||
|
).toEqual(['A', 'b', 'c', 'd', 'E']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Pure block-level THREE-WAY merge planner (diff3) over arrays of opaque block
|
||||||
|
* keys. Used by the git-sync body write to merge an incoming git body into the
|
||||||
|
* live page using the last-synced version as the common ancestor (review #5):
|
||||||
|
*
|
||||||
|
* - a block only the human changed (live != base, git == base) -> keep LIVE
|
||||||
|
* - a block only git changed (git != base, live == base) -> take GIT
|
||||||
|
* - a block both sides changed (a real conflict) -> GIT wins
|
||||||
|
* - inserts/deletes from either side are preserved when unambiguous
|
||||||
|
*
|
||||||
|
* Content-agnostic: it works on string keys and returns the merged block order as
|
||||||
|
* picks ({ src: 'live'|'target', index }) — the caller (the Yjs applier)
|
||||||
|
* materializes them — so the whole algorithm is unit-testable on plain arrays.
|
||||||
|
*
|
||||||
|
* Algorithm: anchor on base blocks present (unchanged) in BOTH live and target
|
||||||
|
* (their LCS-with-base intersection). Between consecutive anchors lies one region
|
||||||
|
* the human and/or git rewrote; resolve each region three-way. Stable anchor
|
||||||
|
* blocks are emitted from LIVE so the applier keeps the existing Yjs block
|
||||||
|
* instances (and the human's in-flight edits) in place.
|
||||||
|
*
|
||||||
|
* LOCATION (deferred): this and its `lcs.ts` sibling are pure, framework-free and
|
||||||
|
* could conceptually live in `packages/git-sync` (the engine). They are kept in
|
||||||
|
* the server integration on purpose: `packages/git-sync` is a VENDORED engine
|
||||||
|
* (pinned upstream, manually re-synced), so adding first-party files there
|
||||||
|
* complicates the re-sync story, and the only consumer today is the server. Move
|
||||||
|
* them into the engine only once the vendoring re-sync story is settled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { buildLcsTable } from './lcs';
|
||||||
|
|
||||||
|
/** Matched index pairs of the longest common subsequence of `a` and `b`. */
|
||||||
|
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
const dp = buildLcsTable(a, b);
|
||||||
|
const pairs: Array<[number, number]> = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
pairs.push([i, j]);
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** o-index -> matched index in the other side (only for LCS-matched blocks). */
|
||||||
|
function matchMap(pairs: Array<[number, number]>): Map<number, number> {
|
||||||
|
const m = new Map<number, number>();
|
||||||
|
for (const [o, x] of pairs) m.set(o, x);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One change `side` made to `base` within a region: base blocks `[oStart,oEnd)`
|
||||||
|
* were replaced by the side's blocks listed in `content` (region-local indices).
|
||||||
|
* A pure insert has `oStart === oEnd`; a pure delete has empty `content`.
|
||||||
|
*/
|
||||||
|
interface Hunk {
|
||||||
|
oStart: number;
|
||||||
|
oEnd: number;
|
||||||
|
content: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff `o` against one side as a list of non-overlapping hunks (the base spans
|
||||||
|
* the side rewrote/inserted/deleted), derived from their LCS alignment.
|
||||||
|
*/
|
||||||
|
function buildHunks(o: string[], side: string[]): Hunk[] {
|
||||||
|
const pairs = lcsPairs(o, side); // [oIdx, sideIdx] kept (unchanged) blocks
|
||||||
|
const hunks: Hunk[] = [];
|
||||||
|
let prevO = -1;
|
||||||
|
let prevS = -1;
|
||||||
|
const flush = (curO: number, curS: number): void => {
|
||||||
|
const oStart = prevO + 1;
|
||||||
|
const oEnd = curO;
|
||||||
|
const content: number[] = [];
|
||||||
|
for (let s = prevS + 1; s < curS; s++) content.push(s);
|
||||||
|
if (oEnd > oStart || content.length > 0) hunks.push({ oStart, oEnd, content });
|
||||||
|
};
|
||||||
|
for (const [oIdx, sIdx] of pairs) {
|
||||||
|
flush(oIdx, sIdx);
|
||||||
|
prevO = oIdx;
|
||||||
|
prevS = sIdx;
|
||||||
|
}
|
||||||
|
flush(o.length, side.length);
|
||||||
|
return hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do two hunks (one per side) touch the same base region? Pure inserts only
|
||||||
|
* collide when nested strictly inside the other hunk's base span (or, for two
|
||||||
|
* inserts, at the same gap); changes sitting at a shared boundary do not.
|
||||||
|
*/
|
||||||
|
function hunksOverlap(a: Hunk, b: Hunk): boolean {
|
||||||
|
const aIns = a.oStart === a.oEnd;
|
||||||
|
const bIns = b.oStart === b.oEnd;
|
||||||
|
if (aIns && bIns) return a.oStart === b.oStart;
|
||||||
|
if (aIns) return b.oStart < a.oStart && a.oStart < b.oEnd;
|
||||||
|
if (bIns) return a.oStart < b.oStart && b.oStart < a.oEnd;
|
||||||
|
return Math.max(a.oStart, b.oStart) < Math.min(a.oEnd, b.oEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalPick {
|
||||||
|
src: 'live' | 'target';
|
||||||
|
local: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fine-grained three-way merge of ONE inter-anchor region. Combines the human's
|
||||||
|
* and git's NON-overlapping hunks (e.g. a human edit to one block plus a git
|
||||||
|
* insert/delete of OTHER blocks in the same region) so neither change is lost.
|
||||||
|
* Returns the merged region as region-local picks, or `null` when the two sides
|
||||||
|
* changed the SAME base block — a genuine conflict the caller resolves by the
|
||||||
|
* original all-or-nothing rule (git wins the whole region).
|
||||||
|
*/
|
||||||
|
function tryMergeRegion(
|
||||||
|
o: string[],
|
||||||
|
a: string[],
|
||||||
|
b: string[],
|
||||||
|
): LocalPick[] | null {
|
||||||
|
// Agreement short-circuit (review #11). When live (a) and target (b) are
|
||||||
|
// identical, both sides converged on the SAME result — diff3 "agreement", NOT
|
||||||
|
// a conflict. This is the dominant echo case (live == target != base) that
|
||||||
|
// otherwise trips the overlap check below and is logged as a false "N same-block
|
||||||
|
// conflict(s) resolved to the git version", masking REAL data-loss signals.
|
||||||
|
// Emit the region straight from live (which equals target); no conflict.
|
||||||
|
if (a.length === b.length && a.every((v, i) => v === b[i])) {
|
||||||
|
return a.map((_v, i) => ({ src: 'live', local: i }) as LocalPick);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aHunks = buildHunks(o, a);
|
||||||
|
const bHunks = buildHunks(o, b);
|
||||||
|
|
||||||
|
// Any overlap between a human hunk and a git hunk is a real conflict; bail so
|
||||||
|
// the caller falls back to git-wins (preserving the original behavior).
|
||||||
|
for (const ah of aHunks) {
|
||||||
|
for (const bh of bHunks) {
|
||||||
|
if (hunksOverlap(ah, bh)) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disjoint: live index of each base block that BOTH sides kept (stable).
|
||||||
|
const aKept = matchMap(lcsPairs(o, a)); // base index -> live index
|
||||||
|
|
||||||
|
const out: LocalPick[] = [];
|
||||||
|
let pa = 0;
|
||||||
|
let pb = 0;
|
||||||
|
let oi = 0;
|
||||||
|
while (oi < o.length || pa < aHunks.length || pb < bHunks.length) {
|
||||||
|
const ah = pa < aHunks.length ? aHunks[pa] : null;
|
||||||
|
const bh = pb < bHunks.length ? bHunks[pb] : null;
|
||||||
|
const nextStart = Math.min(
|
||||||
|
ah ? ah.oStart : o.length,
|
||||||
|
bh ? bh.oStart : o.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit stable base blocks (kept by both) until the next hunk, from LIVE.
|
||||||
|
while (oi < nextStart) {
|
||||||
|
out.push({ src: 'live', local: aKept.get(oi) as number });
|
||||||
|
oi++;
|
||||||
|
}
|
||||||
|
if (!ah && !bh) break;
|
||||||
|
|
||||||
|
// Apply the hunk at oi. When both sides act here they are disjoint, so the
|
||||||
|
// pure-insert (oEnd === oi) is emitted before the side that consumes base oi.
|
||||||
|
const aHere = ah !== null && ah.oStart === oi;
|
||||||
|
const bHere = bh !== null && bh.oStart === oi;
|
||||||
|
let useA: boolean;
|
||||||
|
if (aHere && bHere) {
|
||||||
|
useA = ah!.oEnd === oi; // insert side first; otherwise either order is fine
|
||||||
|
} else {
|
||||||
|
useA = aHere;
|
||||||
|
}
|
||||||
|
const h = (useA ? ah : bh) as Hunk;
|
||||||
|
const src: 'live' | 'target' = useA ? 'live' : 'target';
|
||||||
|
for (const idx of h.content) out.push({ src, local: idx });
|
||||||
|
oi = h.oEnd;
|
||||||
|
if (useA) pa++;
|
||||||
|
else pb++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pick {
|
||||||
|
src: 'live' | 'target';
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The merged block order PLUS how many regions resolved as a genuine SAME-BLOCK
|
||||||
|
* conflict (both sides rewrote the same base block — `tryMergeRegion` returned
|
||||||
|
* null and git won the whole region, so the live/human version of those blocks
|
||||||
|
* is NOT in `picks`). `conflicts > 0` is the OBSERVABLE signal the caller uses to
|
||||||
|
* surface "git won a concurrent same-block edit" (log it + pin the human
|
||||||
|
* baseline to page history) instead of dropping the human side silently.
|
||||||
|
*/
|
||||||
|
export interface Diff3Result {
|
||||||
|
picks: Pick[];
|
||||||
|
conflicts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-way merge of base `o`, live `a`, target `b` (arrays of block keys).
|
||||||
|
* Returns the merged block order as picks from live/target. Thin wrapper over
|
||||||
|
* `diff3PlanWithConflicts` (kept for the existing pure-array callers/tests).
|
||||||
|
*/
|
||||||
|
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
|
||||||
|
return diff3PlanWithConflicts(o, a, b).picks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `diff3Plan` but also reports the SAME-BLOCK conflict count (see
|
||||||
|
* `Diff3Result`). A region where both the human and git rewrote the same base
|
||||||
|
* block cannot be merged automatically; the rule is deterministic — GIT WINS the
|
||||||
|
* whole region — but the human's version of those blocks is then absent from the
|
||||||
|
* picks, so we count it so the caller can make the loss observable/recoverable
|
||||||
|
* rather than silent (the documented conflict contract).
|
||||||
|
*/
|
||||||
|
export function diff3PlanWithConflicts(
|
||||||
|
o: string[],
|
||||||
|
a: string[],
|
||||||
|
b: string[],
|
||||||
|
): Diff3Result {
|
||||||
|
const oToA = matchMap(lcsPairs(o, a));
|
||||||
|
const oToB = matchMap(lcsPairs(o, b));
|
||||||
|
|
||||||
|
const res: Pick[] = [];
|
||||||
|
let conflicts = 0;
|
||||||
|
let oi = 0;
|
||||||
|
let ai = 0;
|
||||||
|
let bi = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
// Next anchor: a base block present (unchanged) in BOTH live and target.
|
||||||
|
let anchor = oi;
|
||||||
|
while (anchor < o.length && !(oToA.has(anchor) && oToB.has(anchor))) {
|
||||||
|
anchor++;
|
||||||
|
}
|
||||||
|
const aEnd = anchor < o.length ? (oToA.get(anchor) as number) : a.length;
|
||||||
|
const bEnd = anchor < o.length ? (oToB.get(anchor) as number) : b.length;
|
||||||
|
|
||||||
|
// Resolve the region [oi,anchor) that one or both sides rewrote/inserted.
|
||||||
|
// Try a fine-grained three-way merge first so a human block-edit survives a
|
||||||
|
// git insert/delete of OTHER blocks in the same region; only a genuine
|
||||||
|
// same-block conflict (null) falls back to the original git-wins rule.
|
||||||
|
const merged = tryMergeRegion(
|
||||||
|
o.slice(oi, anchor),
|
||||||
|
a.slice(ai, aEnd),
|
||||||
|
b.slice(bi, bEnd),
|
||||||
|
);
|
||||||
|
if (merged) {
|
||||||
|
for (const p of merged) {
|
||||||
|
res.push(
|
||||||
|
p.src === 'live'
|
||||||
|
? { src: 'live', index: ai + p.local }
|
||||||
|
: { src: 'target', index: bi + p.local },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SAME-BLOCK CONFLICT: count it ONLY when the human side actually had
|
||||||
|
// content in this region that git's win discards (live region non-empty).
|
||||||
|
// A region only git rewrote (live region empty) is not a human loss.
|
||||||
|
if (aEnd > ai) conflicts++;
|
||||||
|
for (let k = bi; k < bEnd; k++) res.push({ src: 'target', index: k });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor >= o.length) break;
|
||||||
|
|
||||||
|
// Emit the stable anchor block from LIVE, then advance past it on all sides.
|
||||||
|
res.push({ src: 'live', index: aEnd });
|
||||||
|
ai = aEnd + 1;
|
||||||
|
bi = bEnd + 1;
|
||||||
|
oi = anchor + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { picks: res, conflicts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import {
|
||||||
|
markdownToProseMirror,
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
} from '@docmost/git-sync';
|
||||||
|
|
||||||
|
import { tiptapExtensions } from '../collaboration.util';
|
||||||
|
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the QA #119 callout findings (body-duplication re-verify +
|
||||||
|
* "callout strips the whole body"). These reproduce the ACTUAL live merge path:
|
||||||
|
*
|
||||||
|
* live = TiptapTransformer.toYdoc(editor JSON, tiptapExtensions) (the
|
||||||
|
* collaboration server's materialization — schema defaults stamped)
|
||||||
|
* git = toYdoc(markdownToProseMirror(convertProseMirrorToMarkdown(editor)))
|
||||||
|
* (the engine round-trip the push side feeds into writePageBody)
|
||||||
|
*
|
||||||
|
* A page containing a callout (with a neighbouring heading + paragraphs) must:
|
||||||
|
* - merge with ZERO ops on an unchanged resync (no duplication — bug #1), and
|
||||||
|
* - NEVER lose blocks / collapse to empty (no strip — bug #2),
|
||||||
|
* across repeated cycles, for every editor-canonical callout type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const toYdoc = (content: unknown[]) =>
|
||||||
|
TiptapTransformer.toYdoc(
|
||||||
|
{ type: 'doc', content },
|
||||||
|
'default',
|
||||||
|
tiptapExtensions as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockTypes = (f: Y.XmlFragment) =>
|
||||||
|
f.toArray().map((n: any) => n.nodeName);
|
||||||
|
|
||||||
|
function editorPage(calloutType: string) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { id: 'h1', level: 1 },
|
||||||
|
content: [{ type: 'text', text: 'Title here' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { id: 'p1' },
|
||||||
|
content: [{ type: 'text', text: 'Para before callout' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: calloutType },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { id: 'pc' },
|
||||||
|
content: [{ type: 'text', text: 'Inside the callout' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { id: 'p2' },
|
||||||
|
content: [{ type: 'text', text: 'Para after callout' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitRoundTrip(content: unknown[]): Promise<any[]> {
|
||||||
|
const md = await convertProseMirrorToMarkdown({ type: 'doc', content });
|
||||||
|
const json = await markdownToProseMirror(md);
|
||||||
|
return json.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('git-sync callout merge is idempotent + non-destructive (QA #119)', () => {
|
||||||
|
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||||
|
it(`callout(${type}) resyncs with 0 ops and never strips the body`, async () => {
|
||||||
|
const editor = editorPage(type);
|
||||||
|
const gitContent = await gitRoundTrip(editor);
|
||||||
|
|
||||||
|
const liveDoc = toYdoc(editor);
|
||||||
|
const live = liveDoc.getXmlFragment('default');
|
||||||
|
const before = live.toArray().length;
|
||||||
|
expect(before).toBe(4);
|
||||||
|
|
||||||
|
// 2-way: live vs the git round-trip -> no-op (no dup, no strip).
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments(live, toYdoc(gitContent).getXmlFragment('default'));
|
||||||
|
});
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(live.toArray().length).toBe(before);
|
||||||
|
|
||||||
|
// 3-way across 4 cycles with base == git (the steady-state) -> stable.
|
||||||
|
for (let cycle = 0; cycle < 4; cycle++) {
|
||||||
|
let a = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
a = mergeXmlFragments3Way(
|
||||||
|
live,
|
||||||
|
toYdoc(gitContent).getXmlFragment('default'),
|
||||||
|
toYdoc(gitContent).getXmlFragment('default'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(a).toBe(0);
|
||||||
|
expect(live.toArray().length).toBe(before);
|
||||||
|
expect(blockTypes(live)).toEqual([
|
||||||
|
'heading',
|
||||||
|
'paragraph',
|
||||||
|
'callout',
|
||||||
|
'paragraph',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('3-way with a stale base (callout JUST added) keeps the callout + neighbours', async () => {
|
||||||
|
// base = the previously-synced version WITHOUT the callout (git round-trip);
|
||||||
|
// the human just inserted the callout -> the merge must KEEP everything.
|
||||||
|
const prev = [
|
||||||
|
{ type: 'heading', attrs: { id: 'h1', level: 1 }, content: [{ type: 'text', text: 'Title here' }] },
|
||||||
|
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'Para before callout' }] },
|
||||||
|
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'Para after callout' }] },
|
||||||
|
];
|
||||||
|
const editor = editorPage('info');
|
||||||
|
const baseContent = await gitRoundTrip(prev);
|
||||||
|
const gitContent = await gitRoundTrip(editor);
|
||||||
|
|
||||||
|
const liveDoc = toYdoc(editor);
|
||||||
|
const live = liveDoc.getXmlFragment('default');
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
mergeXmlFragments3Way(
|
||||||
|
live,
|
||||||
|
toYdoc(gitContent).getXmlFragment('default'),
|
||||||
|
toYdoc(baseContent).getXmlFragment('default'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Body survives in full — NOT stripped to empty / a lone paragraph.
|
||||||
|
expect(blockTypes(live)).toEqual([
|
||||||
|
'heading',
|
||||||
|
'paragraph',
|
||||||
|
'callout',
|
||||||
|
'paragraph',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () => {
|
||||||
|
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||||
|
it(`preserves callout type "${type}" across the engine round-trip`, async () => {
|
||||||
|
const content = editorPage(type);
|
||||||
|
const gitContent = await gitRoundTrip(content);
|
||||||
|
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||||
|
expect(co?.attrs?.type).toBe(type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps a known GitHub/Obsidian alias to the editor banner (tip -> success)', async () => {
|
||||||
|
// `tip` is not a schema callout type — it is an input alias the editor itself
|
||||||
|
// maps onto the supported set (GITHUB_ALERT_TYPE_MAP: tip -> success). git-sync
|
||||||
|
// mirrors that so the ingest lands on the closest banner instead of flatly info.
|
||||||
|
const content = editorPage('tip');
|
||||||
|
const gitContent = await gitRoundTrip(content);
|
||||||
|
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||||
|
expect(co?.attrs?.type).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens a genuinely unknown callout type to info', async () => {
|
||||||
|
const content = editorPage('banana'); // not a type and not a known alias
|
||||||
|
const gitContent = await gitRoundTrip(content);
|
||||||
|
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||||
|
expect(co?.attrs?.type).toBe('info');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the HIGH-severity runaway whole-body duplication: a page body
|
||||||
|
* was RE-APPENDED in full on every git-sync reconcile cycle, unbounded, with NO
|
||||||
|
* client connected.
|
||||||
|
*
|
||||||
|
* ROOT CAUSE (confirmed in-process against the real failing page): the LIVE Yjs
|
||||||
|
* document materializes the editor-schema default `indent: 0` on every
|
||||||
|
* paragraph/heading (and on the paragraph inside every list item, callout, and
|
||||||
|
* table cell), but a body re-imported from git — parsed from clean markdown —
|
||||||
|
* carries NO indent attribute. So every live block's comparison key differed from
|
||||||
|
* the same block coming back from git; the three-way merge could anchor on
|
||||||
|
* NOTHING, and the trailing unit that git's export already contained (but the
|
||||||
|
* merge could not match against the byte-identical live tail) was re-appended
|
||||||
|
* each cycle. Each grown export then diverged from the last-pushed base by one
|
||||||
|
* more unit — a self-sustaining loop.
|
||||||
|
*
|
||||||
|
* The fix normalizes the materialized default (`indent: 0`) out of the block key
|
||||||
|
* (the schema-derived `serializeXmlNode` normalization in yjs-body-merge.ts drops
|
||||||
|
* every attr equal to its ProseMirror-schema default; `indent: 0` is one such),
|
||||||
|
* so a live block compares equal to its git-round-tripped twin and the resync is
|
||||||
|
* a true no-op. The sibling `yjs-body-merge.schema-defaults.spec.ts` covers the
|
||||||
|
* rest of the bug class (image.align, link mark internal, …).
|
||||||
|
*
|
||||||
|
* These tests model that EXACTLY at the Yjs level: a LIVE fragment whose blocks
|
||||||
|
* carry `indent: 0` + block ids, versus a git-derived fragment of the SAME
|
||||||
|
* content with neither — for a body built from BYTE-IDENTICAL units that each
|
||||||
|
* contain a heading, a paragraph, a callout, and a table with empty cells (the
|
||||||
|
* trigger). RED before the fix (the merge applies > 0 ops and the body grows),
|
||||||
|
* GREEN after (0 ops, no growth).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Attrs = Record<string, string | number>;
|
||||||
|
|
||||||
|
function el(
|
||||||
|
name: string,
|
||||||
|
attrs: Attrs,
|
||||||
|
children: (Y.XmlElement | Y.XmlText)[],
|
||||||
|
) {
|
||||||
|
const e = new Y.XmlElement(name);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||||
|
if (children.length) e.insert(0, children);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(s: string): Y.XmlText {
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
if (s) t.insert(0, s);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One byte-identical content unit (heading / paragraph / callout / table-with-
|
||||||
|
* empty-cells). `live` toggles the two things that exist ONLY in the live Yjs
|
||||||
|
* doc and NOT in a git round-trip: the materialized `indent: 0` default and the
|
||||||
|
* per-block `id`. `n` makes each unit's ids unique (as the editor would stamp)
|
||||||
|
* while keeping the visible CONTENT byte-identical across units.
|
||||||
|
*/
|
||||||
|
function unit(
|
||||||
|
live: boolean,
|
||||||
|
n: number,
|
||||||
|
headingText = 'Big Heading',
|
||||||
|
): Y.XmlElement[] {
|
||||||
|
const ind: Attrs = live ? { indent: 0 } : {};
|
||||||
|
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||||
|
const para = (attrs: Attrs, s: string) =>
|
||||||
|
el('paragraph', { ...attrs, ...ind }, [text(s)]);
|
||||||
|
|
||||||
|
const cell = (name: string) =>
|
||||||
|
el(name, { colspan: 1, rowspan: 1 }, [para({}, '')]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
el('heading', { ...id('h'), level: 1, ...ind }, [text(headingText)]),
|
||||||
|
para(id('p'), 'Para with the same words'),
|
||||||
|
el('callout', { type: 'info' }, [para(id('c'), 'CalloutText here')]),
|
||||||
|
el('table', {}, [
|
||||||
|
el('tableRow', {}, [cell('tableHeader'), cell('tableHeader')]),
|
||||||
|
el('tableRow', {}, [cell('tableCell'), cell('tableCell')]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fragmentOf(units: Y.XmlElement[][]): {
|
||||||
|
doc: Y.Doc;
|
||||||
|
frag: Y.XmlFragment;
|
||||||
|
} {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const frag = doc.getXmlFragment('default');
|
||||||
|
const blocks = units.flat();
|
||||||
|
if (blocks.length) frag.insert(0, blocks);
|
||||||
|
return { doc, frag };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||||
|
|
||||||
|
describe('git-sync reconcile import is idempotent (no whole-body duplication)', () => {
|
||||||
|
const UNITS = 3;
|
||||||
|
|
||||||
|
it('3-way: identical content, live carries indent:0, base stale-by-one -> 0 ops, no growth', () => {
|
||||||
|
// LIVE: the editor-stamped Yjs doc (indent:0 + ids on every block).
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||||
|
);
|
||||||
|
// INCOMING (git export -> re-import): same content, NO indent / ids.
|
||||||
|
const { frag: incoming } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||||
|
);
|
||||||
|
// BASE = last-pushed file, lagging by ONE unit (the realistic divergence
|
||||||
|
// that drives the trailing insert-vs-insert).
|
||||||
|
const { frag: base } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-way is a fixpoint across repeated cycles (does not grow)', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||||
|
);
|
||||||
|
const incomingUnits = () =>
|
||||||
|
fragmentOf(Array.from({ length: UNITS }, (_, i) => unit(false, i))).frag;
|
||||||
|
const baseUnits = () =>
|
||||||
|
fragmentOf(Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)))
|
||||||
|
.frag;
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
for (let cycle = 0; cycle < 5; cycle++) {
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(live, incomingUnits(), baseUnits());
|
||||||
|
});
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2-way: identical content, live carries indent:0 -> 0 ops, no growth', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||||
|
);
|
||||||
|
const { frag: incoming } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments(live, incoming);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT regress real edits: a git change to one block still lands', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||||
|
);
|
||||||
|
const base = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||||
|
).frag;
|
||||||
|
// git edits the heading text of the LAST unit.
|
||||||
|
const incoming = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) =>
|
||||||
|
unit(false, i, i === UNITS - 1 ? 'EDITED Heading' : 'Big Heading'),
|
||||||
|
),
|
||||||
|
).frag;
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
mergeXmlFragments3Way(live, incoming, base);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit landed, and the body did NOT grow (one block changed in place).
|
||||||
|
const headings = live
|
||||||
|
.toArray()
|
||||||
|
.filter((b) => (b as Y.XmlElement).nodeName === 'heading')
|
||||||
|
.map((b) =>
|
||||||
|
(b as Y.XmlElement)
|
||||||
|
.toArray()
|
||||||
|
.map((c) => (c as Y.XmlText).toString())
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
expect(headings).toContain('EDITED Heading');
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { tiptapExtensions } from '../collaboration.util';
|
||||||
|
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the BUG CLASS behind the runaway whole-body duplication: the
|
||||||
|
* point-fix (7a7b840e) only normalized `indent: 0`, but the SAME divergence
|
||||||
|
* recurs for every attribute whose editor-ext (server) schema default the live
|
||||||
|
* Yjs doc MATERIALIZES while the git round-trip — which comes through the engine
|
||||||
|
* schema (different, usually null, defaults) plus `y-prosemirror`'s null-attr
|
||||||
|
* dropping — does NOT carry. Confirmed triggers beyond `indent`:
|
||||||
|
*
|
||||||
|
* - `image.align` : editor-ext default "center" (materialized) vs engine
|
||||||
|
* default null (dropped) -> element-attr divergence.
|
||||||
|
* - link mark `internal`: editor-ext default false (materialized) vs engine
|
||||||
|
* default null -> MARK-attr divergence (the prior denylist
|
||||||
|
* could not reach marks at all — they are serialized raw in
|
||||||
|
* the XmlText delta).
|
||||||
|
*
|
||||||
|
* `highlight.colorName` is normalized too (defense-in-depth); it is NOT a strong
|
||||||
|
* real-world trigger because BOTH schemas default it to null, but the schema-
|
||||||
|
* derived normalization handles it for free and stays idempotent.
|
||||||
|
*
|
||||||
|
* The fix derives the defaults from the ACTUAL ProseMirror schema (getSchema of
|
||||||
|
* the server tiptapExtensions) and drops any element- OR mark-attribute equal to
|
||||||
|
* its schema default (or null/undefined) from the block comparison key — so a
|
||||||
|
* live block compares equal to its git-round-tripped twin and an unchanged
|
||||||
|
* resync applies 0 ops. RED before the fix (keys diverge -> ops > 0 / growth),
|
||||||
|
* GREEN after.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Attrs = Record<string, unknown>;
|
||||||
|
|
||||||
|
function el(
|
||||||
|
name: string,
|
||||||
|
attrs: Attrs,
|
||||||
|
children: (Y.XmlElement | Y.XmlText)[],
|
||||||
|
): Y.XmlElement {
|
||||||
|
const e = new Y.XmlElement(name);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||||
|
if (children.length) e.insert(0, children);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text carrying marks, as the live Yjs doc stores them (XmlText format ops). */
|
||||||
|
function markedText(s: string, marks: Record<string, unknown>): Y.XmlText {
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
t.insert(0, s, marks);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One byte-identical RICH unit: a paragraph with a LINK, a top-level IMAGE, and
|
||||||
|
* a paragraph with a HIGHLIGHT. `live` toggles exactly what the editor
|
||||||
|
* materializes but a git round-trip does not: block `id`, `indent: 0`,
|
||||||
|
* `image.align: "center"`, the link mark's `internal: false`, and the
|
||||||
|
* highlight's `colorName: null`.
|
||||||
|
*/
|
||||||
|
function richUnit(live: boolean, n: number): Y.XmlElement[] {
|
||||||
|
const ind: Attrs = live ? { indent: 0 } : {};
|
||||||
|
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||||
|
|
||||||
|
const linkMarks = live
|
||||||
|
? {
|
||||||
|
link: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
class: null,
|
||||||
|
title: null,
|
||||||
|
internal: false, // editor-ext default, materialized
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
link: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
internal: null, // engine default
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hlMarks = live
|
||||||
|
? { highlight: { color: '#ffd43b', colorName: null } }
|
||||||
|
: { highlight: { color: '#ffd43b' } };
|
||||||
|
|
||||||
|
const imageAttrs: Attrs = live
|
||||||
|
? { src: 'https://img.example.com/a.png', align: 'center' } // materialized
|
||||||
|
: { src: 'https://img.example.com/a.png' }; // align:null dropped on git side
|
||||||
|
|
||||||
|
return [
|
||||||
|
el('paragraph', { ...id('lp'), ...ind }, [
|
||||||
|
markedText('click here', linkMarks),
|
||||||
|
]),
|
||||||
|
el('image', imageAttrs, []),
|
||||||
|
el('paragraph', { ...id('hp'), ...ind }, [markedText('hot', hlMarks)]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fragmentOf(units: Y.XmlElement[][]): {
|
||||||
|
doc: Y.Doc;
|
||||||
|
frag: Y.XmlFragment;
|
||||||
|
} {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const frag = doc.getXmlFragment('default');
|
||||||
|
const blocks = units.flat();
|
||||||
|
if (blocks.length) frag.insert(0, blocks);
|
||||||
|
return { doc, frag };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||||
|
|
||||||
|
describe('git-sync reconcile is idempotent for schema-default attrs (image/link/highlight)', () => {
|
||||||
|
const UNITS = 3;
|
||||||
|
|
||||||
|
it('3-way: live carries image.align/link.internal/indent defaults, base stale-by-one -> 0 ops', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||||
|
);
|
||||||
|
const { frag: incoming } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||||
|
);
|
||||||
|
const { frag: base } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2-way: live carries the materialized defaults -> 0 ops, no growth', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||||
|
);
|
||||||
|
const { frag: incoming } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments(live, incoming);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a fixpoint across repeated cycles (does not grow)', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||||
|
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||||
|
);
|
||||||
|
const incoming = () =>
|
||||||
|
fragmentOf(Array.from({ length: UNITS }, (_, i) => richUnit(false, i)))
|
||||||
|
.frag;
|
||||||
|
const base = () =>
|
||||||
|
fragmentOf(
|
||||||
|
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||||
|
).frag;
|
||||||
|
|
||||||
|
const before = blockCount(live);
|
||||||
|
for (let cycle = 0; cycle < 5; cycle++) {
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(live, incoming(), base());
|
||||||
|
});
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(blockCount(live)).toBe(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT regress a genuine non-default value (a real link.href / image.align:left still diffs)', () => {
|
||||||
|
const { doc: liveDoc, frag: live } = fragmentOf([richUnit(true, 0)]);
|
||||||
|
const base = fragmentOf([richUnit(false, 0)]).frag;
|
||||||
|
// git genuinely changes the image alignment to a NON-default value.
|
||||||
|
const incomingUnit = richUnit(false, 0);
|
||||||
|
(incomingUnit[1] as Y.XmlElement).setAttribute('align', 'left');
|
||||||
|
const incoming = fragmentOf([incomingUnit]).frag;
|
||||||
|
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
mergeXmlFragments3Way(live, incoming, base);
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = live
|
||||||
|
.toArray()
|
||||||
|
.find((b) => (b as Y.XmlElement).nodeName === 'image') as Y.XmlElement;
|
||||||
|
expect(img.getAttribute('align')).toBe('left');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FAITHFUL end-to-end proof through the REAL server transformer: build the live
|
||||||
|
* doc the way the collaboration server does (defaults omitted in the JSON ->
|
||||||
|
* TiptapTransformer.toYdoc MATERIALIZES image.align:"center", link.internal:false,
|
||||||
|
* indent:0) versus the git-derived doc (engine-style: defaults emitted as
|
||||||
|
* explicit null, no block ids). An unchanged resync must apply 0 ops.
|
||||||
|
*/
|
||||||
|
describe('git-sync reconcile is idempotent through the real toYdoc materialization', () => {
|
||||||
|
const liveContent = [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { id: 'p1' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'click here',
|
||||||
|
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'image', attrs: { src: 'https://img.example.com/a.png' } },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: { id: 'p2' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hot',
|
||||||
|
marks: [{ type: 'highlight', attrs: { color: '#ffd43b' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// git/engine-style: explicit nulls for the engine-default attrs, no ids.
|
||||||
|
const gitContent = [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'click here',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
class: null,
|
||||||
|
title: null,
|
||||||
|
internal: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: { src: 'https://img.example.com/a.png', align: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hot',
|
||||||
|
marks: [
|
||||||
|
{ type: 'highlight', attrs: { color: '#ffd43b', colorName: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const toYdoc = (content: unknown[]) =>
|
||||||
|
TiptapTransformer.toYdoc(
|
||||||
|
{ type: 'doc', content },
|
||||||
|
'default',
|
||||||
|
tiptapExtensions as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('3-way: materialized-default live vs engine-style git, base stale-by-one -> 0 ops', () => {
|
||||||
|
const liveDoc = toYdoc(liveContent);
|
||||||
|
const targetDoc = toYdoc(gitContent);
|
||||||
|
const baseDoc = toYdoc(gitContent.slice(0, gitContent.length - 1));
|
||||||
|
|
||||||
|
const live = liveDoc.getXmlFragment('default');
|
||||||
|
const before = live.toArray().length;
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(
|
||||||
|
live,
|
||||||
|
targetDoc.getXmlFragment('default'),
|
||||||
|
baseDoc.getXmlFragment('default'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(live.toArray().length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2-way: materialized-default live vs engine-style git -> 0 ops', () => {
|
||||||
|
const liveDoc = toYdoc(liveContent);
|
||||||
|
const targetDoc = toYdoc(gitContent);
|
||||||
|
|
||||||
|
const live = liveDoc.getXmlFragment('default');
|
||||||
|
const before = live.toArray().length;
|
||||||
|
let applied = -1;
|
||||||
|
liveDoc.transact(() => {
|
||||||
|
applied = mergeXmlFragments(live, targetDoc.getXmlFragment('default'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
expect(live.toArray().length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mergeXmlFragments,
|
||||||
|
mergeXmlFragments3Way,
|
||||||
|
mergeXmlFragments3WayWithStats,
|
||||||
|
cloneXmlNode,
|
||||||
|
diffBlocks,
|
||||||
|
} from './yjs-body-merge';
|
||||||
|
|
||||||
|
// Build a Y.XmlFragment('default') in `doc` from a list of paragraph specs.
|
||||||
|
// Each spec is the paragraph's plain text (a single XmlText child).
|
||||||
|
function buildFragment(doc: Y.Doc, paragraphs: string[]): Y.XmlFragment {
|
||||||
|
const frag = doc.getXmlFragment('default');
|
||||||
|
const blocks = paragraphs.map((text) => {
|
||||||
|
const el = new Y.XmlElement('paragraph');
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
if (text) t.insert(0, text);
|
||||||
|
el.insert(0, [t]);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
if (blocks.length) frag.insert(0, blocks);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(frag: Y.XmlFragment): string[] {
|
||||||
|
return frag.toArray().map((el) => (el as Y.XmlElement).toArray()
|
||||||
|
.map((c) => (c as Y.XmlText).toString())
|
||||||
|
.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('yjs-body-merge', () => {
|
||||||
|
describe('diffBlocks (LCS edit script)', () => {
|
||||||
|
it('identical sequences produce only keeps (no edits)', () => {
|
||||||
|
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'b', 'c']);
|
||||||
|
expect(ops.every((o) => o.op === 'keep')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single changed middle element is one del + one ins', () => {
|
||||||
|
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'B', 'c']);
|
||||||
|
expect(ops.filter((o) => o.op === 'del')).toHaveLength(1);
|
||||||
|
expect(ops.filter((o) => o.op === 'ins')).toHaveLength(1);
|
||||||
|
expect(ops.filter((o) => o.op === 'keep')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeXmlFragments', () => {
|
||||||
|
it('identical content is a complete no-op (0 ops) — never clobbers an unchanged resync', () => {
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const liveFrag = buildFragment(live, ['one', 'two', 'three']);
|
||||||
|
const targetFrag = buildFragment(target, ['one', 'two', 'three']);
|
||||||
|
|
||||||
|
// Capture block identities to prove they are left untouched.
|
||||||
|
const before = liveFrag.toArray();
|
||||||
|
let applied = -1;
|
||||||
|
live.transact(() => {
|
||||||
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
// Same Y.XmlElement instances — nothing was deleted/recreated.
|
||||||
|
expect(liveFrag.toArray()).toEqual(before);
|
||||||
|
expect(texts(liveFrag)).toEqual(['one', 'two', 'three']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a human edit to one block survives a git change to a DIFFERENT block', () => {
|
||||||
|
// Live: the human has the doc open; block 0 holds their edit. Git changed
|
||||||
|
// only block 2. The merge must touch ONLY block 2 and leave block 0 (and
|
||||||
|
// its in-flight edit) exactly as-is.
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const liveFrag = buildFragment(live, ['HUMAN EDIT', 'shared', 'old tail']);
|
||||||
|
const targetFrag = buildFragment(target, [
|
||||||
|
'HUMAN EDIT',
|
||||||
|
'shared',
|
||||||
|
'new tail from git',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const block0Before = liveFrag.get(0); // the human's block instance
|
||||||
|
const block1Before = liveFrag.get(1);
|
||||||
|
|
||||||
|
let applied = -1;
|
||||||
|
live.transact(() => {
|
||||||
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only block 2 was replaced: one del + one ins.
|
||||||
|
expect(applied).toBe(2);
|
||||||
|
// The human's block and the shared block are the SAME instances (untouched).
|
||||||
|
expect(liveFrag.get(0)).toBe(block0Before);
|
||||||
|
expect(liveFrag.get(1)).toBe(block1Before);
|
||||||
|
// Block 2 now carries git's content.
|
||||||
|
expect(texts(liveFrag)).toEqual([
|
||||||
|
'HUMAN EDIT',
|
||||||
|
'shared',
|
||||||
|
'new tail from git',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a new trailing block without disturbing existing ones', () => {
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'b', 'c']);
|
||||||
|
const a = liveFrag.get(0);
|
||||||
|
const b = liveFrag.get(1);
|
||||||
|
|
||||||
|
let applied = -1;
|
||||||
|
live.transact(() => {
|
||||||
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(1); // single insert
|
||||||
|
expect(liveFrag.get(0)).toBe(a);
|
||||||
|
expect(liveFrag.get(1)).toBe(b);
|
||||||
|
expect(texts(liveFrag)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a removed block, keeping its neighbours', () => {
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const liveFrag = buildFragment(live, ['a', 'b', 'c']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'c']);
|
||||||
|
const a = liveFrag.get(0);
|
||||||
|
|
||||||
|
let applied = -1;
|
||||||
|
live.transact(() => {
|
||||||
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applied).toBe(1); // single delete
|
||||||
|
expect(liveFrag.get(0)).toBe(a);
|
||||||
|
expect(texts(liveFrag)).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a fully different body is replaced (and stays valid)', () => {
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const liveFrag = buildFragment(live, ['x', 'y']);
|
||||||
|
const targetFrag = buildFragment(target, ['p', 'q', 'r']);
|
||||||
|
live.transact(() => mergeXmlFragments(liveFrag, targetFrag));
|
||||||
|
expect(texts(liveFrag)).toEqual(['p', 'q', 'r']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeXmlFragments3Way', () => {
|
||||||
|
it('keeps a human edit to one block while applying a git change to another (3-way)', () => {
|
||||||
|
// base (last synced): [a, b, c]. Human edited block 0 in the live doc; git
|
||||||
|
// changed block 2 in the incoming file. 3-way must keep BOTH — the 2-way
|
||||||
|
// merge would instead revert the human's block 0 to git's stale version.
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||||
|
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||||
|
|
||||||
|
const humanBlock = liveFrag.get(0); // the human's live instance
|
||||||
|
live.transact(() =>
|
||||||
|
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Human's block preserved as the SAME instance; git's change applied.
|
||||||
|
expect(liveFrag.get(0)).toBe(humanBlock);
|
||||||
|
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a block both sides changed resolves to git (conflict policy)', () => {
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||||
|
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||||
|
|
||||||
|
live.transact(() =>
|
||||||
|
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||||
|
);
|
||||||
|
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bug #2 observability: the stats variant reports the same-block conflict so
|
||||||
|
// the handler can log it + the persistence layer can pin the human baseline.
|
||||||
|
it('reports the same-block conflict count via mergeXmlFragments3WayWithStats', () => {
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||||
|
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||||
|
|
||||||
|
let result!: { applied: number; conflicts: number };
|
||||||
|
live.transact(() => {
|
||||||
|
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||||
|
});
|
||||||
|
expect(result.conflicts).toBe(1);
|
||||||
|
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports 0 conflicts for a clean different-block 3-way merge', () => {
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||||
|
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||||
|
|
||||||
|
let result!: { applied: number; conflicts: number };
|
||||||
|
live.transact(() => {
|
||||||
|
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||||
|
});
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('git change with no concurrent human edit (live == base) applies cleanly', () => {
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const target = new Y.Doc();
|
||||||
|
const baseFrag = buildFragment(base, ['a', 'b']);
|
||||||
|
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||||
|
const targetFrag = buildFragment(target, ['a', 'B2']);
|
||||||
|
|
||||||
|
live.transact(() =>
|
||||||
|
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||||
|
);
|
||||||
|
expect(texts(liveFrag)).toEqual(['a', 'B2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: start-of-document content duplicating on every two-way sync.
|
||||||
|
//
|
||||||
|
// The LIVE Docmost doc stamps a per-block UniqueID on every heading/paragraph;
|
||||||
|
// a body arriving FROM git is parsed from clean markdown and carries NO block
|
||||||
|
// ids. If the merge comparison key includes that `id`, an unchanged live block
|
||||||
|
// never matches the SAME block coming from git, so the three-way merge cannot
|
||||||
|
// anchor on it — and an incoming block with no anchor (content inserted at the
|
||||||
|
// TOP of the page) is RE-ADDED on every cycle, an unbounded duplication loop.
|
||||||
|
// These tests model that exact id-asymmetry and assert the reconciliation is
|
||||||
|
// IDEMPOTENT (no block growth). They are RED before excluding `id` from the
|
||||||
|
// key in `serializeXmlNode`.
|
||||||
|
describe('idempotent reconciliation with live block ids (start-of-doc dup)', () => {
|
||||||
|
// Build a fragment from block specs. `id` is set only when provided, mirroring
|
||||||
|
// the live doc (ids present) vs a git-parsed body (ids absent).
|
||||||
|
type Spec = { tag: 'heading' | 'paragraph'; text: string; id?: string };
|
||||||
|
function buildDoc(doc: Y.Doc, specs: Spec[]): Y.XmlFragment {
|
||||||
|
const frag = doc.getXmlFragment('default');
|
||||||
|
const blocks = specs.map((s) => {
|
||||||
|
const el = new Y.XmlElement(s.tag);
|
||||||
|
if (s.id) el.setAttribute('id', s.id);
|
||||||
|
if (s.tag === 'heading') el.setAttribute('level', '2');
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
if (s.text) t.insert(0, s.text);
|
||||||
|
el.insert(0, [t]);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
if (blocks.length) frag.insert(0, blocks);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
const textsOf = (frag: Y.XmlFragment): string[] =>
|
||||||
|
frag.toArray().map((el) =>
|
||||||
|
(el as Y.XmlElement)
|
||||||
|
.toArray()
|
||||||
|
.map((c) => (c as Y.XmlText).toString())
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('re-merging the SAME git body does NOT re-add the top block (idempotent)', () => {
|
||||||
|
// last-synced base (from git markdown): NO block ids.
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const baseFrag = buildDoc(base, [
|
||||||
|
{ tag: 'heading', text: 'Title' },
|
||||||
|
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||||
|
{ tag: 'paragraph', text: 'End block.' },
|
||||||
|
]);
|
||||||
|
// live Docmost doc: SAME content, but every block carries a UniqueID.
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const liveFrag = buildDoc(live, [
|
||||||
|
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||||
|
{ tag: 'paragraph', text: 'Some paragraph.', id: 'idb' },
|
||||||
|
{ tag: 'paragraph', text: 'End block.', id: 'idc' },
|
||||||
|
]);
|
||||||
|
// incoming git body: the user inserted a heading at the very TOP.
|
||||||
|
const buildTarget = (): Y.XmlFragment =>
|
||||||
|
buildDoc(new Y.Doc(), [
|
||||||
|
{ tag: 'heading', text: 'TOPDUP' },
|
||||||
|
{ tag: 'heading', text: 'Title' },
|
||||||
|
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||||
|
{ tag: 'paragraph', text: 'End block.' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First sync: the top block is added once.
|
||||||
|
live.transact(() =>
|
||||||
|
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||||
|
);
|
||||||
|
expect(textsOf(liveFrag)).toEqual([
|
||||||
|
'TOPDUP',
|
||||||
|
'Title',
|
||||||
|
'Some paragraph.',
|
||||||
|
'End block.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Subsequent sync of the SAME git body against the SAME base must be a
|
||||||
|
// NO-OP — not a second copy of the top block. Before the fix this re-adds
|
||||||
|
// 'TOPDUP', growing the doc on every cycle.
|
||||||
|
live.transact(() =>
|
||||||
|
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||||
|
);
|
||||||
|
expect(textsOf(liveFrag)).toEqual([
|
||||||
|
'TOPDUP',
|
||||||
|
'Title',
|
||||||
|
'Some paragraph.',
|
||||||
|
'End block.',
|
||||||
|
]);
|
||||||
|
expect(textsOf(liveFrag).filter((t) => t === 'TOPDUP')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unchanged git body (live ids, none in git) is a complete no-op', () => {
|
||||||
|
// base == git body (no pending git change); live is the same content with
|
||||||
|
// ids. With `id` in the key the whole body looks rewritten; the merge must
|
||||||
|
// still leave live byte-identical (block instances untouched).
|
||||||
|
const base = new Y.Doc();
|
||||||
|
const baseFrag = buildDoc(base, [
|
||||||
|
{ tag: 'heading', text: 'Title' },
|
||||||
|
{ tag: 'paragraph', text: 'Body.' },
|
||||||
|
]);
|
||||||
|
const live = new Y.Doc();
|
||||||
|
const liveFrag = buildDoc(live, [
|
||||||
|
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||||
|
{ tag: 'paragraph', text: 'Body.', id: 'idb' },
|
||||||
|
]);
|
||||||
|
const before = liveFrag.toArray();
|
||||||
|
let applied = -1;
|
||||||
|
live.transact(() => {
|
||||||
|
applied = mergeXmlFragments3Way(
|
||||||
|
liveFrag,
|
||||||
|
buildDoc(new Y.Doc(), [
|
||||||
|
{ tag: 'heading', text: 'Title' },
|
||||||
|
{ tag: 'paragraph', text: 'Body.' },
|
||||||
|
]),
|
||||||
|
baseFrag,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(applied).toBe(0);
|
||||||
|
// Same live block instances (ids preserved) — nothing recreated.
|
||||||
|
expect(liveFrag.toArray()).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cloneXmlNode', () => {
|
||||||
|
it('preserves text marks (XmlText delta) across docs', () => {
|
||||||
|
const src = new Y.Doc();
|
||||||
|
const srcFrag = src.getXmlFragment('default');
|
||||||
|
const el = new Y.XmlElement('paragraph');
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
t.insert(0, 'plain ');
|
||||||
|
t.insert(6, 'bold', { bold: true });
|
||||||
|
el.insert(0, [t]);
|
||||||
|
srcFrag.insert(0, [el]);
|
||||||
|
|
||||||
|
const dst = new Y.Doc();
|
||||||
|
const dstFrag = dst.getXmlFragment('default');
|
||||||
|
dstFrag.insert(0, [cloneXmlNode(srcFrag.get(0) as Y.XmlElement)]);
|
||||||
|
|
||||||
|
const clonedText = (dstFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText;
|
||||||
|
expect(clonedText.toDelta()).toEqual([
|
||||||
|
{ insert: 'plain ' },
|
||||||
|
{ insert: 'bold', attributes: { bold: true } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { getSchema } from '@tiptap/core';
|
||||||
|
import type { Schema } from '@tiptap/pm/model';
|
||||||
|
|
||||||
|
import { tiptapExtensions } from '../collaboration.util';
|
||||||
|
import { diff3PlanWithConflicts } from './three-way-merge';
|
||||||
|
import { buildLcsTable } from './lcs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block-level merge of an incoming (git) page body into a LIVE Yjs document,
|
||||||
|
* replacing the previous full-body "delete everything + re-insert" write that
|
||||||
|
* clobbered concurrent human edits on every sync (review #5 — "do the write as a
|
||||||
|
* merge").
|
||||||
|
*
|
||||||
|
* Strategy: diff the two documents at TOP-LEVEL BLOCK granularity (an LCS over a
|
||||||
|
* canonical structural serialization of each block) and apply only the minimal
|
||||||
|
* insert/delete operations. Blocks that are byte-identical on both sides are
|
||||||
|
* left UNTOUCHED in the live doc — so a human editing one paragraph is unaffected
|
||||||
|
* when git changes a different paragraph, and an unchanged re-sync is a complete
|
||||||
|
* no-op (zero Yjs operations). Yjs then CRDT-merges the minimal ops with any
|
||||||
|
* concurrent edits.
|
||||||
|
*
|
||||||
|
* Merge mode: a THREE-WAY merge (live vs incoming vs base) runs whenever the
|
||||||
|
* engine plumbs the last-synced base (`baseMarkdown` from refs/docmost/last-pushed)
|
||||||
|
* — which it now does end-to-end — so a block both sides changed is a genuine
|
||||||
|
* conflict resolved deterministically (git wins that block; the prior state is
|
||||||
|
* preserved in page history). Only when NO base is available (a brand-new file)
|
||||||
|
* does it fall back to a 2-way merge (live vs incoming). Common cases — unchanged
|
||||||
|
* resync and edits to DIFFERENT blocks — are lossless in both modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node attributes that are VOLATILE identity (not content) and so must be
|
||||||
|
* excluded from the block comparison key.
|
||||||
|
*
|
||||||
|
* `id` is the per-block UniqueID the editor stamps on every heading/paragraph
|
||||||
|
* (and transclusionSource). It exists ONLY in the live Yjs document — a body
|
||||||
|
* arriving from git is parsed from clean markdown, which carries no block ids
|
||||||
|
* (`markdownToProseMirror` materializes `id: null`, which the Yjs transform then
|
||||||
|
* drops). If `id` were part of the key, an UNCHANGED live block (id "abc123")
|
||||||
|
* would never match the SAME block coming from git (no id), so the three-way
|
||||||
|
* merge's LCS could not anchor on it. The merge would then treat every live
|
||||||
|
* block as deleted-and-reinserted and, when an incoming block has no matching
|
||||||
|
* anchor (e.g. content inserted at the very TOP of the page), RE-ADD a copy of
|
||||||
|
* it on every sync cycle — a non-convergent, unbounded duplication loop
|
||||||
|
* (start-of-document content duplicating each push/pull cycle).
|
||||||
|
*
|
||||||
|
* Excluding `id` makes blocks compare by CONTENT, so an unchanged block matches
|
||||||
|
* across the git round-trip and the reconciliation is idempotent. Block identity
|
||||||
|
* is still preserved in the merged output: `diff3Plan` keeps the LIVE block
|
||||||
|
* INSTANCE (with its id) for an anchor — picks are by index, not by key — so the
|
||||||
|
* stable Yjs block (and any in-flight human edit on it) stays put. This mirrors
|
||||||
|
* `canonicalize.ts`, which already strips the regenerated block `id` from the
|
||||||
|
* round-trip idempotency comparison for exactly the same reason.
|
||||||
|
*
|
||||||
|
* Known limitation (accepted trade-off of content-based matching): two GENUINELY
|
||||||
|
* DISTINCT blocks whose content is byte-identical now collapse to the same content
|
||||||
|
* key, so when git deletes one of the duplicates the LCS may drop the OTHER live
|
||||||
|
* instance instead. The visible result is identical (one copy removed, one kept),
|
||||||
|
* but a concurrent in-flight human edit on the dropped instance could be lost.
|
||||||
|
*/
|
||||||
|
const VOLATILE_KEY_ATTRS = new Set(['id']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor (ProseMirror) schema, built ONCE from the same `tiptapExtensions`
|
||||||
|
* the collaboration server uses to materialize Yjs docs. Memoized: building the
|
||||||
|
* schema is non-trivial and the block key is computed per block per cycle.
|
||||||
|
*
|
||||||
|
* Why the schema (not a hardcoded denylist): the LIVE Yjs document is produced by
|
||||||
|
* `TiptapTransformer.toYdoc(pm, 'default', tiptapExtensions)`, which STAMPS every
|
||||||
|
* schema-default attribute onto every node and mark — `indent: 0` on every
|
||||||
|
* paragraph/heading, `image.align: "center"`, the link mark's `internal: false`,
|
||||||
|
* `highlight.colorName: null`, and so on for youtube/pdf/any future node. A body
|
||||||
|
* re-imported from git comes through the engine's `markdownToProseMirror`, whose
|
||||||
|
* schema declares those attrs with DIFFERENT (usually null) defaults; the
|
||||||
|
* resulting null/absent element attrs are then DROPPED by `y-prosemirror`'s
|
||||||
|
* toYdoc. So the SAME block carries materialized defaults on the live side and
|
||||||
|
* nothing on the git side, its key diverges, the three-way merge anchors on
|
||||||
|
* NOTHING, and the whole body is RE-APPENDED every reconcile cycle — an unbounded
|
||||||
|
* duplication loop with no client connected.
|
||||||
|
*
|
||||||
|
* Deriving the defaults from the actual schema normalizes ALL such attributes
|
||||||
|
* generally (it is not another per-attribute denylist): any attribute whose value
|
||||||
|
* equals the schema default — or is null/undefined — is dropped from the key, on
|
||||||
|
* BOTH element attributes and the mark attributes inside each XmlText delta, so a
|
||||||
|
* live block compares equal to its git-round-tripped twin and an unchanged resync
|
||||||
|
* applies zero ops. Genuinely non-default values (a real `indent: 2`, an
|
||||||
|
* `align: "left"`, a real `link.href`, a real highlight color) are content and
|
||||||
|
* stay in the key, so real edits still diff and land.
|
||||||
|
*/
|
||||||
|
let memoSchema: Schema | null = null;
|
||||||
|
let memoSchemaTried = false;
|
||||||
|
function getMergeSchema(): Schema | null {
|
||||||
|
if (!memoSchemaTried) {
|
||||||
|
memoSchemaTried = true;
|
||||||
|
try {
|
||||||
|
memoSchema = getSchema(tiptapExtensions as any);
|
||||||
|
} catch {
|
||||||
|
// Defensive: if the schema can't be built (e.g. a degenerate extension
|
||||||
|
// set in a unit test that stubs `tiptapExtensions`), fall back to dropping
|
||||||
|
// only null/undefined attrs. The real server always builds it fine.
|
||||||
|
memoSchema = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memoSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if `value` is the schema default for `attrName` of `attrSpecs`, or is
|
||||||
|
* null/undefined (which a git round-trip drops). Such attributes are excluded
|
||||||
|
* from the comparison key. `attrSpecs` is a ProseMirror node/mark spec attr map
|
||||||
|
* (`{ [name]: { default } }`); a missing map (unknown node/mark) only drops
|
||||||
|
* null/undefined. (A non-null value matching an attr declared without a default
|
||||||
|
* cannot occur — `spec.default === value` is then `undefined === value`, false.) */
|
||||||
|
function isDefaultAttr(
|
||||||
|
attrSpecs: Record<string, any> | undefined | null,
|
||||||
|
attrName: string,
|
||||||
|
value: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
const spec = attrSpecs?.[attrName];
|
||||||
|
return !!spec && spec.default === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one XmlText delta op's mark attributes: drop every mark-attr whose
|
||||||
|
* value equals the mark's schema default (or is null/undefined), so the link
|
||||||
|
* mark's materialized `internal: false`/`target: "_blank"` and a highlight's
|
||||||
|
* `colorName: null` no longer diverge from a git round-trip that carries neither.
|
||||||
|
* The text (op.insert) and genuinely-set mark attrs (a real `href`, a real
|
||||||
|
* highlight color) are preserved verbatim. `attributes` maps markName -> mark
|
||||||
|
* attrs object (or `true`/boolean for attr-less marks); each is handled safely.
|
||||||
|
*/
|
||||||
|
function normalizeDelta(delta: any[]): any[] {
|
||||||
|
const schema = getMergeSchema();
|
||||||
|
return delta.map((op) => {
|
||||||
|
if (!op || op.attributes == null || typeof op.attributes !== 'object') {
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
const marks: Record<string, unknown> = {};
|
||||||
|
for (const markName of Object.keys(op.attributes).sort()) {
|
||||||
|
const markVal = op.attributes[markName];
|
||||||
|
if (markVal === null || markVal === undefined) continue;
|
||||||
|
if (typeof markVal !== 'object') {
|
||||||
|
// attr-less mark stored as a primitive (e.g. `true`) — keep as-is.
|
||||||
|
marks[markName] = markVal;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const markSpec = schema?.marks[markName]?.spec.attrs as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
const cleaned: Record<string, unknown> = {};
|
||||||
|
for (const ak of Object.keys(markVal as object).sort()) {
|
||||||
|
const av = (markVal as Record<string, unknown>)[ak];
|
||||||
|
if (isDefaultAttr(markSpec, ak, av)) continue;
|
||||||
|
cleaned[ak] = av;
|
||||||
|
}
|
||||||
|
marks[markName] = cleaned;
|
||||||
|
}
|
||||||
|
return { ...op, attributes: marks };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical, comparable serialization of a Yjs XML node (structure + text +
|
||||||
|
* marks + attributes), with attribute keys sorted so equal blocks always produce
|
||||||
|
* an identical string regardless of attribute insertion order. The volatile
|
||||||
|
* block `id` (see `VOLATILE_KEY_ATTRS`) and every schema-default attribute (see
|
||||||
|
* `getMergeSchema`) are excluded at every level — on element attributes AND on
|
||||||
|
* the mark attributes inside each XmlText delta — so a block compares equal by
|
||||||
|
* CONTENT across the git round-trip (which materializes neither), keeping the
|
||||||
|
* merge anchor-able and idempotent.
|
||||||
|
*/
|
||||||
|
export function serializeXmlNode(node: unknown): unknown {
|
||||||
|
if (node instanceof Y.XmlText) {
|
||||||
|
return { t: normalizeDelta(node.toDelta()) };
|
||||||
|
}
|
||||||
|
if (node instanceof Y.XmlElement) {
|
||||||
|
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||||
|
const attrSpecs = getMergeSchema()?.nodes[node.nodeName]?.spec.attrs as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const k of Object.keys(attrs).sort()) {
|
||||||
|
if (VOLATILE_KEY_ATTRS.has(k)) continue;
|
||||||
|
if (isDefaultAttr(attrSpecs, k, attrs[k])) continue;
|
||||||
|
sorted[k] = attrs[k];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
n: node.nodeName,
|
||||||
|
a: sorted,
|
||||||
|
c: node.toArray().map(serializeXmlNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// XmlHook / unknown: fall back to a stable string so it compares by identity
|
||||||
|
// of its serialized form (these do not occur in the Docmost block schema).
|
||||||
|
return { u: String(node) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (node: unknown): string => JSON.stringify(serializeXmlNode(node));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-clone a detached/owned Yjs XML node into a fresh node that can be inserted
|
||||||
|
* into ANOTHER document (Yjs types are bound to their doc, so cross-doc moves are
|
||||||
|
* impossible — we rebuild). Preserves nodeName, attributes, text+marks (via the
|
||||||
|
* XmlText delta) and the full child subtree.
|
||||||
|
*/
|
||||||
|
export function cloneXmlNode(node: XmlNode): Y.XmlElement | Y.XmlText {
|
||||||
|
if (node instanceof Y.XmlText) {
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
const delta = node.toDelta();
|
||||||
|
if (delta.length) t.applyDelta(delta);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
if (node instanceof Y.XmlElement) {
|
||||||
|
const el = new Y.XmlElement(node.nodeName);
|
||||||
|
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||||
|
for (const k of Object.keys(attrs)) el.setAttribute(k, attrs[k] as string);
|
||||||
|
const kids = node.toArray().map((c) => cloneXmlNode(c as XmlNode));
|
||||||
|
if (kids.length) el.insert(0, kids);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
// Best-effort for any other node type (XmlHook — does not occur in the
|
||||||
|
// Docmost block schema): an empty paragraph so the merge never crashes.
|
||||||
|
return new Y.XmlElement('paragraph');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Op = { op: 'keep' } | { op: 'del' } | { op: 'ins'; bi: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LCS-based edit script turning sequence `a` (live block keys) into `b` (incoming
|
||||||
|
* block keys): a run of keep/del/ins ops. O(n*m) table — fine for page block
|
||||||
|
* counts.
|
||||||
|
*/
|
||||||
|
export function diffBlocks(a: string[], b: string[]): Op[] {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
const dp = buildLcsTable(a, b);
|
||||||
|
const ops: Op[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
ops.push({ op: 'keep' });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
ops.push({ op: 'del' });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
ops.push({ op: 'ins', bi: j });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) {
|
||||||
|
ops.push({ op: 'del' });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (j < m) {
|
||||||
|
ops.push({ op: 'ins', bi: j });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `target` block children into `live`, mutating `live` in place with the
|
||||||
|
* minimal set of inserts/deletes. MUST be called inside a Yjs transaction.
|
||||||
|
* Returns the number of block operations applied (0 == content already identical).
|
||||||
|
*/
|
||||||
|
export function mergeXmlFragments(
|
||||||
|
live: Y.XmlFragment,
|
||||||
|
target: Y.XmlFragment,
|
||||||
|
): number {
|
||||||
|
const liveKids = live.toArray();
|
||||||
|
const targetKids = target.toArray();
|
||||||
|
const liveKeys = liveKids.map(key);
|
||||||
|
const targetKeys = targetKids.map(key);
|
||||||
|
|
||||||
|
const ops = diffBlocks(liveKeys, targetKeys);
|
||||||
|
|
||||||
|
let cursor = 0; // index into the LIVE fragment as we mutate it
|
||||||
|
let applied = 0;
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.op === 'keep') {
|
||||||
|
cursor++;
|
||||||
|
} else if (op.op === 'del') {
|
||||||
|
live.delete(cursor, 1); // remove the live block at the cursor; do not advance
|
||||||
|
applied++;
|
||||||
|
} else {
|
||||||
|
live.insert(cursor, [cloneXmlNode(targetKids[op.bi] as XmlNode)]);
|
||||||
|
cursor++;
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a 3-way block merge: ops applied + same-block conflict count. */
|
||||||
|
export interface Merge3WayResult {
|
||||||
|
/** Number of block insert/delete operations spliced into `live`. */
|
||||||
|
applied: number;
|
||||||
|
/**
|
||||||
|
* Regions where the human AND git rewrote the SAME base block. The rule is
|
||||||
|
* deterministic (GIT WINS the region), so the human's version of those blocks
|
||||||
|
* is dropped from the live doc. `conflicts > 0` is the OBSERVABLE signal the
|
||||||
|
* caller uses to LOG the loss and pin the human baseline to page history (so it
|
||||||
|
* is recoverable), instead of the edit vanishing silently.
|
||||||
|
*/
|
||||||
|
conflicts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THREE-WAY block merge: reconcile `live` toward `target` using `base` (the
|
||||||
|
* last-synced common ancestor) so a block only the human changed is KEPT and a
|
||||||
|
* block only git changed is taken — instead of git's version always winning
|
||||||
|
* (review #5). Conflicts (both changed the same block) resolve to git.
|
||||||
|
*
|
||||||
|
* Implementation: diff3Plan computes the merged block ORDER (picks from live or
|
||||||
|
* target); we materialize that as a virtual target fragment and reuse the 2-way
|
||||||
|
* `mergeXmlFragments` to splice it into `live` minimally (so untouched live block
|
||||||
|
* instances — and their in-flight edits — stay put). MUST be called inside a Yjs
|
||||||
|
* transaction. Returns the number of block operations applied. (Use
|
||||||
|
* `mergeXmlFragments3WayWithStats` when the SAME-BLOCK conflict count is needed.)
|
||||||
|
*/
|
||||||
|
export function mergeXmlFragments3Way(
|
||||||
|
live: Y.XmlFragment,
|
||||||
|
target: Y.XmlFragment,
|
||||||
|
base: Y.XmlFragment,
|
||||||
|
): number {
|
||||||
|
return mergeXmlFragments3WayWithStats(live, target, base).applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As `mergeXmlFragments3Way`, but also returns the SAME-BLOCK conflict count so
|
||||||
|
* the caller can make a "git won a concurrent same-block edit" event OBSERVABLE
|
||||||
|
* (the documented conflict contract: git wins deterministically, but the losing
|
||||||
|
* human content is never destroyed silently — it is logged and recoverable via
|
||||||
|
* page history).
|
||||||
|
*/
|
||||||
|
export function mergeXmlFragments3WayWithStats(
|
||||||
|
live: Y.XmlFragment,
|
||||||
|
target: Y.XmlFragment,
|
||||||
|
base: Y.XmlFragment,
|
||||||
|
): Merge3WayResult {
|
||||||
|
const liveKids = live.toArray();
|
||||||
|
const targetKids = target.toArray();
|
||||||
|
const liveKeys = liveKids.map(key);
|
||||||
|
const targetKeys = targetKids.map(key);
|
||||||
|
const baseKeys = base.toArray().map(key);
|
||||||
|
|
||||||
|
const { picks: plan, conflicts } = diff3PlanWithConflicts(
|
||||||
|
baseKeys,
|
||||||
|
liveKeys,
|
||||||
|
targetKeys,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the merged block sequence in a throwaway doc, cloning from whichever
|
||||||
|
// side each pick came from, then 2-way merge it back into the live fragment.
|
||||||
|
const merged = new Y.Doc();
|
||||||
|
const mergedFrag = merged.getXmlFragment('default');
|
||||||
|
const nodes = plan.map((p) =>
|
||||||
|
cloneXmlNode(
|
||||||
|
(p.src === 'live' ? liveKids[p.index] : targetKids[p.index]) as XmlNode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (nodes.length) mergedFrag.insert(0, nodes);
|
||||||
|
|
||||||
|
return { applied: mergeXmlFragments(live, mergedFrag), conflicts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { getSchema } from '@tiptap/core';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { tiptapExtensions } from './collaboration.util';
|
||||||
|
// The vendored git-sync mirror's extension set. Imported via the subpath the
|
||||||
|
// server jest config maps to the package SOURCE (moduleNameMapper
|
||||||
|
// `^@docmost/git-sync/(.*)$`), so this reads the real mirror, not a build.
|
||||||
|
import { docmostExtensions as gitSyncExtensions } from '@docmost/git-sync/lib/docmost-schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ATTRIBUTE-LEVEL SCHEMA CONTRACT (review #293, variant A).
|
||||||
|
*
|
||||||
|
* The document schema exists as three hand-synced copies (editor-ext =
|
||||||
|
* source-of-truth, plus the git-sync and mcp converter mirrors). The existing
|
||||||
|
* `schema-editor-ext-contract.test.ts` compares only node/mark TYPE NAMES, so a
|
||||||
|
* NEW ATTRIBUTE added to an existing node upstream slips through and its value is
|
||||||
|
* silently dropped on every git-sync round trip. That is a repeatedly-hit
|
||||||
|
* data-loss class (image caption #221, paragraph alignment #10, details `open`).
|
||||||
|
*
|
||||||
|
* This test closes the attribute gap MECHANICALLY: it builds the real canonical
|
||||||
|
* schema from the server's `tiptapExtensions` (the same set the collab write path
|
||||||
|
* uses) and the git-sync mirror schema, then asserts that for every node/mark the
|
||||||
|
* two schemas share, their ATTRIBUTE-KEY sets are equal — minus a committed
|
||||||
|
* allowlist of intentional, understood divergences. A forgotten attribute now
|
||||||
|
* fails CI loudly instead of losing data in production.
|
||||||
|
*
|
||||||
|
* WHY THIS ISN'T THE "fragile attribute compare" the sibling name-level contract
|
||||||
|
* (`packages/git-sync/test/schema-editor-ext-contract.test.ts`) deferred: that
|
||||||
|
* concern was about comparing raw extension CONFIGS, where editor-ext spreads
|
||||||
|
* global attributes (textAlign, id, …) across separate extensions and StarterKit
|
||||||
|
* contributes types the mirror gets elsewhere. We instead compare the RESOLVED
|
||||||
|
* ProseMirror `Schema` objects — `getSchema()` has already merged every
|
||||||
|
* addGlobalAttributes spread into concrete per-node attrs on both sides — so the
|
||||||
|
* compare is apples-to-apples (57 shared nodes/marks, only a handful of
|
||||||
|
* documented divergences) rather than config-shape noise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intentional, understood attribute divergences between the canonical schema and
|
||||||
|
* the git-sync mirror. Each entry MUST carry a reason. The test asserts the
|
||||||
|
* allowlist is not stale (every listed attr is actually still divergent), so this
|
||||||
|
* cannot rot into a silent escape hatch.
|
||||||
|
*
|
||||||
|
* Shape: { [nodeOrMarkName]: { canonicalOnly?: string[]; mirrorOnly?: string[] } }
|
||||||
|
*/
|
||||||
|
const ALLOWED_DIVERGENCES: Record<
|
||||||
|
string,
|
||||||
|
{ canonicalOnly?: string[]; mirrorOnly?: string[] }
|
||||||
|
> = {
|
||||||
|
// mirrorOnly: the converter mirror carries `align` on table cells/headers so a
|
||||||
|
// GFM column-alignment marker (:--, :-:, --:) can be reconstructed on export;
|
||||||
|
// editor-ext expresses cell alignment differently. Intentional, round-trip-used.
|
||||||
|
tableCell: { mirrorOnly: ['align'] },
|
||||||
|
tableHeader: { mirrorOnly: ['align'] },
|
||||||
|
// youtube: the mirror adds `align` (media alignment it renders as data-align)
|
||||||
|
// and does NOT carry editor-ext's `start` (video start-time). `start` is a
|
||||||
|
// PRE-EXISTING gap (a youtube embed's start offset is not preserved across a
|
||||||
|
// markdown round trip) — documented here so the contract is green for the known
|
||||||
|
// state and RED for any NEW drift. Follow-up: carry `start` through the mirror.
|
||||||
|
youtube: { mirrorOnly: ['align'], canonicalOnly: ['start'] },
|
||||||
|
// image.title: the mirror carries a `title` attr (used to round-trip the
|
||||||
|
// markdown image title ``) that editor-ext does not declare
|
||||||
|
// on its image node. Mirror-only and round-trip-used, not data loss. Intentional.
|
||||||
|
image: { mirrorOnly: ['title'] },
|
||||||
|
// highlight.colorName (a named-color alias alongside the color value) is a
|
||||||
|
// PRE-EXISTING mirror gap; the color value itself round-trips. Documented.
|
||||||
|
highlight: { canonicalOnly: ['colorName'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function attrKeys(schema: Schema): Map<string, Set<string>> {
|
||||||
|
const out = new Map<string, Set<string>>();
|
||||||
|
for (const [name, type] of Object.entries(schema.nodes)) {
|
||||||
|
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||||
|
}
|
||||||
|
for (const [name, type] of Object.entries(schema.marks)) {
|
||||||
|
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diff(a: Set<string>, b: Set<string>): string[] {
|
||||||
|
return [...a].filter((x) => !b.has(x)).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schema attribute contract: git-sync mirror vs canonical editor-ext', () => {
|
||||||
|
const canonical = attrKeys(getSchema(tiptapExtensions as never));
|
||||||
|
const mirror = attrKeys(getSchema(gitSyncExtensions as never));
|
||||||
|
|
||||||
|
it('builds meaningful schemas (guard against a vacuous pass)', () => {
|
||||||
|
expect(canonical.size).toBeGreaterThan(10);
|
||||||
|
expect(mirror.size).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every shared node/mark has matching attribute keys (modulo the allowlist)', () => {
|
||||||
|
const drift: string[] = [];
|
||||||
|
for (const [name, canonAttrs] of canonical) {
|
||||||
|
const mirrorAttrs = mirror.get(name);
|
||||||
|
if (!mirrorAttrs) continue; // name-level gaps are the other test's job
|
||||||
|
const allow = ALLOWED_DIVERGENCES[name] ?? {};
|
||||||
|
const canonicalOnly = diff(canonAttrs, mirrorAttrs).filter(
|
||||||
|
(k) => !(allow.canonicalOnly ?? []).includes(k),
|
||||||
|
);
|
||||||
|
const mirrorOnly = diff(mirrorAttrs, canonAttrs).filter(
|
||||||
|
(k) => !(allow.mirrorOnly ?? []).includes(k),
|
||||||
|
);
|
||||||
|
if (canonicalOnly.length) {
|
||||||
|
drift.push(
|
||||||
|
`${name}: attrs in editor-ext but MISSING from git-sync mirror ` +
|
||||||
|
`(silently dropped on round trip): ${canonicalOnly.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mirrorOnly.length) {
|
||||||
|
drift.push(
|
||||||
|
`${name}: attrs in git-sync mirror but NOT in editor-ext ` +
|
||||||
|
`(mirror invented an attribute): ${mirrorOnly.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(drift).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the allowlist is not stale (every listed divergence is still real)', () => {
|
||||||
|
const stale: string[] = [];
|
||||||
|
for (const [name, allow] of Object.entries(ALLOWED_DIVERGENCES)) {
|
||||||
|
const canonAttrs = canonical.get(name);
|
||||||
|
const mirrorAttrs = mirror.get(name);
|
||||||
|
if (!canonAttrs || !mirrorAttrs) {
|
||||||
|
stale.push(`${name}: no longer a shared node/mark`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const k of allow.canonicalOnly ?? []) {
|
||||||
|
if (!(canonAttrs.has(k) && !mirrorAttrs.has(k))) {
|
||||||
|
stale.push(`${name}.canonicalOnly '${k}' is no longer divergent`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of allow.mirrorOnly ?? []) {
|
||||||
|
if (!(mirrorAttrs.has(k) && !canonAttrs.has(k))) {
|
||||||
|
stale.push(`${name}.mirrorOnly '${k}' is no longer divergent`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(stale).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setYjsMark,
|
setYjsMark,
|
||||||
removeYjsMarkByAttribute,
|
removeYjsMarkByAttribute,
|
||||||
updateYjsMarkAttribute,
|
updateYjsMarkAttribute,
|
||||||
|
replaceYjsMarkedText,
|
||||||
type YjsSelection,
|
type YjsSelection,
|
||||||
} from './yjs.util';
|
} from './yjs.util';
|
||||||
|
|
||||||
@@ -276,3 +277,256 @@ describe('updateYjsMarkAttribute', () => {
|
|||||||
expect(text.toDelta()).toEqual(before);
|
expect(text.toDelta()).toEqual(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('replaceYjsMarkedText', () => {
|
||||||
|
// Build a single-paragraph XmlText from runs. Insert the whole string as
|
||||||
|
// plain text FIRST, then format only the marked ranges — otherwise text
|
||||||
|
// inserted right after a marked run inherits its comment mark (Yjs carries
|
||||||
|
// formatting from the left insertion boundary).
|
||||||
|
function buildRuns(
|
||||||
|
runs: Array<{
|
||||||
|
text: string;
|
||||||
|
comment?: { commentId: string; resolved: boolean };
|
||||||
|
}>,
|
||||||
|
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, runs.map((r) => r.text).join(''));
|
||||||
|
let offset = 0;
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.comment) {
|
||||||
|
text.format(offset, run.text.length, { comment: run.comment });
|
||||||
|
}
|
||||||
|
offset += run.text.length;
|
||||||
|
}
|
||||||
|
return { fragment, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two paragraphs, each with its own XmlText, both marked with the same
|
||||||
|
// commentId — mirrors a suggestion anchor that got split across blocks.
|
||||||
|
function buildTwoParagraphs(
|
||||||
|
a: { text: string; comment?: { commentId: string; resolved: boolean } },
|
||||||
|
b: { text: string; comment?: { commentId: string; resolved: boolean } },
|
||||||
|
): { fragment: Y.XmlFragment; textA: Y.XmlText; textB: Y.XmlText } {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const build = (seg: typeof a) => {
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, seg.text);
|
||||||
|
if (seg.comment) {
|
||||||
|
text.format(0, seg.text.length, { comment: seg.comment });
|
||||||
|
}
|
||||||
|
return { para, text };
|
||||||
|
};
|
||||||
|
const pa = build(a);
|
||||||
|
const pb = build(b);
|
||||||
|
fragment.insert(0, [pa.para, pb.para]);
|
||||||
|
return { fragment, textA: pa.text, textB: pb.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('happy path: replaces marked text with newText and keeps the comment mark', () => {
|
||||||
|
const { fragment, text } = buildRuns([
|
||||||
|
{ text: 'Hello ' },
|
||||||
|
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: '!' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'planet' });
|
||||||
|
// New text carries the SAME comment mark; surrounding text is untouched.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'Hello ' },
|
||||||
|
{
|
||||||
|
insert: 'planet',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: '!' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches by commentId even when the mark is resolved', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'foo', comment: { commentId: 'c9', resolved: true } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c9', 'foo', 'bar');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'bar' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'bar',
|
||||||
|
attributes: { comment: { commentId: 'c9', resolved: true } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changed text: marked text differs from expected → no-op, doc unchanged', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
]);
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'expected', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'abc' });
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
// F1 regression: the marked doc text is TYPOGRAPHIC (smart quotes / em-dash)
|
||||||
|
// and expectedText equals that raw typographic text — as it now does, because
|
||||||
|
// the MCP client stores the RAW anchored substring (getAnchoredText) rather
|
||||||
|
// than the agent's ASCII input. The strict `joinedText !== expectedText`
|
||||||
|
// compare must therefore MATCH and the suggestion apply (not a spurious 409).
|
||||||
|
it('typographic marked text applies when expectedText is the raw typographic text', () => {
|
||||||
|
const marked = '“hello”—world';
|
||||||
|
const { fragment, text } = buildRuns([
|
||||||
|
{ text: 'say ' },
|
||||||
|
{ text: marked, comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: '!' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', marked, 'bye');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'bye' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'say ' },
|
||||||
|
{
|
||||||
|
insert: 'bye',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: '!' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchor deleted: no mark with that commentId → { applied: false, currentText: null }', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
]);
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'missing', 'abc', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: null });
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paragraph split: same commentId in two XmlText nodes → no-op, doc unchanged', () => {
|
||||||
|
const { fragment, textA, textB } = buildTwoParagraphs(
|
||||||
|
{ text: 'Hello ', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
);
|
||||||
|
const beforeA = textA.toDelta();
|
||||||
|
const beforeB = textB.toDelta();
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'Hello world', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
|
||||||
|
expect(textA.toDelta()).toEqual(beforeA);
|
||||||
|
expect(textB.toDelta()).toEqual(beforeB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interleaved unmarked text: marked run not contiguous → no-op, doc unchanged', () => {
|
||||||
|
const { fragment, text } = buildRuns([
|
||||||
|
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: 'X' },
|
||||||
|
{ text: 'def', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
]);
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
|
||||||
|
|
||||||
|
// Joined marked text ("abcdef") is returned, but the run is not contiguous.
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves surrounding text and merges adjacent marked segments on apply', () => {
|
||||||
|
// The marked run itself is split into two adjacent delta segments; they must
|
||||||
|
// be treated as one contiguous run and replaced as a whole.
|
||||||
|
const { fragment, text } = buildRuns([
|
||||||
|
{ text: 'pre ' },
|
||||||
|
{ text: 'ab', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: 'cd', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: ' post' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'abcd', 'Z');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'pre ' },
|
||||||
|
{
|
||||||
|
insert: 'Z',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: ' post' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embed before the marked run: offset accounts for the embed unit → replaces the right text, embed intact', () => {
|
||||||
|
// "AB", then a Yjs embed (1 index unit), then marked "world". Before the
|
||||||
|
// fix the embed was skipped WITHOUT advancing offset, so the computed start
|
||||||
|
// for "world" was too low by 1 → delete/insert would have hit the embed/text
|
||||||
|
// instead of "world", mangling the embed. With the fix offset is correct.
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, 'AB');
|
||||||
|
text.insertEmbed(2, { image: { src: 'x' } });
|
||||||
|
text.insert(3, 'world');
|
||||||
|
text.format(3, 'world'.length, {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'planet' });
|
||||||
|
// "AB" untouched, embed still present and intact, "world" → "planet"
|
||||||
|
// carrying the SAME comment mark.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'AB' },
|
||||||
|
{ insert: { image: { src: 'x' } } },
|
||||||
|
{
|
||||||
|
insert: 'planet',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embed inside the marked run: embed splits the run → non-contiguous → no-op, doc unchanged', () => {
|
||||||
|
// marked "abc", an embed, marked "def" — same commentId. The embed occupies
|
||||||
|
// one index unit between the two marked segments, so they are not contiguous
|
||||||
|
// → the guard rejects it and nothing is mutated (embed intact).
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, 'abc');
|
||||||
|
text.insertEmbed(3, { image: { src: 'y' } });
|
||||||
|
text.insert(4, 'def');
|
||||||
|
text.format(0, 'abc'.length, {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
});
|
||||||
|
text.format(4, 'def'.length, {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
});
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -133,6 +133,137 @@ export function removeYjsMarkByAttribute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single marked delta segment collected during the walk, together with the
|
||||||
|
* Y.XmlText node that owns it, the segment's start offset within that node,
|
||||||
|
* and the full `comment` mark attributes object (needed to re-attach the mark
|
||||||
|
* to the replacement text).
|
||||||
|
*/
|
||||||
|
type MarkedSegment = {
|
||||||
|
node: Y.XmlText;
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
text: string;
|
||||||
|
markAttrs: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically check-and-replace the text currently under a comment mark.
|
||||||
|
*
|
||||||
|
* Walks the fragment collecting every delta segment whose `comment` mark has the
|
||||||
|
* given commentId. The replacement is applied ONLY if the marked run is intact:
|
||||||
|
* it lives in a single Y.XmlText node, is contiguous (no unmarked text spliced
|
||||||
|
* into the middle), and its joined text still equals `expectedText`. On success
|
||||||
|
* the run is deleted and `newText` is inserted at the same offset carrying the
|
||||||
|
* SAME comment attributes, so the comment thread stays anchored to the new text.
|
||||||
|
*
|
||||||
|
* This mutates the passed fragment/text directly and does NOT open its own Y
|
||||||
|
* transaction — the caller is expected to wrap the call in connection.transact()
|
||||||
|
* so the delete+insert are atomic (mirrors updateYjsMarkAttribute's direct
|
||||||
|
* mutation style).
|
||||||
|
*
|
||||||
|
* @returns `{ applied: true, currentText: newText }` on replacement, otherwise
|
||||||
|
* `{ applied: false, currentText }` where currentText is the text currently
|
||||||
|
* under the mark (or null when the mark/anchor no longer exists).
|
||||||
|
*/
|
||||||
|
export function replaceYjsMarkedText(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
commentId: string,
|
||||||
|
expectedText: string,
|
||||||
|
newText: string,
|
||||||
|
): { applied: boolean; currentText: string | null } {
|
||||||
|
// 1. Collect every marked segment in document order.
|
||||||
|
const segments: MarkedSegment[] = [];
|
||||||
|
|
||||||
|
const processItem = (item: any) => {
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
const deltas = item.toDelta();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const insert = delta.insert;
|
||||||
|
// Non-string inserts (embeds) carry no text length we can splice on.
|
||||||
|
if (typeof insert !== 'string') {
|
||||||
|
// A Yjs embed occupies one unit in the index space used by delete/
|
||||||
|
// insert/format — advance offset so a marked segment after an embed
|
||||||
|
// gets the right position (and an embed inside a marked run creates a
|
||||||
|
// gap → the contiguity guard rejects it as a changed anchor).
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const length = insert.length;
|
||||||
|
const attributes = delta.attributes ?? {};
|
||||||
|
const markAttr = attributes['comment'];
|
||||||
|
|
||||||
|
if (markAttr && markAttr.commentId === commentId) {
|
||||||
|
segments.push({
|
||||||
|
node: item,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
text: insert,
|
||||||
|
markAttrs: markAttr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
processItem(item.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
processItem(fragment.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedText = segments.map((s) => s.text).join('');
|
||||||
|
|
||||||
|
// 2a. No segments — the mark/anchor was deleted.
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return { applied: false, currentText: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Segments span more than one Y.XmlText node (paragraph split by Enter,
|
||||||
|
// or the mark bled across blocks) — treat as changed.
|
||||||
|
const node = segments[0].node;
|
||||||
|
const sameNode = segments.every((s) => s.node === node);
|
||||||
|
if (!sameNode) {
|
||||||
|
return { applied: false, currentText: joinedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. Non-contiguous within the single node: unmarked text is spliced between
|
||||||
|
// the first and last marked segment. Since collected segments are in document
|
||||||
|
// order, contiguity holds iff each segment starts where the previous ended.
|
||||||
|
let contiguous = true;
|
||||||
|
for (let i = 1; i < segments.length; i++) {
|
||||||
|
if (segments[i].offset !== segments[i - 1].offset + segments[i - 1].length) {
|
||||||
|
contiguous = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!contiguous) {
|
||||||
|
return { applied: false, currentText: joinedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2d. The text under the mark changed.
|
||||||
|
if (joinedText !== expectedText) {
|
||||||
|
return { applied: false, currentText: joinedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. All guards passed: delete the marked run and re-insert newText with the
|
||||||
|
// same comment attributes at the same offset. Atomic within the caller's
|
||||||
|
// transaction.
|
||||||
|
const start = segments[0].offset;
|
||||||
|
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||||
|
const markAttrs = segments[0].markAttrs;
|
||||||
|
|
||||||
|
node.delete(start, len);
|
||||||
|
node.insert(start, newText, { comment: markAttrs });
|
||||||
|
|
||||||
|
return { applied: true, currentText: newText };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a mark's attributes for all text that has the specified attribute value.
|
* Updates a mark's attributes for all text that has the specified attribute value.
|
||||||
* Useful for resolving/unresolving comments by commentId.
|
* Useful for resolving/unresolving comments by commentId.
|
||||||
|
|||||||
@@ -73,6 +73,32 @@ describe('agentSourceFields', () => {
|
|||||||
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps ONLY the source column 'git-sync' (no chat key) for a git-sync write", () => {
|
||||||
|
// The git-sync data plane (issue #194 §8.1) has no internal ai_chats row, so
|
||||||
|
// it stamps the *Source column 'git-sync' and OMITS the chat key entirely
|
||||||
|
// (unlike the agent branch, which also writes aiChatId). Pinned directly here
|
||||||
|
// because the page.service.spec only exercises it indirectly.
|
||||||
|
expect(
|
||||||
|
agentSourceFields(
|
||||||
|
{ actor: 'git-sync', aiChatId: null },
|
||||||
|
'lastUpdatedSource',
|
||||||
|
'lastUpdatedAiChatId',
|
||||||
|
),
|
||||||
|
).toEqual({ lastUpdatedSource: 'git-sync' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores any aiChatId on a git-sync write (chat key never written)", () => {
|
||||||
|
// Even if a non-null aiChatId is present, the git-sync branch must not emit
|
||||||
|
// the chat key.
|
||||||
|
expect(
|
||||||
|
agentSourceFields(
|
||||||
|
{ actor: 'git-sync', aiChatId: 'should-be-ignored' },
|
||||||
|
'createdSource',
|
||||||
|
'aiChatId',
|
||||||
|
),
|
||||||
|
).toEqual({ createdSource: 'git-sync' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns {} for a user write so the column keeps its default', () => {
|
it('returns {} for a user write so the column keeps its default', () => {
|
||||||
expect(
|
expect(
|
||||||
agentSourceFields(
|
agentSourceFields(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
|||||||
* cannot fake an 'agent' marker.
|
* cannot fake an 'agent' marker.
|
||||||
*/
|
*/
|
||||||
export interface AuthProvenanceData {
|
export interface AuthProvenanceData {
|
||||||
|
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||||
|
// plane (issue #194 §8.1) when it drives PageService writes; never from a request token.
|
||||||
actor: ProvenanceSource;
|
actor: ProvenanceSource;
|
||||||
aiChatId: string | null;
|
aiChatId: string | null;
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,14 @@ export function agentSourceFields<S extends string, C extends string>(
|
|||||||
sourceKey: S,
|
sourceKey: S,
|
||||||
chatKey: C,
|
chatKey: C,
|
||||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||||
|
// git-sync data-plane write (issue #194 §8.1): stamp the source 'git-sync' with NO
|
||||||
|
// aiChatId (it has no internal ai_chats row). Mirrors the agent branch; each
|
||||||
|
// write has a single actor, so precedence is irrelevant here.
|
||||||
|
if (provenance?.actor === 'git-sync') {
|
||||||
|
return { [sourceKey]: 'git-sync' } as Partial<
|
||||||
|
Record<S, ProvenanceSource> & Record<C, string | null>
|
||||||
|
>;
|
||||||
|
}
|
||||||
if (provenance?.actor !== 'agent') return {};
|
if (provenance?.actor !== 'agent') return {};
|
||||||
return {
|
return {
|
||||||
[sourceKey]: 'agent',
|
[sourceKey]: 'agent',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const AuditEvent = {
|
|||||||
COMMENT_UPDATED: 'comment.updated',
|
COMMENT_UPDATED: 'comment.updated',
|
||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
COMMENT_REOPENED: 'comment.reopened',
|
COMMENT_REOPENED: 'comment.reopened',
|
||||||
|
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||||
|
|
||||||
// Page
|
// Page
|
||||||
PAGE_CREATED: 'page.created',
|
PAGE_CREATED: 'page.created',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic ESM import bridge for a CommonJS build.
|
||||||
|
*
|
||||||
|
* The server compiles with `module: commonjs`, and TypeScript downlevels a
|
||||||
|
* literal `import()` expression to `require()` — which cannot load an ESM-only
|
||||||
|
* package (`@docmost/mcp`, `@docmost/git-sync`). Indirecting through `new
|
||||||
|
* Function` hides the `import()` from the TS downleveler so the REAL dynamic
|
||||||
|
* `import()` survives to runtime and can load ESM from CommonJS.
|
||||||
|
*
|
||||||
|
* This is the single shared copy of that bridge. The per-package typed loaders
|
||||||
|
* (git-sync.loader.ts, docmost-client.loader.ts, mcp.service.ts) import this and
|
||||||
|
* keep their own typed `loadX()` wrappers (require.resolve + pathToFileURL +
|
||||||
|
* memoization) on top.
|
||||||
|
*/
|
||||||
|
export const esmImport = new Function(
|
||||||
|
'specifier',
|
||||||
|
'return import(specifier)',
|
||||||
|
) as (specifier: string) => Promise<unknown>;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { resolveRequestWorkspace } from './resolve-request-workspace';
|
||||||
|
|
||||||
|
// Unit tests for the shared self-hosted/cloud workspace resolver deduplicated out
|
||||||
|
// of DomainMiddleware + GitHttpService (architecture #11). They must behave
|
||||||
|
// identically, so this pins the single source of truth.
|
||||||
|
|
||||||
|
type AnyMock = jest.Mock;
|
||||||
|
|
||||||
|
function build(opts: {
|
||||||
|
selfHosted: boolean;
|
||||||
|
first?: { id: string } | null;
|
||||||
|
byHostname?: { id: string } | null;
|
||||||
|
}) {
|
||||||
|
const env = {
|
||||||
|
isSelfHosted: jest.fn(() => opts.selfHosted),
|
||||||
|
isCloud: jest.fn(() => !opts.selfHosted),
|
||||||
|
};
|
||||||
|
const repo = {
|
||||||
|
findFirst: jest.fn(async () => opts.first ?? null) as AnyMock,
|
||||||
|
findByHostname: jest.fn(async () => opts.byHostname ?? null) as AnyMock,
|
||||||
|
};
|
||||||
|
return { env, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveRequestWorkspace', () => {
|
||||||
|
it('self-hosted: returns the first/default workspace, ignoring the host', async () => {
|
||||||
|
const { env, repo } = build({ selfHosted: true, first: { id: 'ws-1' } });
|
||||||
|
const ws = await resolveRequestWorkspace(
|
||||||
|
env as any,
|
||||||
|
repo as any,
|
||||||
|
'anything.example.com',
|
||||||
|
);
|
||||||
|
expect(ws).toEqual({ id: 'ws-1' });
|
||||||
|
expect(repo.findFirst).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('self-hosted: returns null when no workspace is configured', async () => {
|
||||||
|
const { env, repo } = build({ selfHosted: true, first: null });
|
||||||
|
expect(await resolveRequestWorkspace(env as any, repo as any, 'h')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloud: resolves by the host-header subdomain', async () => {
|
||||||
|
const { env, repo } = build({
|
||||||
|
selfHosted: false,
|
||||||
|
byHostname: { id: 'ws-acme' },
|
||||||
|
});
|
||||||
|
const ws = await resolveRequestWorkspace(
|
||||||
|
env as any,
|
||||||
|
repo as any,
|
||||||
|
'acme.example.com',
|
||||||
|
);
|
||||||
|
expect(ws).toEqual({ id: 'ws-acme' });
|
||||||
|
expect(repo.findByHostname).toHaveBeenCalledWith('acme');
|
||||||
|
expect(repo.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloud: returns null for a blank/missing host (no throw)', async () => {
|
||||||
|
const { env, repo } = build({ selfHosted: false, byHostname: { id: 'x' } });
|
||||||
|
expect(await resolveRequestWorkspace(env as any, repo as any, undefined)).toBeNull();
|
||||||
|
expect(await resolveRequestWorkspace(env as any, repo as any, '')).toBeNull();
|
||||||
|
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloud: returns null when the subdomain matches no workspace', async () => {
|
||||||
|
const { env, repo } = build({ selfHosted: false, byHostname: null });
|
||||||
|
expect(
|
||||||
|
await resolveRequestWorkspace(env as any, repo as any, 'ghost.example.com'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE canonical way to resolve the workspace for an incoming request:
|
||||||
|
* - self-hosted (single workspace) -> the first/default workspace;
|
||||||
|
* - cloud (multi-tenant) -> resolved by the host-header subdomain.
|
||||||
|
* Returns null when none resolves (no workspace configured, or a blank/unknown
|
||||||
|
* subdomain on cloud). `isSelfHosted()` is `!isCloud()`, so exactly one branch is
|
||||||
|
* always taken.
|
||||||
|
*
|
||||||
|
* Extracted so the self-hosted/cloud branch is not hand-duplicated. Shared by
|
||||||
|
* `DomainMiddleware` (the normal /api request path) and `GitHttpService` (the raw
|
||||||
|
* root-mounted /git smart-HTTP host, which Nest middleware does NOT run for) so
|
||||||
|
* the two cannot drift.
|
||||||
|
*
|
||||||
|
* This helper does NOT catch DB errors — callers decide: DomainMiddleware lets a
|
||||||
|
* throw bubble (as before); GitHttpService wraps it to log + treat as
|
||||||
|
* unresolvable (-> 404). A blank/missing host on cloud resolves to null rather
|
||||||
|
* than throwing.
|
||||||
|
*/
|
||||||
|
export async function resolveRequestWorkspace(
|
||||||
|
environmentService: EnvironmentService,
|
||||||
|
workspaceRepo: WorkspaceRepo,
|
||||||
|
hostHeader: string | undefined,
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
if (environmentService.isSelfHosted()) {
|
||||||
|
return (await workspaceRepo.findFirst()) ?? null;
|
||||||
|
}
|
||||||
|
// Cloud (isSelfHosted === !isCloud, so this is the only remaining branch).
|
||||||
|
const subdomain = hostHeader ? hostHeader.split('.')[0] : '';
|
||||||
|
if (!subdomain) return null;
|
||||||
|
return (await workspaceRepo.findByHostname(subdomain)) ?? null;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
import { resolveRequestWorkspace } from '../helpers/resolve-request-workspace';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DomainMiddleware implements NestMiddleware {
|
export class DomainMiddleware implements NestMiddleware {
|
||||||
@@ -14,30 +15,19 @@ export class DomainMiddleware implements NestMiddleware {
|
|||||||
res: FastifyReply['raw'],
|
res: FastifyReply['raw'],
|
||||||
next: () => void,
|
next: () => void,
|
||||||
) {
|
) {
|
||||||
if (this.environmentService.isSelfHosted()) {
|
// Shared self-hosted/cloud resolution (the SAME branch the /git host uses),
|
||||||
const workspace = await this.workspaceRepo.findFirst();
|
// so the logic cannot drift between the two.
|
||||||
if (!workspace) {
|
const workspace = await resolveRequestWorkspace(
|
||||||
//throw new NotFoundException('Workspace not found');
|
this.environmentService,
|
||||||
(req as any).workspaceId = null;
|
this.workspaceRepo,
|
||||||
return next();
|
req.headers.host,
|
||||||
}
|
);
|
||||||
|
|
||||||
// TODO: unify
|
|
||||||
(req as any).workspaceId = workspace.id;
|
|
||||||
(req as any).workspace = workspace;
|
|
||||||
} else if (this.environmentService.isCloud()) {
|
|
||||||
const header = req.headers.host;
|
|
||||||
const subdomain = header.split('.')[0];
|
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
(req as any).workspaceId = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (workspace) {
|
||||||
(req as any).workspaceId = workspace.id;
|
(req as any).workspaceId = workspace.id;
|
||||||
(req as any).workspace = workspace;
|
(req as any).workspace = workspace;
|
||||||
|
} else {
|
||||||
|
(req as any).workspaceId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -2,43 +2,91 @@ import { AiChatController } from './ai-chat.controller';
|
|||||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
|
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint, hardened for
|
||||||
* the requesting user + workspace + pageId to findLatestByPage and return the
|
* #312. `dto.pageId` carries either a page slugId (10-char nanoid, off a slug
|
||||||
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
|
* URL) or a page uuid, so the controller must FIRST resolve it to a real page
|
||||||
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
|
* uuid via PageRepo.findById (which accepts both) — passing the raw slugId into
|
||||||
* (null) — no extra page-access check is needed. Exercised with hand-rolled
|
* the uuid ai_chats.page_id column caused a Postgres 22P02 500. Only then is the
|
||||||
* mocks, no Nest graph and no DB.
|
* caller's most-recent OWN chat for that page looked up (by the resolved uuid),
|
||||||
|
* and a page in a different workspace (or an unknown id) yields { chatId: null }
|
||||||
|
* without ever touching the chat lookup. Exercised with hand-rolled mocks, no
|
||||||
|
* Nest graph and no DB.
|
||||||
*/
|
*/
|
||||||
describe('AiChatController.boundChat', () => {
|
describe('AiChatController.boundChat', () => {
|
||||||
const user = { id: 'u1' } as User;
|
const user = { id: 'u1' } as User;
|
||||||
const workspace = { id: 'ws1' } as Workspace;
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
function makeController(chat: unknown) {
|
function makeController(opts: { page: unknown; chat?: unknown }) {
|
||||||
const aiChatRepo = {
|
const aiChatRepo = {
|
||||||
findLatestByPage: jest.fn().mockResolvedValue(chat),
|
findLatestByPage: jest.fn().mockResolvedValue(opts.chat),
|
||||||
|
};
|
||||||
|
const pageRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.page),
|
||||||
};
|
};
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
pageRepo as never,
|
||||||
);
|
);
|
||||||
return { controller, aiChatRepo };
|
return { controller, aiChatRepo, pageRepo };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
|
it('resolves a slugId to the page uuid and returns the owned chat id', async () => {
|
||||||
const { controller, aiChatRepo } = makeController({
|
const { controller, aiChatRepo, pageRepo } = makeController({
|
||||||
id: 'c1',
|
// findById accepts a slugId and returns the page with its real uuid.
|
||||||
creatorId: 'u1',
|
page: { id: 'page-uuid-1', workspaceId: 'ws1' },
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
});
|
});
|
||||||
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
|
// The client sends a 10-char nanoid slugId, NOT a uuid.
|
||||||
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
|
const res = await controller.boundChat(
|
||||||
|
{ pageId: 'i82qXsivsx' },
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
expect(pageRepo.findById).toHaveBeenCalledWith('i82qXsivsx');
|
||||||
|
// findLatestByPage must receive the RESOLVED uuid, never the raw slugId.
|
||||||
|
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith(
|
||||||
|
'u1',
|
||||||
|
'ws1',
|
||||||
|
'page-uuid-1',
|
||||||
|
);
|
||||||
expect(res).toEqual({ chatId: 'c1' });
|
expect(res).toEqual({ chatId: 'c1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
|
it('returns { chatId: null } for a page in a DIFFERENT workspace without a chat lookup', async () => {
|
||||||
const { controller } = makeController(undefined);
|
const { controller, aiChatRepo, pageRepo } = makeController({
|
||||||
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
|
page: { id: 'page-uuid-2', workspaceId: 'other-ws' },
|
||||||
|
});
|
||||||
|
const res = await controller.boundChat(
|
||||||
|
{ pageId: 'foreignSlug' },
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
expect(pageRepo.findById).toHaveBeenCalledWith('foreignSlug');
|
||||||
|
// No cross-workspace leak: the chat lookup must never run.
|
||||||
|
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ chatId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { chatId: null } for an unknown id without throwing or looking up a chat', async () => {
|
||||||
|
const { controller, aiChatRepo } = makeController({ page: undefined });
|
||||||
|
const res = await controller.boundChat(
|
||||||
|
{ pageId: 'does-not-exist' },
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
);
|
||||||
|
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ chatId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { chatId: null } when the resolved page has no owned chat', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
page: { id: 'page-uuid-3', workspaceId: 'ws1' },
|
||||||
|
chat: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.boundChat({ pageId: 'p3' }, user, workspace);
|
||||||
expect(res).toEqual({ chatId: null });
|
expect(res).toEqual({ chatId: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('AiChatController.export', () => {
|
|||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
aiChatMessageRepo as never,
|
aiChatMessageRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { controller, aiChatRepo, aiChatMessageRepo };
|
return { controller, aiChatRepo, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
@@ -55,6 +56,7 @@ export class AiChatController {
|
|||||||
private readonly aiChatRepo: AiChatRepo,
|
private readonly aiChatRepo: AiChatRepo,
|
||||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||||
private readonly aiTranscription: AiTranscriptionService,
|
private readonly aiTranscription: AiTranscriptionService,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List the requesting user's chats in this workspace (paginated). */
|
/** List the requesting user's chats in this workspace (paginated). */
|
||||||
@@ -71,9 +73,15 @@ export class AiChatController {
|
|||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document for the requesting user: the most-recent
|
* Resolve the chat bound to a document for the requesting user: the most-recent
|
||||||
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
||||||
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
|
* { chatId: null } when the page has no owned chat (-> a fresh chat).
|
||||||
* access check needed: only the caller's OWN chats are matched, so a foreign
|
*
|
||||||
* pageId reveals nothing.
|
* `dto.pageId` carries EITHER a page slugId (10-char nanoid, sent by the client
|
||||||
|
* off a slug URL) OR a page uuid, so it must be resolved to a real page uuid
|
||||||
|
* before it touches the uuid ai_chats.page_id column — passing a slugId straight
|
||||||
|
* through triggered a Postgres 22P02 "invalid input syntax for type uuid" 500
|
||||||
|
* (#312). PageRepo.findById accepts both forms. The workspace guard rejects an
|
||||||
|
* unknown or cross-workspace page (-> { chatId: null }) so a foreign id cannot
|
||||||
|
* probe another workspace's chats. Only the caller's OWN chats are then matched.
|
||||||
*/
|
*/
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('bound-chat')
|
@Post('bound-chat')
|
||||||
@@ -82,10 +90,14 @@ export class AiChatController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<{ chatId: string | null }> {
|
): Promise<{ chatId: string | null }> {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId); // accepts slugId OR uuid
|
||||||
|
if (!page || page.workspaceId !== workspace.id) {
|
||||||
|
return { chatId: null }; // unknown or foreign-workspace page — no binding, no leak
|
||||||
|
}
|
||||||
const chat = await this.aiChatRepo.findLatestByPage(
|
const chat = await this.aiChatRepo.findLatestByPage(
|
||||||
user.id,
|
user.id,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
dto.pageId,
|
page.id, // the real uuid, never the incoming slugId
|
||||||
);
|
);
|
||||||
return { chatId: chat?.id ?? null };
|
return { chatId: chat?.id ?? null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe('AiChatController.generatePageTitle', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { controller, aiChatService };
|
return { controller, aiChatService };
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user