Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68caf8157a | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 5edd75da42 | |||
| 24b903aaf3 | |||
| 2637640291 | |||
| aa0428e28b | |||
| 0a3e32e7f6 | |||
| b1ede48319 | |||
| d4d05c8e8b | |||
| 351860ba4b | |||
| 795dde463b | |||
| 0392566af9 | |||
| f43696a1c4 | |||
| 8971912d9e | |||
| 588596fb2f | |||
| ba94def3c8 | |||
| e1b8f81b15 | |||
| 45478098f5 | |||
| 62b818bb36 | |||
| b7c16dc634 | |||
| da952ca536 | |||
| 1458e3e152 | |||
| 13a333632a | |||
| 344b9723b2 | |||
| d57392b5af | |||
| a86e5f409f | |||
| 33d22ff164 | |||
| b861266ff8 | |||
| 8b99b70d73 | |||
| b3d4922efa | |||
| 49c7c4bb64 | |||
| d9517ff3f1 | |||
| 48c1ec46f7 | |||
| cd539558ed | |||
| b62db917de | |||
| ec542a924b | |||
| a9da8f7f15 | |||
| 7c0664d2b3 | |||
| a32fba63ec | |||
| 808a5c70df | |||
| 0210faabea | |||
| 17003fbbc1 | |||
| 0df6242128 | |||
| 36b3539571 | |||
| a63efa6920 | |||
| ccd38152ab | |||
| 8f95c5808e | |||
| 6f7d439811 | |||
| 88d96c41b5 | |||
| ef16743406 | |||
| 6c208a965f | |||
| 86c1307ed2 | |||
| f720151c63 | |||
| 0968ea97d2 | |||
| 4af21494af | |||
| 2d30ad1fa2 | |||
| e648771ab8 | |||
| 4d8315da5c | |||
| 3f7e1bdc7b | |||
| d89650a45e | |||
| b1e48d3765 | |||
| 293348f9dc | |||
| 330837cfa6 | |||
| 916b24e3ff | |||
| ecf022ffca | |||
| 62af116271 | |||
| e9d5d493d3 | |||
| db9ed51e01 | |||
| 963822bd28 | |||
| c452902432 | |||
| 731a4f0dca | |||
| 895173b176 | |||
| 45d5ae1601 | |||
| ec30e6c08a | |||
| db9f29c16b | |||
| fa439d7c7b | |||
| 82411f8707 | |||
| af481d401a | |||
| affa32cbaa | |||
| b349676eae | |||
| 438ef091f9 | |||
| 768d135a19 | |||
| c90caeb21a | |||
| 5664da57ad | |||
| c39fab70c1 | |||
| 3a5794894e | |||
| 8d745352d1 | |||
| f0a69abd0f | |||
| f8c4343fa8 | |||
| 4d0f791471 | |||
| 20032be921 | |||
| c16942777d | |||
| 0bdc9f98f5 | |||
| 6e70c7bd6a | |||
| 85b303e387 | |||
| 23c80f727a | |||
| 2b36997c63 |
+22
-3
@@ -173,9 +173,21 @@ MCP_DOCMOST_PASSWORD=
|
||||
# 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
|
||||
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
||||
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
||||
# AI_STREAM_KEEPALIVE_MS=10000
|
||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
|
||||
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
|
||||
# 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).
|
||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||
@@ -190,6 +202,13 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||
# active" behavior.
|
||||
# AI_CHAT_DEFERRED_TOOLS=true
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
|
||||
@@ -72,6 +72,14 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||
# unless it is built first. Build it before the recursive test run.
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
+16
-1
@@ -4,7 +4,20 @@
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
node_modules
|
||||
|
||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||
# so src/ and prod can never silently diverge).
|
||||
packages/git-sync/build/
|
||||
|
||||
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||
# never committed, so src/ and prod can never silently diverge).
|
||||
packages/prosemirror-markdown/build/
|
||||
|
||||
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||
# markdown convention; the package is private and rebuilt at deploy.
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -43,6 +56,8 @@ lerna-debug.log*
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
# Local Chrome performance traces recorded by the AI-chat perf harness
|
||||
.claude/perf-traces/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -72,7 +72,10 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
||||
|
||||
### 4. Push and PR to develop
|
||||
|
||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
||||
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
||||
branch is still pushed with `git push`), while **the PR itself is opened through
|
||||
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||
conflict with the owner's account in the git credential helper):
|
||||
@@ -94,18 +97,24 @@ git remote set-url gitea "$ORIG_URL"
|
||||
unset AGENT_PASS SAFE_PASS
|
||||
```
|
||||
|
||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
||||
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
||||
Call `pull_request_write` with:
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @pr_body.json \
|
||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||
```
|
||||
- `method: "create"`
|
||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||
- `base: "develop"`, `head: "<branch>"`
|
||||
- `title`, `body` — in the body: what was done, what is out of scope,
|
||||
verification results (tsc/lint/tests).
|
||||
|
||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||
of scope, verification results (tsc/lint/tests).
|
||||
Manage and read PRs through the same server: `list_pull_requests`,
|
||||
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
||||
`pull_request_review_write`.
|
||||
|
||||
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
||||
with `get_me`), a different account from the `claude_code` used for git
|
||||
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
||||
the MCP account; the commits themselves stay authored as `claude_code`.
|
||||
|
||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||
@@ -152,23 +161,25 @@ below.
|
||||
| Agent user (Gitea/git) | `claude_code` |
|
||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
||||
| Base branch | `develop` |
|
||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||
| `upstream` | The original Docmost — **never push** |
|
||||
|
||||
## Creating issues (Gitea `tea` CLI)
|
||||
## Creating issues (Gitea MCP)
|
||||
|
||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
||||
`issue_write` with:
|
||||
|
||||
```bash
|
||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
--title '<title>' --description "$(cat body.md)"
|
||||
```
|
||||
- `method: "create"`
|
||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||
- `title`, `body`
|
||||
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
||||
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
||||
pass e.g. `labels: [<id>]`.
|
||||
|
||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
||||
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -271,7 +283,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, 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. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||
- 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`.
|
||||
|
||||
@@ -282,6 +294,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
images as a row that wraps onto the next line on narrow screens. The row is
|
||||
centered horizontally by default in modern browsers (CSS `:has()`), falling
|
||||
back to start-aligned rows in browsers without support. Unlike the float
|
||||
modes, text does not wrap around inline images. The mode round-trips
|
||||
losslessly through markdown as `data-align`, like the other alignment
|
||||
values.
|
||||
|
||||
- **Editable captions for images.** Images gain an optional caption shown
|
||||
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
||||
losslessly through markdown as a `data-caption` attribute on the image, so
|
||||
@@ -77,6 +86,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||
- **Dock the AI chat window into the side menu.** The floating chat window can
|
||||
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
|
||||
shows where it lands) or use the new "Dock to sidebar" header button; while
|
||||
docked it fills the sidebar area and follows its live size. "Undock" (or
|
||||
dragging it back out) restores the floating window, a collapsed/absent
|
||||
sidebar falls back to floating, and the docked state survives a reload.
|
||||
(#276, #282)
|
||||
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
|
||||
at a highlighted comment mark pops a small card with the author and plain
|
||||
text of the root comment and its replies, so a thread can be skimmed without
|
||||
opening the side panel. The card appears after a short delay (no flicker on a
|
||||
passing glance), skips resolved and text-less threads, and dismisses on
|
||||
scroll or click — clicking a mark still opens the comments panel. (#268,
|
||||
#271)
|
||||
- **"Move to trash" button in the temporary-note banner.** Besides "Make
|
||||
permanent", the banner on an open temporary note now also offers to trash the
|
||||
note immediately instead of waiting out its lifetime. It reuses the regular
|
||||
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
|
||||
no confirmation dialog. (#273, #277)
|
||||
- **Code-block controls float as an overlay instead of taking a row above the
|
||||
code.** The language selector and copy button now sit in the block's top-right
|
||||
corner, and the selector stays invisible until the block is hovered or the
|
||||
selector is focused, so reading code is chrome-free. In read-only views only
|
||||
the copy button renders. (#275, #278)
|
||||
- **The AI agent is told about your page edits between turns.** The server
|
||||
snapshots the open page's Markdown at the end of every agent turn and, on the
|
||||
next turn, injects a unified diff of what changed in between, so the agent
|
||||
knows its earlier copy of the page is stale and builds on the user's edits
|
||||
instead of reverting or overwriting them. The diff is whitespace-normalized
|
||||
(pure formatting churn injects nothing) and size-capped, with a hint to
|
||||
re-read the full page via `getPage` when truncated. (#274, #281)
|
||||
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
|
||||
toggle a combining acute accent over it — a Russian-style stress mark. The
|
||||
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
|
||||
export, full-text search and public shares unchanged; the toggle is a single
|
||||
undo step and re-clicking removes the accent. (#270, #280)
|
||||
- **Reading position survives a reload.** The editor remembers how far you
|
||||
scrolled in each page (per tab, in `sessionStorage`) and restores that
|
||||
position after an F5 or reopening the document, waiting for the collaborative
|
||||
content to finish laying out first. A URL `#hash` anchor still wins — restore
|
||||
is a no-op then. (#266, #267)
|
||||
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
|
||||
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
|
||||
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
|
||||
by physical key position and matched against the commands; genuine Cyrillic
|
||||
search terms keep priority over remapped candidates, and short wrong-layout
|
||||
prefixes match by command title. (#283, #285, #287)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -142,6 +198,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
emits a single-use "intentional clear" signal that lets exactly that one empty
|
||||
write through the guard, so genuinely emptying a page is persisted while
|
||||
accidental empties are blocked. (#248, #251)
|
||||
- **Ctrl+Z works again right after using a table menu.** Closing a table
|
||||
row/column menu (grip or chevron) left focus on the menu's portaled target
|
||||
outside the editor, so undo keystrokes went nowhere until you clicked back
|
||||
into a cell. The editor is now refocused after the menu closes — unless you
|
||||
deliberately moved focus to another input or editable (e.g. the page title).
|
||||
(#269, #279)
|
||||
- **The AI reindex progress counter no longer freezes at 0.** Right after
|
||||
"Reindex now" the client could read the stale pre-reindex snapshot of an
|
||||
already-indexed workspace (`reindexing=false`, all pages counted) as
|
||||
"finished" and stop polling on the very first tick, leaving the counter
|
||||
frozen until a manual reload. Polling now keeps going until it has actually
|
||||
observed the active run. (#262, #264)
|
||||
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
|
||||
When the agent addressed a page by its short slugId, the MCP opened a
|
||||
collaboration document named after that slugId while the web editor always
|
||||
uses the page's canonical UUID — two independent live documents for one page,
|
||||
whose debounced stores clobbered each other. The MCP now resolves every page
|
||||
id to the canonical UUID before opening the collab doc (a UUID input
|
||||
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -38,6 +38,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/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||
# revisit at step 6 when #119 lands.)
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||
- ✅ **Temporary notes** — create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -187,14 +187,17 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
||||
- Spaces
|
||||
- Permissions management
|
||||
- Groups
|
||||
- Comments (with resolve / re-open)
|
||||
- Comments (with resolve / re-open and hover tooltips showing the comment text)
|
||||
- Page history
|
||||
- Search
|
||||
- File attachments
|
||||
- Embeds (Airtable, Loom, Miro and more)
|
||||
- Translations (10+ languages)
|
||||
- Embedded MCP server (`/mcp`)
|
||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
|
||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
|
||||
- Code-block buttons as an overlay, with the language selector revealed on hover
|
||||
- Stress-accent button (U+0301) in the bubble menu
|
||||
- Reading scroll position restored on reload
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
+7
-3
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
- ✅ **Временные заметки** — создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -174,14 +174,18 @@ dump/restore, существующий каталог данных переис
|
||||
- Пространства (Spaces)
|
||||
- Управление правами доступа
|
||||
- Группы
|
||||
- Комментарии (с резолвом / переоткрытием)
|
||||
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
|
||||
- История страниц
|
||||
- Поиск
|
||||
- Вложения файлов
|
||||
- Встраивания (Airtable, Loom, Miro и другие)
|
||||
- Переводы (10+ языков)
|
||||
- Встроенный MCP-сервер (`/mcp`)
|
||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
|
||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
|
||||
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
|
||||
- Кнопка «Ударение» (U+0301) в bubble-меню
|
||||
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
|
||||
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
|
||||
|
||||
### Скриншоты
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ roles:
|
||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [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
|
||||
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.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity:
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [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].
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity:
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -166,14 +168,17 @@ roles:
|
||||
- Don't verify facts — that's the Fact-checker.
|
||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||
|
||||
HOW TO WORK
|
||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||
- [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.
|
||||
|
||||
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
|
||||
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
||||
@@ -272,7 +277,7 @@ roles:
|
||||
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
||||
|
||||
═══ 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 ═══
|
||||
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
||||
|
||||
@@ -34,11 +34,13 @@ roles:
|
||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
|
||||
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
|
||||
|
||||
ТОН
|
||||
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
||||
|
||||
@@ -85,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -126,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -167,14 +169,17 @@ roles:
|
||||
- Не проверяешь достоверность фактов — это фактчекер.
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК РАБОТАТЬ
|
||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
|
||||
ТОН
|
||||
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев.
|
||||
По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
|
||||
|
||||
ПРИ НЕУВЕРЕННОСТИ
|
||||
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
||||
@@ -273,7 +278,7 @@ roles:
|
||||
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
||||
|
||||
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
|
||||
═══ ТОН ═══
|
||||
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
||||
|
||||
@@ -12,15 +12,15 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 2
|
||||
version: 4
|
||||
- slug: line-editor
|
||||
version: 2
|
||||
version: 4
|
||||
- slug: fact-checker
|
||||
version: 3
|
||||
version: 6
|
||||
- slug: proofreader
|
||||
version: 3
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 1
|
||||
version: 2
|
||||
- id: research
|
||||
name:
|
||||
ru: Исследование
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
"version": 4,
|
||||
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
"version": 8,
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
"version": 4,
|
||||
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
"clsx": "2.1.1",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "3.4.1",
|
||||
"file-saver": "2.0.5",
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
|
||||
* /perf/ai-chat-perf.html; never part of the production build, which uses the
|
||||
* single default index.html entry).
|
||||
*
|
||||
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
|
||||
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
|
||||
* React mounts so ChatThread's DefaultChatTransport requests to
|
||||
* /api/ai-chat/stream are answered by the synthetic SSE generator.
|
||||
*/
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { mantineCssResolver, theme } from "../src/theme.ts";
|
||||
// i18n side-effect init (http-backend). Translations load from /locales in dev;
|
||||
// missing keys fall back to the key text, which is fine for the harness.
|
||||
import "../src/i18n.ts";
|
||||
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
|
||||
import PerfHarness from "./harness.tsx";
|
||||
|
||||
// MUST run before React mounts: ChatThread creates its transport with the
|
||||
// global fetch, so the patch has to be in place before the first send.
|
||||
installAiChatStreamFetchPatch();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<MemoryRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PerfHarness />
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI chat perf harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./ai-chat-perf-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* DEV-ONLY perf harness UI for the AI chat feature.
|
||||
*
|
||||
* Left panel: controls + live stats. Right side: a bordered box (~real chat
|
||||
* window size) hosting the REAL ChatThread component.
|
||||
*
|
||||
* Scenario A "Open existing chat": mount ChatThread seeded with a large
|
||||
* persisted transcript and measure click -> post-mount-paint time.
|
||||
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
|
||||
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
|
||||
* through the real useChat pipeline.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, MutableRefObject } from "react";
|
||||
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
|
||||
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
PRESETS,
|
||||
buildPersistedRows,
|
||||
buildTurnScript,
|
||||
setLiveStreamSettings,
|
||||
type PresetKey,
|
||||
} from "./synthetic-turn.ts";
|
||||
|
||||
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
|
||||
const AUTO_SEND_TIMEOUT_MS = 1000;
|
||||
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
|
||||
const STATS_FLUSH_MS = 500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PerfStats {
|
||||
longtaskCount: number;
|
||||
longtaskTotalMs: number;
|
||||
longtaskMaxMs: number;
|
||||
fps: number;
|
||||
sseChunks: number;
|
||||
sseChars: number;
|
||||
mountAMs: number | null;
|
||||
streamState: "idle" | "streaming" | "done" | "aborted";
|
||||
}
|
||||
|
||||
function emptyStats(): PerfStats {
|
||||
return {
|
||||
longtaskCount: 0,
|
||||
longtaskTotalMs: 0,
|
||||
longtaskMaxMs: 0,
|
||||
fps: 0,
|
||||
sseChunks: 0,
|
||||
sseChars: 0,
|
||||
mountAMs: null,
|
||||
streamState: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
|
||||
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
|
||||
* re-renders only this panel — NOT the ChatThread under measurement.
|
||||
*/
|
||||
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
|
||||
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
|
||||
|
||||
// Long tasks (main-thread blocks > 50ms).
|
||||
useEffect(() => {
|
||||
let observer: PerformanceObserver | null = null;
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
stats.current.longtaskCount += 1;
|
||||
stats.current.longtaskTotalMs += entry.duration;
|
||||
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// longtask entries unsupported in this browser — panel shows zeros.
|
||||
}
|
||||
return () => observer?.disconnect();
|
||||
}, [stats]);
|
||||
|
||||
// FPS: frames rendered within the trailing 1s window.
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const frames: number[] = [];
|
||||
const loop = (now: number) => {
|
||||
frames.push(now);
|
||||
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
|
||||
stats.current.fps = frames.length;
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [stats]);
|
||||
|
||||
// Flush the mutable stats into the display at most 2x/s.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stats]);
|
||||
|
||||
const resetLongtasks = () => {
|
||||
stats.current.longtaskCount = 0;
|
||||
stats.current.longtaskTotalMs = 0;
|
||||
stats.current.longtaskMaxMs = 0;
|
||||
setSnapshot({ ...stats.current });
|
||||
};
|
||||
|
||||
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
|
||||
return (
|
||||
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
|
||||
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
|
||||
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
|
||||
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
|
||||
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
|
||||
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
|
||||
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
|
||||
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
|
||||
<div style={row}>
|
||||
<span>Mount A</span>
|
||||
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
|
||||
</div>
|
||||
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
|
||||
Reset long tasks
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fill the composer textarea via the native value setter + an `input` event
|
||||
* (React 18 controlled-input pattern), then click the enabled "Send" button.
|
||||
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
|
||||
*/
|
||||
function autoSend(host: HTMLElement, text: string): void {
|
||||
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
|
||||
|
||||
const tryClick = () => {
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
if (performance.now() < deadline) requestAnimationFrame(tryClick);
|
||||
else console.error("[perf] auto-send: Send button never became clickable");
|
||||
};
|
||||
|
||||
const trySetValue = () => {
|
||||
const textarea = host.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
|
||||
else console.error("[perf] auto-send: textarea not found");
|
||||
return;
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(textarea, text);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// Click on a later frame so React commits the controlled value (which
|
||||
// enables the Send button) before we press it.
|
||||
requestAnimationFrame(tryClick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(trySetValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MountState {
|
||||
mode: "A" | "B";
|
||||
key: number;
|
||||
chatId: string | null;
|
||||
rows: IAiChatMessageRow[];
|
||||
}
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
export default function PerfHarness() {
|
||||
const [preset, setPreset] = useState<PresetKey>("20k");
|
||||
const [intervalMs, setIntervalMs] = useState<number>(15);
|
||||
const [mounted, setMounted] = useState<MountState | null>(null);
|
||||
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
|
||||
|
||||
const statsRef = useRef<PerfStats>(emptyStats());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const keyCounterRef = useRef(0);
|
||||
const mountStartRef = useRef(0);
|
||||
const pendingMountMeasureRef = useRef(false);
|
||||
|
||||
// The scripted live turn for the current preset (reused across B runs; the
|
||||
// script is immutable data, so rebuilding per run is unnecessary).
|
||||
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
|
||||
|
||||
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
|
||||
|
||||
// Scenario A: mount ChatThread seeded with a large persisted transcript.
|
||||
const handleMountA = () => {
|
||||
const fixture = buildPersistedRows(PRESETS[preset]);
|
||||
setFixtureInfo(
|
||||
`Persisted fixture: ${fixture.rows.length} rows, ` +
|
||||
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
|
||||
);
|
||||
statsRef.current.mountAMs = null;
|
||||
// Mark AFTER fixture generation: we measure mount cost, not generation cost
|
||||
// (production receives its rows from the network).
|
||||
performance.mark("perf:mountA:start");
|
||||
mountStartRef.current = performance.now();
|
||||
pendingMountMeasureRef.current = true;
|
||||
keyCounterRef.current += 1;
|
||||
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
|
||||
};
|
||||
|
||||
// Measure scenario A: effect runs after the mount commit; double rAF lands
|
||||
// after the first paint of the mounted transcript.
|
||||
useEffect(() => {
|
||||
if (!pendingMountMeasureRef.current) return;
|
||||
pendingMountMeasureRef.current = false;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
|
||||
performance.mark("perf:mountA:end");
|
||||
try {
|
||||
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
|
||||
} catch {
|
||||
// Marks cleared mid-run — ignore.
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [mounted]);
|
||||
|
||||
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
|
||||
const handleStartB = () => {
|
||||
statsRef.current.sseChunks = 0;
|
||||
statsRef.current.sseChars = 0;
|
||||
statsRef.current.streamState = "streaming";
|
||||
setLiveStreamSettings({
|
||||
script: liveScript,
|
||||
chunkIntervalMs: intervalMs,
|
||||
onProgress: (chunks, chars) => {
|
||||
statsRef.current.sseChunks = chunks;
|
||||
statsRef.current.sseChars = chars;
|
||||
},
|
||||
onDone: () => {
|
||||
statsRef.current.streamState = "done";
|
||||
performance.mark("perf:streamB:end");
|
||||
try {
|
||||
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
|
||||
} catch {
|
||||
// Start mark missing (e.g. marks cleared) — ignore.
|
||||
}
|
||||
},
|
||||
onAbort: () => {
|
||||
statsRef.current.streamState = "aborted";
|
||||
},
|
||||
});
|
||||
performance.mark("perf:streamB:start");
|
||||
keyCounterRef.current += 1;
|
||||
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
|
||||
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
|
||||
};
|
||||
|
||||
const handleUnmount = () => setMounted(null);
|
||||
|
||||
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
|
||||
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* Left: controls + stats */}
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
flex: "0 0 260px",
|
||||
padding: 12,
|
||||
borderRight: "1px solid #ccc",
|
||||
overflowY: "auto",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
|
||||
|
||||
<label style={label}>Preset</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as PresetKey)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="5k">5k tokens</option>
|
||||
<option value="20k">20k tokens</option>
|
||||
<option value="50k">50k tokens</option>
|
||||
</select>
|
||||
|
||||
<label style={label}>Chunk interval (scenario B)</label>
|
||||
<select
|
||||
value={intervalMs}
|
||||
onChange={(e) => setIntervalMs(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value={15}>15 ms (normal)</option>
|
||||
<option value={5}>5 ms (stress)</option>
|
||||
</select>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button type="button" style={button} onClick={handleMountA}>
|
||||
Mount persisted chat (A)
|
||||
</button>
|
||||
<button type="button" style={button} onClick={handleStartB}>
|
||||
Start live stream (B)
|
||||
</button>
|
||||
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
|
||||
Unmount
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
|
||||
<div>
|
||||
Live turn: {liveScript.totalChars.toLocaleString()} chars ≈{" "}
|
||||
{liveScript.approxTokens.toLocaleString()} tokens
|
||||
</div>
|
||||
{fixtureInfo && <div>{fixtureInfo}</div>}
|
||||
{mounted && (
|
||||
<div>
|
||||
Mounted: scenario {mounted.mode} (key {mounted.key})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
|
||||
<StatsPanel stats={statsRef} />
|
||||
</div>
|
||||
|
||||
{/* Right: the real ChatThread inside a real-window-sized box */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f4f4f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={hostRef}
|
||||
style={{
|
||||
width: 540,
|
||||
height: 680,
|
||||
border: "1px solid #bbb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
padding: 8,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{mounted ? (
|
||||
<ChatThread
|
||||
key={mounted.key}
|
||||
chatId={mounted.chatId}
|
||||
threadKey={`perf-${mounted.key}`}
|
||||
initialRows={mounted.rows}
|
||||
openPage={openPage}
|
||||
roleId={null}
|
||||
roles={[]}
|
||||
onRolePicked={noop}
|
||||
assistantName="Perf agent"
|
||||
onTurnFinished={noop}
|
||||
onServerChatId={noop}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
|
||||
ChatThread unmounted. Use the controls on the left.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
|
||||
*
|
||||
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
|
||||
* from a size config, and materializes it two ways:
|
||||
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
|
||||
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
|
||||
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
|
||||
*
|
||||
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
|
||||
* (strict objects — only the exact field names below are accepted).
|
||||
*/
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config / presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 1 token ~= 4 chars — the approximation used throughout this module. */
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
export interface TurnConfig {
|
||||
/** Number of agent steps; each step = one reasoning block + one tool call. */
|
||||
steps: number;
|
||||
/** Approximate reasoning tokens generated per step. */
|
||||
reasoningTokensPerStep: number;
|
||||
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
|
||||
toolOutputBytes: number;
|
||||
/** Approximate size of the final markdown answer, in tokens. */
|
||||
answerTokens: number;
|
||||
}
|
||||
|
||||
export type PresetKey = "5k" | "20k" | "50k";
|
||||
|
||||
export const PRESETS: Record<PresetKey, TurnConfig> = {
|
||||
"5k": {
|
||||
steps: 3,
|
||||
reasoningTokensPerStep: 500,
|
||||
toolOutputBytes: 10_000,
|
||||
answerTokens: 600,
|
||||
},
|
||||
"20k": {
|
||||
steps: 6,
|
||||
reasoningTokensPerStep: 2500,
|
||||
toolOutputBytes: 20_000,
|
||||
answerTokens: 1500,
|
||||
},
|
||||
"50k": {
|
||||
steps: 10,
|
||||
reasoningTokensPerStep: 4000,
|
||||
toolOutputBytes: 40_000,
|
||||
answerTokens: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
|
||||
const REASONING_SENTENCES = [
|
||||
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
|
||||
"First I need to inspect the current page content to understand its overall structure.",
|
||||
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
|
||||
"The table in section three contains the migration matrix that I should cross-check against the summary.",
|
||||
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
|
||||
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
|
||||
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
|
||||
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
|
||||
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
|
||||
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Build realistic prose of ~`targetChars` characters, inserting a newline
|
||||
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
|
||||
*/
|
||||
function makeProse(targetChars: number): string {
|
||||
const pieces: string[] = [];
|
||||
let length = 0;
|
||||
let sinceNewline = 0;
|
||||
let i = 0;
|
||||
while (length < targetChars) {
|
||||
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
|
||||
i += 1;
|
||||
pieces.push(sentence);
|
||||
length += sentence.length + 1;
|
||||
sinceNewline += sentence.length + 1;
|
||||
if (sinceNewline >= 200) {
|
||||
pieces.push("\n");
|
||||
sinceNewline = 0;
|
||||
} else {
|
||||
pieces.push(" ");
|
||||
}
|
||||
}
|
||||
return pieces.join("").trimEnd();
|
||||
}
|
||||
|
||||
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
|
||||
function markdownSection(n: number): string {
|
||||
return [
|
||||
`## Section ${n}: migration analysis`,
|
||||
``,
|
||||
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
|
||||
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
|
||||
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
|
||||
``,
|
||||
`- Update the fetch layer to the v6 transport`,
|
||||
`- Перенести таблицы соответствия идентификаторов`,
|
||||
`- Verify citation links after the move`,
|
||||
`- Проверить отображение длинных ответов в узкой панели`,
|
||||
``,
|
||||
`| Область | Страниц | Статус | Риск |`,
|
||||
`| --- | --- | --- | --- |`,
|
||||
`| API reference | ${n + 4} | migrated | low |`,
|
||||
`| Onboarding | ${n + 2} | in progress | medium |`,
|
||||
`| Release notes | ${n * 3} | pending | high |`,
|
||||
``,
|
||||
"```ts",
|
||||
`export function migrateSection${n}(rows: Row[]): Row[] {`,
|
||||
` return rows`,
|
||||
` .filter((row) => row.section === ${n})`,
|
||||
` .map((row) => ({ ...row, migrated: true }));`,
|
||||
`}`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
|
||||
function makeMarkdownAnswer(targetChars: number): string {
|
||||
const sections: string[] = [];
|
||||
let length = 0;
|
||||
let n = 1;
|
||||
while (length < targetChars) {
|
||||
const section = markdownSection(n);
|
||||
sections.push(section);
|
||||
length += section.length + 2;
|
||||
n += 1;
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
|
||||
function makeFiller(bytes: number): string {
|
||||
const unit = "Perf filler content for the synthetic getPage tool output. ";
|
||||
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TurnToolCall {
|
||||
toolCallId: string;
|
||||
toolName: "getPage";
|
||||
input: { pageId: string };
|
||||
output: { id: string; title: string; content: string };
|
||||
}
|
||||
|
||||
export interface TurnStep {
|
||||
reasoningText: string;
|
||||
tool: TurnToolCall;
|
||||
}
|
||||
|
||||
export interface TurnScript {
|
||||
steps: TurnStep[];
|
||||
answerText: string;
|
||||
/** Approximate reasoning tokens for the whole turn (chars / 4). */
|
||||
reasoningTokens: number;
|
||||
/** Approximate context size after this turn, in tokens. */
|
||||
contextTokens: number;
|
||||
maxContextTokens: number;
|
||||
/** Actual generated visible chars: reasoning + tool outputs + answer. */
|
||||
totalChars: number;
|
||||
/** totalChars / 4, rounded. */
|
||||
approxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
|
||||
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
|
||||
*/
|
||||
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
|
||||
const steps: TurnStep[] = [];
|
||||
let reasoningChars = 0;
|
||||
let toolChars = 0;
|
||||
for (let i = 0; i < config.steps; i++) {
|
||||
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
|
||||
const content = makeFiller(config.toolOutputBytes);
|
||||
reasoningChars += reasoningText.length;
|
||||
toolChars += content.length;
|
||||
steps.push({
|
||||
reasoningText,
|
||||
tool: {
|
||||
toolCallId: `${idPrefix}-call-${i + 1}`,
|
||||
toolName: "getPage",
|
||||
input: { pageId: "page-1" },
|
||||
output: { id: "page-1", title: "Perf test page", content },
|
||||
},
|
||||
});
|
||||
}
|
||||
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
|
||||
const totalChars = reasoningChars + toolChars + answerText.length;
|
||||
return {
|
||||
steps,
|
||||
answerText,
|
||||
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
|
||||
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
maxContextTokens: 200_000,
|
||||
totalChars,
|
||||
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario A: persisted rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Number of user+assistant pairs the preset is split across for history. */
|
||||
const HISTORY_TURNS = 3;
|
||||
|
||||
const USER_PROMPTS = [
|
||||
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
|
||||
"Now cross-check the migration matrix against the summary and list every mismatch.",
|
||||
"Собери финальный план миграции с оценкой рисков по каждой области.",
|
||||
];
|
||||
|
||||
/** Persisted UIMessage parts for one finished assistant turn. */
|
||||
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
|
||||
const parts: unknown[] = [];
|
||||
for (const step of script.steps) {
|
||||
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
|
||||
parts.push({
|
||||
type: `tool-${step.tool.toolName}`,
|
||||
toolCallId: step.tool.toolCallId,
|
||||
state: "output-available",
|
||||
input: step.tool.input,
|
||||
output: step.tool.output,
|
||||
});
|
||||
}
|
||||
parts.push({ type: "text", text: script.answerText, state: "done" });
|
||||
return parts as UIMessage["parts"];
|
||||
}
|
||||
|
||||
export interface PersistedFixture {
|
||||
rows: IAiChatMessageRow[];
|
||||
totalChars: number;
|
||||
approxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the preset as a finished 3-turn transcript: user row + assistant
|
||||
* row per turn, with the preset's steps/answer split across the assistant turns.
|
||||
* Approximate accounting — the actual totals are reported back for display.
|
||||
*/
|
||||
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
|
||||
const rows: IAiChatMessageRow[] = [];
|
||||
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
|
||||
let totalChars = 0;
|
||||
|
||||
for (let t = 0; t < HISTORY_TURNS; t++) {
|
||||
// Distribute steps as evenly as possible (earlier turns get the remainder).
|
||||
const stepsForTurn =
|
||||
Math.floor(config.steps / HISTORY_TURNS) +
|
||||
(t < config.steps % HISTORY_TURNS ? 1 : 0);
|
||||
const turnConfig: TurnConfig = {
|
||||
steps: Math.max(1, stepsForTurn),
|
||||
reasoningTokensPerStep: config.reasoningTokensPerStep,
|
||||
toolOutputBytes: config.toolOutputBytes,
|
||||
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
|
||||
};
|
||||
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
|
||||
totalChars += script.totalChars;
|
||||
|
||||
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
|
||||
rows.push({
|
||||
id: `perf-row-u${t + 1}`,
|
||||
role: "user",
|
||||
content: userText,
|
||||
metadata: null,
|
||||
createdAt: new Date(baseTime + t * 60_000).toISOString(),
|
||||
});
|
||||
rows.push({
|
||||
id: `perf-row-a${t + 1}`,
|
||||
role: "assistant",
|
||||
content: script.answerText,
|
||||
metadata: {
|
||||
parts: scriptToPersistedParts(script),
|
||||
usage: { reasoningTokens: script.reasoningTokens },
|
||||
contextTokens: script.contextTokens,
|
||||
maxContextTokens: script.maxContextTokens,
|
||||
finishReason: "stop",
|
||||
},
|
||||
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalChars,
|
||||
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario B: SSE stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Streaming delta size in chars (reasoning/answer text is split into these). */
|
||||
const DELTA_CHARS = 200;
|
||||
|
||||
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
|
||||
const deltas: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
deltas.push(text.slice(i, i + size));
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
|
||||
interface SseFrame {
|
||||
data: string;
|
||||
chars: number;
|
||||
}
|
||||
|
||||
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
|
||||
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
|
||||
* (excluding the final `data: [DONE]` terminator, appended by the pump).
|
||||
*/
|
||||
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
|
||||
const frames: SseFrame[] = [];
|
||||
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
|
||||
|
||||
script.steps.forEach((step, i) => {
|
||||
frames.push(frame({ type: "start-step" }));
|
||||
const reasoningId = `${messageId}-r${i + 1}`;
|
||||
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
|
||||
for (const delta of splitDeltas(step.reasoningText)) {
|
||||
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
|
||||
}
|
||||
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
|
||||
|
||||
const { toolCallId, toolName, input, output } = step.tool;
|
||||
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
|
||||
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
|
||||
// The tool result arrives as ONE chunk, like the real server sends it.
|
||||
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
|
||||
frames.push(frame({ type: "finish-step" }));
|
||||
});
|
||||
|
||||
// Final step: the markdown answer.
|
||||
frames.push(frame({ type: "start-step" }));
|
||||
const textId = `${messageId}-answer`;
|
||||
frames.push(frame({ type: "text-start", id: textId }));
|
||||
for (const delta of splitDeltas(script.answerText)) {
|
||||
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
|
||||
}
|
||||
frames.push(frame({ type: "text-end", id: textId }));
|
||||
frames.push(frame({ type: "finish-step" }));
|
||||
|
||||
frames.push(
|
||||
frame({
|
||||
type: "finish",
|
||||
messageMetadata: {
|
||||
usage: { reasoningTokens: script.reasoningTokens },
|
||||
contextTokens: script.contextTokens,
|
||||
maxContextTokens: script.maxContextTokens,
|
||||
finishReason: "stop",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return frames;
|
||||
}
|
||||
|
||||
export interface LiveStreamSettings {
|
||||
script: TurnScript;
|
||||
/** Delay between SSE chunks (one chunk per tick). */
|
||||
chunkIntervalMs: number;
|
||||
/** Progress callback: cumulative emitted chunk count and visible chars. */
|
||||
onProgress?: (chunks: number, chars: number) => void;
|
||||
/** Fired once after the `[DONE]` terminator is enqueued. */
|
||||
onDone?: () => void;
|
||||
/** Fired if the client aborted the stream (Stop button). */
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
|
||||
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
|
||||
*/
|
||||
export function buildSseResponse(
|
||||
settings: LiveStreamSettings,
|
||||
signal?: AbortSignal | null,
|
||||
): Response {
|
||||
const messageId = `m-live-${Date.now()}`;
|
||||
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
let emittedChars = 0;
|
||||
let timer: number | undefined;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const stopPump = () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const pump = () => {
|
||||
timer = undefined;
|
||||
if (signal?.aborted) {
|
||||
stopPump();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed/cancelled — nothing to do.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (index >= frames.length) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
} catch {
|
||||
// Cancelled mid-flight.
|
||||
}
|
||||
settings.onDone?.();
|
||||
return;
|
||||
}
|
||||
const next = frames[index];
|
||||
index += 1;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(next.data));
|
||||
} catch {
|
||||
stopPump();
|
||||
return;
|
||||
}
|
||||
emittedChars += next.chars;
|
||||
settings.onProgress?.(index, emittedChars);
|
||||
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||
};
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
stopPump();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Reader already cancelled.
|
||||
}
|
||||
settings.onAbort?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||
},
|
||||
cancel() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
"x-vercel-ai-ui-message-stream": "v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// window.fetch patch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentLiveSettings: LiveStreamSettings | null = null;
|
||||
|
||||
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
|
||||
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
|
||||
currentLiveSettings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
|
||||
* get the synthetic SSE Response; everything else passes through untouched.
|
||||
*/
|
||||
export function installAiChatStreamFetchPatch(): void {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (url.includes("/api/ai-chat/stream")) {
|
||||
const settings = currentLiveSettings;
|
||||
if (!settings) {
|
||||
return Promise.resolve(
|
||||
new Response("perf harness: no live stream configured", { status: 500 }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
}
|
||||
@@ -257,6 +257,8 @@
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copy chat": "Copy chat",
|
||||
"Dock to sidebar": "Dock to sidebar",
|
||||
"Undock": "Undock",
|
||||
"Copied": "Copied",
|
||||
"Failed to export chat": "Failed to export chat",
|
||||
"Duplicate": "Duplicate",
|
||||
@@ -356,6 +358,7 @@
|
||||
"Strike": "Strike",
|
||||
"Code": "Code",
|
||||
"Spoiler": "Spoiler",
|
||||
"Stress": "Stress",
|
||||
"Comment": "Comment",
|
||||
"Text": "Text",
|
||||
"Heading 1": "Heading 1",
|
||||
@@ -1219,8 +1222,8 @@
|
||||
"Commented": "Commented",
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
||||
"AI agent {{name}}": "AI agent {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"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.",
|
||||
@@ -1271,6 +1274,10 @@
|
||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||
"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",
|
||||
"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",
|
||||
"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)",
|
||||
@@ -1322,6 +1329,7 @@
|
||||
"Move to space": "Move to space",
|
||||
"Float left (wrap text)": "Float left (wrap text)",
|
||||
"Float right (wrap text)": "Float right (wrap text)",
|
||||
"Inline (side by side)": "Inline (side by side)",
|
||||
"Switch to tree": "Switch to tree",
|
||||
"Switch to flat list": "Switch to flat list",
|
||||
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||
@@ -1369,5 +1377,13 @@
|
||||
"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)"
|
||||
"Connecting… (read-only)": "Connecting… (read-only)",
|
||||
"Apply": "Apply",
|
||||
"Applied": "Applied",
|
||||
"Suggestion applied": "Suggestion applied",
|
||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||
"Dismiss": "Dismiss",
|
||||
"Suggestion dismissed": "Suggestion dismissed",
|
||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
"Strike": "Перечёркнутый",
|
||||
"Code": "Код",
|
||||
"Spoiler": "Спойлер",
|
||||
"Stress": "Ударение",
|
||||
"Comment": "Комментарий",
|
||||
"Text": "Текст",
|
||||
"Heading 1": "Заголовок 1",
|
||||
@@ -392,6 +393,17 @@
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -715,13 +727,16 @@
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Dock to sidebar": "Закрепить в боковой панели",
|
||||
"Undock": "Открепить",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
@@ -1175,6 +1190,7 @@
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Inline (side by side)": "В ряд",
|
||||
"Switch to tree": "Переключить на дерево",
|
||||
"Switch to flat list": "Переключить на плоский список",
|
||||
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||
@@ -1224,5 +1240,13 @@
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
||||
"Connecting… (read-only)": "Подключение… (только чтение)",
|
||||
"Apply": "Применить",
|
||||
"Applied": "Применено",
|
||||
"Suggestion applied": "Предложение применено",
|
||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||
"Dismiss": "Не применять",
|
||||
"Suggestion dismissed": "Предложение отклонено",
|
||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@ import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// The export request uses `responseType: "blob"`, so a server error body arrives
|
||||
// as a Blob rather than parsed JSON — `err.response?.data.message` is therefore
|
||||
// always undefined. Read and parse the blob to surface the real error message.
|
||||
async function extractExportError(err: any): Promise<string> {
|
||||
const data = err?.response?.data;
|
||||
if (data instanceof Blob) {
|
||||
try {
|
||||
const json = JSON.parse(await data.text());
|
||||
return json?.message ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return data?.message ?? err?.message ?? "";
|
||||
}
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
type: "space" | "page";
|
||||
@@ -52,8 +68,9 @@ export default function ExportModal({
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = await extractExportError(err);
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
message: t("Export failed") + (message ? `: ${message}` : ""),
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
|
||||
@@ -12,6 +12,7 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -53,7 +54,13 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
||||
// (which flips mobileOpened) must be the one visible across the
|
||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
||||
// regression from the initial sm->md navbar change).
|
||||
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -63,7 +70,7 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
@@ -87,7 +89,13 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
||||
// room for content — the settings tables (Members/…) overflow the offset
|
||||
// content area on tablet (~768px) and clip the Role/actions columns
|
||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
||||
// drawer across the whole tablet band frees the full width for content
|
||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
||||
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
@@ -96,7 +104,7 @@ export default function GlobalAppShell({
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 420,
|
||||
breakpoint: "sm",
|
||||
breakpoint: "md",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
@@ -106,6 +114,7 @@ export default function GlobalAppShell({
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar
|
||||
id={APP_NAVBAR_ID}
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
||||
import { atom } from "jotai";
|
||||
|
||||
// Stable DOM id set on the app-shell navbar (<AppShell.Navbar>). Declared here —
|
||||
// alongside the sidebar atoms — rather than in the chat window so the AI chat
|
||||
// window can reference the navbar by id without importing the app shell (which
|
||||
// would create a shell -> chat-window -> shell import cycle).
|
||||
export const APP_NAVBAR_ID = "app-shell-navbar";
|
||||
|
||||
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
|
||||
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
|
||||
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
|
||||
// (the round-1 regression of #292). Kept here so the shell and the header share
|
||||
// one constant the compiler enforces, instead of three hand-synced string literals.
|
||||
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
|
||||
|
||||
export const mobileSidebarAtom = atom<boolean>(false);
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||
import { avatarStyle } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
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) {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const utils = render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<AgentAvatarStack {...props} />
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe("AgentAvatarStack", () => {
|
||||
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
});
|
||||
|
||||
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
|
||||
expect(screen.getByText("🔬")).toBeDefined();
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
// Label: bold role name + dimmed "· launcher".
|
||||
expect(screen.getByText("Researcher")).toBeDefined();
|
||||
expect(screen.getByText(/·/)).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", () => {
|
||||
renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
showName: false,
|
||||
});
|
||||
|
||||
// The agent glyph is still rendered...
|
||||
expect(screen.getByText("🔬")).toBeDefined();
|
||||
// ...but neither the agent NOR the launcher inline name label is rendered
|
||||
// (they live only in the hover tooltip, which is not mounted in the initial
|
||||
// DOM) — guards against suppressing only the agent name and leaking the
|
||||
// launcher name.
|
||||
expect(screen.queryByText("Researcher")).toBeNull();
|
||||
expect(screen.queryByText("Alice")).toBeNull();
|
||||
});
|
||||
|
||||
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "AI agent", avatarUrl: null },
|
||||
launcher: { name: "Bob", avatarUrl: null },
|
||||
aiChatId: "chat-2",
|
||||
});
|
||||
|
||||
// No avatarUrl and no emoji => sparkles glyph (priority 3).
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
|
||||
expect(screen.getByText("AI agent")).toBeDefined();
|
||||
expect(screen.getByText("Bob")).toBeDefined();
|
||||
});
|
||||
|
||||
it("external MCP: agent avatar only, NO human launcher badge", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||
launcher: null,
|
||||
aiChatId: null,
|
||||
});
|
||||
|
||||
// avatarUrl provided (priority 1) => not the sparkles fallback.
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
||||
// No human behind => no "·" separator is rendered.
|
||||
expect(screen.queryByText(/·/)).toBeNull();
|
||||
// No internal chat => the stack is not an interactive deep-link button.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("click deep-links into the chat when aiChatId is present", () => {
|
||||
const { store } = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
|
||||
});
|
||||
|
||||
it("click is a no-op / not interactive without a chat target", () => {
|
||||
const onActivate = vi.fn();
|
||||
renderStack({
|
||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||
launcher: null,
|
||||
aiChatId: null,
|
||||
onActivate,
|
||||
});
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Box, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
|
||||
// launched it). Both are computed server-side (#300) so the client never branches
|
||||
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
|
||||
export interface AgentInfo {
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
export interface LauncherInfo {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
const GLYPH_SIZE = 38;
|
||||
const LAUNCHER_SIZE = 22;
|
||||
// How far the launcher avatar sticks out past the agent's top-right corner — it
|
||||
// sits as a small badge over that corner (above the glyph) and stays fully visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
/**
|
||||
* The front avatar. Image-source priority (#300):
|
||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
|
||||
*/
|
||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
if (agent.avatarUrl) {
|
||||
return (
|
||||
<CustomAvatar
|
||||
size={GLYPH_SIZE}
|
||||
avatarUrl={agent.avatarUrl}
|
||||
name={agent.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
|
||||
// and split angle all hashed from the agent name via avatarStyle — see
|
||||
// @/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>
|
||||
{agent.emoji}
|
||||
</span>
|
||||
) : (
|
||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AgentAvatarStackProps {
|
||||
agent: AgentInfo;
|
||||
// null/absent => external MCP (front agent avatar only, no human behind).
|
||||
launcher?: LauncherInfo | null;
|
||||
// Deep-links into the internal AI chat when present (null for external MCP).
|
||||
aiChatId?: string | null;
|
||||
// Fired after the stack deep-links into its chat, so the caller can react
|
||||
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
|
||||
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
|
||||
onActivate?: () => void;
|
||||
// Whether to render the inline name label next to the avatars (default true).
|
||||
// Set false when the caller renders the name itself (e.g. the comment row).
|
||||
showName?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "agent avatar stack" (#300): the AGENT glyph, and — for an internal AI
|
||||
* 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
|
||||
* 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
|
||||
* enclosing row handler.
|
||||
*/
|
||||
export function AgentAvatarStack({
|
||||
agent,
|
||||
launcher,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
showName = true,
|
||||
}: AgentAvatarStackProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const clickable = !!aiChatId;
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching chats must start with a clean composer — clear any unsent draft
|
||||
// so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
// Internal chat => "role on behalf of person"; external MCP => just the agent.
|
||||
const tooltip = launcher
|
||||
? t("AI agent «{{role}}» on behalf of {{person}}", {
|
||||
role: agent.name,
|
||||
person: launcher.name,
|
||||
})
|
||||
: t("AI agent {{name}}", { name: agent.name });
|
||||
|
||||
// The container is only enlarged when there is a launcher to overhang; with no
|
||||
// human behind it stays tight at the agent glyph size.
|
||||
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
|
||||
|
||||
const stack = (
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
width: stackSize,
|
||||
height: stackSize,
|
||||
flexShrink: 0,
|
||||
// Center the (in-flow) agent glyph vertically so it lines up with its
|
||||
// name label; the absolutely-positioned launcher is unaffected by flex.
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: clickable ? "pointer" : undefined,
|
||||
}}
|
||||
{...(clickable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{launcher && (
|
||||
// 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
|
||||
size={LAUNCHER_SIZE}
|
||||
avatarUrl={launcher.avatarUrl}
|
||||
name={launcher.name}
|
||||
style={{ border: "2px solid var(--mantine-color-body)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* The agent glyph keeps its own size (flex-centered in the container); the
|
||||
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
}}
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{stack}
|
||||
</Tooltip>
|
||||
{showName && (
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
{launcher && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
||||
{launcher.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentAvatarStack;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AiAgentBadge } from "./ai-agent-badge";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<AiAgentBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||
// assertable. Returns the store and spies.
|
||||
function setupClickable() {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onActivate = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||
}
|
||||
|
||||
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||
}
|
||||
|
||||
describe("AiAgentBadge", () => {
|
||||
it("renders the AI-agent label", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||
const badge = screen.getByRole("button");
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge.textContent).toContain("AI-agent");
|
||||
});
|
||||
|
||||
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||
fireEvent.click(badge);
|
||||
expectDeepLinked(store, onActivate);
|
||||
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])(
|
||||
"keyboard %j activates the deep-link (same side effects as click)",
|
||||
(key) => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key });
|
||||
expectDeepLinked(store, onActivate);
|
||||
},
|
||||
);
|
||||
|
||||
it("an unrelated key does NOT activate the badge", () => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key: "Tab" });
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ aiChatId: null }, {}])(
|
||||
"is a plain non-clickable label without a chat target (%o)",
|
||||
(props) => {
|
||||
renderBadge({ authorName: "Bot", ...props });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No interactive role is exposed when there is no chat to deep-link into.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||
// context (e.g. the page-history row closes the history modal) so this generic
|
||||
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||
* page-history list and the comments sidebar.
|
||||
*
|
||||
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||
* also trigger an enclosing row's click handler.
|
||||
*/
|
||||
export function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
}: AiAgentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching to another chat must start with a clean composer — clear any
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside a row's
|
||||
// UnstyledButton; expose it as an accessible button via
|
||||
// role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAgentBadge;
|
||||
@@ -18,6 +18,18 @@ export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the AI chat window is docked into the sidebar (page-tree navbar).
|
||||
* Persisted to localStorage so the docked/floating mode survives a full page
|
||||
* reload and close/reopen. `false` = the default floating window. When docked,
|
||||
* the SAME window instance pins itself to the live bounding rect of the app
|
||||
* navbar (see AiChatWindow), overlaying the page tree.
|
||||
*/
|
||||
export const aiChatWindowDockedAtom = atomWithStorage<boolean>(
|
||||
"ai-chat-window-docked",
|
||||
false,
|
||||
);
|
||||
|
||||
/**
|
||||
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
||||
* the server creates the chat row on the first streamed message and echoes its
|
||||
|
||||
@@ -35,6 +35,35 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Docked into the sidebar: the window pins itself to the live navbar rect
|
||||
(position/size supplied inline). It sits flush inside the navbar area, so we
|
||||
drop the floating chrome — no border-radius, drop shadow or user resize — and
|
||||
remove the floating min/max clamps so the size is driven ENTIRELY by the
|
||||
inline navbar rect (which may be narrower than the floating min-width of
|
||||
300px, e.g. the 220px navbar minimum). z-index 105 keeps it above the page
|
||||
tree (navbar 101) but below the header and Mantine overlays. */
|
||||
.docked {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
resize: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Drop-zone highlight shown over the navbar bounds while a floating window is
|
||||
dragged onto the sidebar. Sits just above the docked window (106) so the cue
|
||||
is visible; purely decorative, so it never intercepts pointer events. */
|
||||
.dockHighlight {
|
||||
position: fixed;
|
||||
z-index: 106;
|
||||
border: 2px dashed light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-4));
|
||||
background: light-dark(rgba(34, 139, 230, 0.08), rgba(34, 139, 230, 0.14));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* When minimized the window collapses to the header only: auto height, no
|
||||
resize. Width/height inline values are overridden. */
|
||||
.minimized {
|
||||
|
||||
@@ -13,21 +13,29 @@ import {
|
||||
IconChevronDown,
|
||||
IconCopy,
|
||||
IconGripVertical,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconMinus,
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useLocation, useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatWindowGeomAtom,
|
||||
aiChatWindowDockedAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
@@ -46,6 +54,11 @@ import {
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
import {
|
||||
isPointWithinRect,
|
||||
isNavbarRectVisible,
|
||||
type NavbarRect,
|
||||
} from "@/features/ai-chat/utils/dock-helpers.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -112,6 +125,28 @@ function clampGeom(g: {
|
||||
};
|
||||
}
|
||||
|
||||
// Live bounding rect of the app-shell navbar (the page-tree sidebar), by its
|
||||
// stable id. Returns null when the navbar is absent OR collapsed: Mantine
|
||||
// collapses the navbar by translating it off-screen (its right edge lands at or
|
||||
// left of the viewport), so a zero-size or off-screen rect is treated as "no
|
||||
// navbar" — the docked window then falls back to floating instead of pinning to
|
||||
// an off-screen box. Reads the DOM, so call it inside effects / handlers only.
|
||||
function getNavbarRect(): NavbarRect | null {
|
||||
const el = document.getElementById(APP_NAVBAR_ID);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
// Off-screen/collapsed navbar (visibility predicate extracted + unit-tested).
|
||||
if (!isNavbarRectVisible(r)) return null;
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
}
|
||||
|
||||
// Whether a viewport point falls within the (visible) navbar bounds. Used to
|
||||
// decide dock-on-drop and undock-on-drag-out. The point-in-rect math is the pure
|
||||
// isPointWithinRect helper (unit-tested); this only supplies the live rect.
|
||||
function isPointerOverNavbar(x: number, y: number): boolean {
|
||||
return isPointWithinRect(x, y, getNavbarRect());
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
||||
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
||||
@@ -138,6 +173,43 @@ export default function AiChatWindow() {
|
||||
const minimizedRef = useRef(minimized);
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
// Docked-into-sidebar mode (#276). Persisted so it survives reload + reopen.
|
||||
// When docked the SAME window instance pins itself to the navbar rect below.
|
||||
const [docked, setDocked] = useAtom(aiChatWindowDockedAtom);
|
||||
// Mirror for the useCallback([]) drag handlers (same reason as minimizedRef).
|
||||
const dockedRef = useRef(docked);
|
||||
dockedRef.current = docked;
|
||||
// Live navbar rect the docked window is pinned to; synced before paint by the
|
||||
// layout effect below. null = navbar absent/collapsed -> floating fallback.
|
||||
const [dockRect, setDockRect] = useState<NavbarRect | null>(null);
|
||||
// While dragging a FLOATING window over the navbar: show the drop-zone hint.
|
||||
const [dockHint, setDockHint] = useState(false);
|
||||
// Live window position during a drag. Normally the drag is fully imperative
|
||||
// (el.style updated per mousemove, no re-render — matching the pre-#276
|
||||
// behavior), so this stays null. It is set ONLY at a navbar-boundary crossing:
|
||||
// that crossing already forces a re-render (dockHint flips), which would
|
||||
// otherwise re-apply the committed geom and snap the box back for a frame — so
|
||||
// we hand the render the live position at that instant instead. Cleared on drop.
|
||||
const [dragPos, setDragPos] = useState<{ left: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Subscribed (read-only) so this component re-renders — and the dockRect-sync
|
||||
// effect below re-runs — when the sidebar is collapsed/expanded via the header
|
||||
// toggle. Mantine collapses the navbar with a transform (width/border-box
|
||||
// unchanged), so the navbar's ResizeObserver never fires; these deps + the
|
||||
// navbar `transitionend` listener are what re-measure the rect on toggle.
|
||||
const [desktopSidebarOpen] = useAtom(desktopSidebarAtom);
|
||||
const [mobileSidebarOpen] = useAtom(mobileSidebarAtom);
|
||||
|
||||
// Dock mode is only EFFECTIVE when a navbar rect is available. When docked but
|
||||
// the navbar is absent/collapsed (dockRect === null) the window falls back to
|
||||
// the floating look, so effects gated on "is docked" must use this — not the
|
||||
// raw `docked` flag — or a fallback-floating window would behave half-docked.
|
||||
const useDock = docked && dockRect !== null;
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); persisted to localStorage so a
|
||||
// drag/resize survives a full page reload (and close/reopen). `null` means
|
||||
@@ -325,6 +397,47 @@ export default function AiChatWindow() {
|
||||
setMinimized(false);
|
||||
}, [windowOpen]);
|
||||
|
||||
// While docked, keep the window pinned to the navbar's LIVE rect. useLayoutEffect
|
||||
// (not useEffect) so dockRect is measured/committed before the browser paints,
|
||||
// avoiding a first-frame jump. Re-measures on: navbar size changes (manual
|
||||
// sidebar resize -> ResizeObserver), viewport resize (window `resize`), and
|
||||
// route changes that swap the navbar width (space <-> shared/global sidebar are
|
||||
// 300px vs sidebarWidth -> re-run on location.pathname). If the navbar is
|
||||
// absent/collapsed, getNavbarRect() returns null and the render falls back to
|
||||
// the floating look (the window does NOT vanish).
|
||||
useLayoutEffect(() => {
|
||||
if (!windowOpen || !docked) return;
|
||||
const sync = () => setDockRect(getNavbarRect());
|
||||
sync();
|
||||
const navbar = document.getElementById(APP_NAVBAR_ID);
|
||||
let ro: ResizeObserver | null = null;
|
||||
if (navbar) {
|
||||
ro = new ResizeObserver(sync);
|
||||
ro.observe(navbar);
|
||||
// Collapsing/expanding the sidebar translates the navbar off-screen WITHOUT
|
||||
// changing its width/border-box, so the ResizeObserver never fires and the
|
||||
// effect's initial sync() may measure mid-transition (stale). Re-measure at
|
||||
// transitionend so getNavbarRect() sees the final position: null once the
|
||||
// navbar is translated off (right <= 0) -> fall back to floating; the real
|
||||
// rect once it slides back -> re-dock. The sidebar-state deps below force
|
||||
// this effect (and the immediate sync) to re-run on each toggle, covering
|
||||
// the reduced-motion case where no transition -> no transitionend.
|
||||
navbar.addEventListener("transitionend", sync);
|
||||
}
|
||||
window.addEventListener("resize", sync);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
navbar?.removeEventListener("transitionend", sync);
|
||||
window.removeEventListener("resize", sync);
|
||||
};
|
||||
}, [
|
||||
windowOpen,
|
||||
docked,
|
||||
location.pathname,
|
||||
desktopSidebarOpen,
|
||||
mobileSidebarOpen,
|
||||
]);
|
||||
|
||||
// Auto-collapse the window into its header as soon as the user interacts with
|
||||
// anything outside it (clicks the page/editor). Armed ONLY while the window is
|
||||
// open and expanded, so it never fires repeatedly and never collapses on the
|
||||
@@ -333,7 +446,12 @@ export default function AiChatWindow() {
|
||||
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
|
||||
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
// Disabled while EFFECTIVELY docked: a docked window intentionally overlays
|
||||
// the page tree, so a click on the surrounding page must NOT auto-collapse
|
||||
// it. Gated on useDock (not raw `docked`) so a fallback-floating window
|
||||
// (docked but navbar absent/collapsed) still auto-collapses like a normal
|
||||
// floating window.
|
||||
if (!windowOpen || minimized || useDock) return;
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
|
||||
setMinimized(true);
|
||||
@@ -341,13 +459,18 @@ export default function AiChatWindow() {
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
}, [windowOpen, minimized, useDock]);
|
||||
|
||||
// Persist the user's resize into state so it survives close/reopen. Skipped
|
||||
// while minimized so the collapsed (auto) height is never captured. The
|
||||
// equality guard avoids an update loop.
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
// Disabled while EFFECTIVELY docked: in dock mode the size is driven by the
|
||||
// navbar rect, not a user resize, so we must not capture the navbar-sized box
|
||||
// into the persisted floating geom (it would clobber the remembered floating
|
||||
// size). Gated on useDock so a fallback-floating window (docked but navbar
|
||||
// absent) still persists user resizes like a normal floating window.
|
||||
if (!windowOpen || minimized || useDock) return;
|
||||
const el = winRef.current;
|
||||
// `geom` is in the deps so this re-runs once geometry is settled and the
|
||||
// window is actually rendered (on the first open `geom` is still null on the
|
||||
@@ -365,18 +488,30 @@ export default function AiChatWindow() {
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [windowOpen, minimized, geom !== null]);
|
||||
}, [windowOpen, minimized, useDock, geom !== null]);
|
||||
|
||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||
// Ignore drags that originate on a button (minimize/close/new chat).
|
||||
// Ignore drags that originate on a button (dock/minimize/close/new chat).
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
const el = winRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
// Starting position: the element's current inline left/top, whether it was
|
||||
// placed by the floating geom or pinned to the navbar rect (both render as
|
||||
// "<n>px"). getBoundingClientRect would work too, but the inline values keep
|
||||
// the drag math identical to the pre-#276 floating behavior.
|
||||
const ol = parseFloat(el.style.left) || 0;
|
||||
const ot = parseFloat(el.style.top) || 0;
|
||||
// Freeze the box size for the drag: a docked window keeps its navbar size
|
||||
// while being pulled out, a floating window keeps its own size.
|
||||
const dragW = el.offsetWidth;
|
||||
const dragH = el.offsetHeight;
|
||||
|
||||
// Latch for the drop-zone hint so setState fires only when the pointer
|
||||
// actually crosses the navbar boundary, not on every mousemove.
|
||||
let overNavbar = false;
|
||||
|
||||
const move = (ev: MouseEvent): void => {
|
||||
let nl = ol + (ev.clientX - sx);
|
||||
@@ -385,20 +520,58 @@ export default function AiChatWindow() {
|
||||
// with position: fixed) with an 8px margin.
|
||||
nl = Math.max(
|
||||
EDGE_MARGIN,
|
||||
Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN),
|
||||
Math.min(nl, window.innerWidth - dragW - EDGE_MARGIN),
|
||||
);
|
||||
nt = Math.max(
|
||||
EDGE_MARGIN,
|
||||
Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN),
|
||||
Math.min(nt, window.innerHeight - dragH - EDGE_MARGIN),
|
||||
);
|
||||
el.style.left = `${nl}px`;
|
||||
el.style.top = `${nt}px`;
|
||||
// Drop-zone highlight: only meaningful when dragging a FLOATING window in
|
||||
// to dock it (a docked window is already over the navbar).
|
||||
if (!dockedRef.current) {
|
||||
const nowOver = isPointerOverNavbar(ev.clientX, ev.clientY);
|
||||
if (nowOver !== overNavbar) {
|
||||
overNavbar = nowOver;
|
||||
// This re-render would re-apply the committed geom; hand it the live
|
||||
// position so the box does not snap back for a frame.
|
||||
setDragPos({ left: nl, top: nt });
|
||||
setDockHint(nowOver);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const up = (ev: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
setDragPos(null);
|
||||
setDockHint(false);
|
||||
const overNavbarNow = isPointerOverNavbar(ev.clientX, ev.clientY);
|
||||
|
||||
if (dockedRef.current) {
|
||||
// Docked window: releasing OUTSIDE the navbar pops it out as a floating
|
||||
// window at the drop point (clamped to the viewport). Released over the
|
||||
// navbar -> stays docked (a header click is a no-op here). The response
|
||||
// stream is untouched — only the mode flag / geom change.
|
||||
if (!overNavbarNow) {
|
||||
const el2 = winRef.current;
|
||||
const dropLeft = el2 ? parseFloat(el2.style.left) || 0 : 0;
|
||||
const dropTop = el2 ? parseFloat(el2.style.top) || 0 : 0;
|
||||
setGeom((prev) =>
|
||||
clampGeom({
|
||||
...(prev ?? computeInitialGeom()),
|
||||
left: dropLeft,
|
||||
top: dropTop,
|
||||
}),
|
||||
);
|
||||
setDocked(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Floating window.
|
||||
// Treat a near-zero-movement press as a click (not a drag). When the
|
||||
// window is minimized, a header click expands it; nothing to persist
|
||||
// because the position did not change. minimizedRef avoids the stale
|
||||
@@ -410,6 +583,13 @@ export default function AiChatWindow() {
|
||||
setMinimized(false);
|
||||
return;
|
||||
}
|
||||
// Released over the navbar -> dock. The layout effect then pins the window
|
||||
// to the navbar rect; the last floating geom is left untouched so a later
|
||||
// undock/close restores the remembered floating placement.
|
||||
if (overNavbarNow) {
|
||||
setDocked(true);
|
||||
return;
|
||||
}
|
||||
const el2 = winRef.current;
|
||||
// Persist the final position back into state (preserving the size) so
|
||||
// re-renders keep it.
|
||||
@@ -432,6 +612,20 @@ export default function AiChatWindow() {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Dock/undock via the header button. Docking pins the window to the navbar;
|
||||
// undocking restores the floating window at its last remembered geom. On
|
||||
// undock we re-clamp that geom to the current viewport (matching drag-undock's
|
||||
// clampGeom) so a viewport shrink while docked can't leave the popped-out
|
||||
// window partly off-screen. The chat thread stays mounted across the toggle,
|
||||
// so a live stream is intact. dockedRef gives the live value inside this
|
||||
// useCallback([]) handler.
|
||||
const toggleDock = useCallback((): void => {
|
||||
if (dockedRef.current) {
|
||||
setGeom((prev) => (prev ? clampGeom(prev) : prev));
|
||||
}
|
||||
setDocked((d) => !d);
|
||||
}, [setDocked, setGeom]);
|
||||
|
||||
// Just toggle the flag. The `.minimized` CSS handles the collapsed height and
|
||||
// disables resize, and `.minimized .content` hides the body while keeping
|
||||
// ChatThread mounted (so an in-flight stream is not aborted).
|
||||
@@ -441,17 +635,45 @@ export default function AiChatWindow() {
|
||||
|
||||
if (!windowOpen || !geom) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={winRef}
|
||||
className={`${classes.window}${minimized ? ` ${classes.minimized}` : ""}`}
|
||||
style={{
|
||||
// `useDock` (computed above) is the EFFECTIVE dock state: docked AND a navbar
|
||||
// rect is available. If the navbar is absent/collapsed we keep the persisted
|
||||
// `docked` flag but render the floating look so the window never vanishes (it
|
||||
// re-docks once the navbar reappears — see the layout effect above). Minimize
|
||||
// is suppressed while actually docked.
|
||||
const showMinimized = minimized && !useDock;
|
||||
|
||||
// Position/size of the window this frame. `dragPos` (set only at a mid-drag
|
||||
// navbar-boundary crossing) overrides the committed position so the box does
|
||||
// not snap back for a frame when that crossing forces a re-render.
|
||||
const boxStyle = dockRect && useDock
|
||||
? {
|
||||
left: dockRect.left,
|
||||
top: dockRect.top,
|
||||
width: dockRect.width,
|
||||
height: dockRect.height,
|
||||
}
|
||||
: {
|
||||
left: geom.left,
|
||||
top: geom.top,
|
||||
width: geom.width,
|
||||
// Height omitted when minimized so the `.minimized` CSS auto-height wins.
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
height: showMinimized ? undefined : geom.height,
|
||||
};
|
||||
const style = dragPos
|
||||
? { ...boxStyle, left: dragPos.left, top: dragPos.top }
|
||||
: boxStyle;
|
||||
|
||||
// Drop-zone highlight over the navbar bounds while dragging a floating window
|
||||
// onto the sidebar. Rendered as a viewport-fixed sibling overlay (not inside
|
||||
// the moving window), so its position is independent of the drag.
|
||||
const hintRect = dockHint ? getNavbarRect() : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={winRef}
|
||||
className={`${classes.window}${showMinimized ? ` ${classes.minimized}` : ""}${useDock ? ` ${classes.docked}` : ""}`}
|
||||
style={style}
|
||||
>
|
||||
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||
@@ -471,11 +693,11 @@ export default function AiChatWindow() {
|
||||
is a plain, non-focusable label. */}
|
||||
<span
|
||||
className={classes.title}
|
||||
role={minimized ? "button" : undefined}
|
||||
tabIndex={minimized ? 0 : undefined}
|
||||
aria-label={minimized ? t("Expand") : undefined}
|
||||
role={showMinimized ? "button" : undefined}
|
||||
tabIndex={showMinimized ? 0 : undefined}
|
||||
aria-label={showMinimized ? t("Expand") : undefined}
|
||||
onKeyDown={
|
||||
minimized
|
||||
showMinimized
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
@@ -531,15 +753,39 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Dock/undock toggle. Effectively docked -> "Undock" (expand icon) pops
|
||||
the window back out to floating; floating -> "Dock to sidebar"
|
||||
(collapse icon) pins it into the navbar. The LABEL/icon reflect the
|
||||
EFFECTIVE state (useDock), consistent with the Minimize gate: when
|
||||
docked but the navbar is absent/collapsed the window renders floating,
|
||||
so an "Undock" label there would misdescribe a floating window. The
|
||||
action still toggles the raw `docked` atom. */}
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
title={t("Minimize")}
|
||||
aria-label={t("Minimize")}
|
||||
onClick={toggleMinimize}
|
||||
title={useDock ? t("Undock") : t("Dock to sidebar")}
|
||||
aria-label={useDock ? t("Undock") : t("Dock to sidebar")}
|
||||
onClick={toggleDock}
|
||||
>
|
||||
<IconMinus size={14} />
|
||||
{useDock ? (
|
||||
<IconLayoutSidebarLeftExpand size={14} />
|
||||
) : (
|
||||
<IconLayoutSidebarLeftCollapse size={14} />
|
||||
)}
|
||||
</button>
|
||||
{/* Minimize (collapse to header) makes no sense while docked — the
|
||||
window fills the navbar — so it is hidden in dock mode. */}
|
||||
{!useDock && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
title={t("Minimize")}
|
||||
aria-label={t("Minimize")}
|
||||
onClick={toggleMinimize}
|
||||
>
|
||||
<IconMinus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
@@ -641,12 +887,29 @@ export default function AiChatWindow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* resize affordance icon (drawn manually; native resizer is hidden) */}
|
||||
{!minimized && (
|
||||
{/* resize affordance icon (drawn manually; native resizer is hidden).
|
||||
Hidden while docked — the docked size follows the navbar, not a manual
|
||||
resize. */}
|
||||
{!showMinimized && !useDock && (
|
||||
<span className={classes.resizeHandle}>
|
||||
<IconArrowsDiagonal size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Drop-zone highlight over the navbar while dragging a floating window in
|
||||
to dock it. Sibling of the window (position: fixed) so it tracks the
|
||||
navbar bounds, not the moving window. */}
|
||||
{hintRect && (
|
||||
<div
|
||||
className={classes.dockHighlight}
|
||||
style={{
|
||||
left: hintRect.left,
|
||||
top: hintRect.top,
|
||||
width: hintRect.width,
|
||||
height: hintRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,8 +164,8 @@
|
||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||
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
|
||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
||||
inline itself (see reasoning-block.tsx). */
|
||||
margins. The streaming plain-text path that needs pre-wrap sets it
|
||||
per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
|
||||
}
|
||||
|
||||
.reasoningText p {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
@@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => {
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||
// the queue intact for the user), and which stop notice — if any — to show.
|
||||
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||
// message, and report both what the parent was told and whether the queue was
|
||||
// flushed (a resend to the sendMessage spy).
|
||||
function finishWith(flags: {
|
||||
isAbort?: boolean;
|
||||
isDisconnect?: boolean;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||
// not leave duplicate queue buttons in the DOM.
|
||||
cleanup();
|
||||
h.state.sendMessage.mockClear();
|
||||
const { onTurnFinished } = renderThread();
|
||||
// Populate the queue while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
...flags,
|
||||
});
|
||||
});
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||
finishWith({});
|
||||
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
// A clean finish shows no stop notice.
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||
finishWith({ isAbort: true });
|
||||
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||
// queue is preserved for the user to decide.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||
finishWith({ isDisconnect: true });
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText("Connection lost — the answer was interrupted."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||
finishWith({ isError: true });
|
||||
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
// isError clears the neutral notice (the error banner covers this case).
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the parent on EVERY terminal outcome", () => {
|
||||
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||
// every failure path alike.
|
||||
for (const flags of [
|
||||
{},
|
||||
{ isAbort: true },
|
||||
{ isDisconnect: true },
|
||||
{ isError: true },
|
||||
]) {
|
||||
const { onTurnFinished } = finishWith(flags);
|
||||
expect(onTurnFinished).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
|
||||
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", () => {
|
||||
const a = 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.
|
||||
*/
|
||||
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,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
@@ -155,8 +170,23 @@ function MessageItem({
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
// Absent state (persisted rows) and "done" both mean finalized.
|
||||
// `messageSignature` already includes each part's `state`, so the
|
||||
// streaming→done flip changes the row signature and re-renders this
|
||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||
// ALSO require the turn to be live: a part stranded at
|
||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||
const streaming =
|
||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
||||
return (
|
||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
||||
<ReasoningBlock
|
||||
key={index}
|
||||
text={text}
|
||||
tokens={reasoningTokens}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +275,11 @@ export function arePropsEqual(
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
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 { render } from "@testing-library/react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
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
|
||||
// regression test to model how the AI SDK hands back the SAME message object.
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
// Pass an explicit `id` when a test renders several rows at once.
|
||||
const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
|
||||
({ id, role: "assistant", parts }) as UIMessage;
|
||||
|
||||
describe("MessageList", () => {
|
||||
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"),
|
||||
).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 (
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
{messages.map((message, index) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
@@ -210,6 +210,13 @@ export default function MessageList({
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
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 && (
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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";
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -17,10 +24,15 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
import ReasoningBlock from "./reasoning-block";
|
||||
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.
|
||||
|
||||
function renderBlock(props: { text: string; tokens?: number }) {
|
||||
function renderBlock(props: {
|
||||
text: string;
|
||||
tokens?: number;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock {...props} />
|
||||
@@ -62,4 +74,68 @@ describe("ReasoningBlock", () => {
|
||||
// either way the text is present in the document.
|
||||
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 { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.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";
|
||||
|
||||
interface ReasoningBlockProps {
|
||||
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
|
||||
* 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. */
|
||||
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
|
||||
* 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 [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — ONLY here, not in the normal answer.
|
||||
// Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
|
||||
// 1. Collapsed -> never parse (#302): the html is only shown inside
|
||||
// <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
|
||||
// marked + DOMPurify storm.
|
||||
// 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
|
||||
// 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(
|
||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||
[trimmed],
|
||||
() =>
|
||||
open && trimmed && !streaming
|
||||
? renderChatMarkdown(collapseBlankLines(trimmed), {})
|
||||
: "",
|
||||
[open, trimmed, streaming],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
className={classes.reasoningText}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{trimmed}
|
||||
</Text>
|
||||
// Still streaming (or markdown yielded nothing): chunked plain text.
|
||||
// The wrapper carries the reasoningText styling; each chunk sets its
|
||||
// own pre-wrap inline (NOT on this div — see ai-chat.module.css).
|
||||
<div className={classes.reasoningText}>
|
||||
<StreamingPlainText text={trimmed} />
|
||||
</div>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
@@ -92,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||
// Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
|
||||
// props, default shallow compare), so a parent re-render during streaming of OTHER
|
||||
// content does not re-run the markdown parse for an already-finalized reasoning block.
|
||||
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
|
||||
// see :pageSlug — match the full path against the authenticated page route.
|
||||
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 () => {
|
||||
// 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.
|
||||
setWindowOpen(true);
|
||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||
if (pageId) {
|
||||
if (slugId) {
|
||||
try {
|
||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
||||
resolved = await getBoundChat(slugId); // null => fresh chat
|
||||
} catch {
|
||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export function useOpenAiChatForCurrentPage() {
|
||||
}, [
|
||||
windowOpen,
|
||||
activeChatId,
|
||||
pageId,
|
||||
slugId,
|
||||
setWindowOpen,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
|
||||
@@ -46,9 +46,11 @@ export async function getAiChatMessages(
|
||||
* 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.
|
||||
*/
|
||||
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", {
|
||||
pageId,
|
||||
pageId: slugId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isPointWithinRect,
|
||||
isNavbarRectVisible,
|
||||
type NavbarRect,
|
||||
} from "./dock-helpers.ts";
|
||||
|
||||
const NAVBAR: NavbarRect = { left: 0, top: 45, width: 300, height: 800 };
|
||||
|
||||
describe("isPointWithinRect", () => {
|
||||
it("returns true for a point inside the navbar", () => {
|
||||
expect(isPointWithinRect(150, 400, NAVBAR)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the boundary edges as inside (drop exactly on the edge docks)", () => {
|
||||
// Top-left corner and bottom-right corner are both inclusive.
|
||||
expect(isPointWithinRect(0, 45, NAVBAR)).toBe(true);
|
||||
expect(isPointWithinRect(300, 845, NAVBAR)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a point in the content area (to the right)", () => {
|
||||
expect(isPointWithinRect(500, 400, NAVBAR)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false above the navbar (in the header band)", () => {
|
||||
expect(isPointWithinRect(150, 10, NAVBAR)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the navbar rect is null (absent/collapsed)", () => {
|
||||
expect(isPointWithinRect(150, 400, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavbarRectVisible", () => {
|
||||
it("returns true for a normal on-screen navbar rect", () => {
|
||||
expect(isNavbarRectVisible({ width: 300, height: 800, right: 300 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for a zero-size rect (width or height 0)", () => {
|
||||
expect(isNavbarRectVisible({ width: 0, height: 800, right: 300 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isNavbarRectVisible({ width: 300, height: 0, right: 300 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the navbar is translated off-screen (right <= 0)", () => {
|
||||
expect(isNavbarRectVisible({ width: 300, height: 800, right: 0 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isNavbarRectVisible({ width: 300, height: 800, right: -50 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Pure geometry helper for the AI chat window dock/undock decision (#276). Kept
|
||||
// free of React and the DOM so it can be unit-tested in isolation (see
|
||||
// dock-helpers.test.ts). The DOM-reading getNavbarRect() lives in the window
|
||||
// component; this is only the point-in-rect math that decides dock-on-drop and
|
||||
// undock-on-drag-out from the measured navbar rect.
|
||||
|
||||
export type NavbarRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a viewport point (x, y) falls within `rect`. Edges are inclusive so a
|
||||
* drop exactly on the navbar boundary counts as "over the navbar". Returns false
|
||||
* when the rect is null (navbar absent/collapsed) so the caller falls back to the
|
||||
* floating behavior.
|
||||
*/
|
||||
export function isPointWithinRect(
|
||||
x: number,
|
||||
y: number,
|
||||
rect: NavbarRect | null,
|
||||
): boolean {
|
||||
if (!rect) return false;
|
||||
return (
|
||||
x >= rect.left &&
|
||||
x <= rect.left + rect.width &&
|
||||
y >= rect.top &&
|
||||
y <= rect.top + rect.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a measured navbar rect represents a VISIBLE navbar. Mantine collapses
|
||||
* the navbar by translating it off-screen (its right edge lands at or left of the
|
||||
* viewport) without changing its width/border-box, so a zero-size or off-screen
|
||||
* rect means "no navbar" — the docked window then falls back to floating instead
|
||||
* of pinning to an invisible box. Pure (no DOM) so it can be unit-tested; the
|
||||
* DOM-reading getNavbarRect() in the window component supplies the rect.
|
||||
*/
|
||||
export function isNavbarRectVisible(r: {
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
}): boolean {
|
||||
return !(r.width === 0 || r.height === 0 || r.right <= 0);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -122,6 +123,11 @@ export default function useAuth() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
setCurrentUser(RESET);
|
||||
// Purge the persisted sidebar tree caches (they contain page titles) so the
|
||||
// cached page titles aren't left readable in localStorage on a shared
|
||||
// machine. (Only the tree caches are swept; other localStorage entries
|
||||
// remain.)
|
||||
clearPersistedTreeCaches();
|
||||
await logout();
|
||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
@@ -7,10 +7,20 @@ import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// 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.
|
||||
const applyMutateAsync = vi.fn();
|
||||
const dismissMutateAsync = vi.fn();
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useApplySuggestionMutation: () => ({
|
||||
mutateAsync: applyMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDismissSuggestionMutation: () => ({
|
||||
mutateAsync: dismissMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||
@@ -19,6 +29,10 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
}));
|
||||
|
||||
import CommentListItem from "./comment-list-item";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
|
||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
@@ -32,28 +46,243 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function renderItem(comment: IComment) {
|
||||
function renderItem(
|
||||
comment: IComment,
|
||||
canEdit = true,
|
||||
canComment = true,
|
||||
userSpaceRole?: string,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId="page-1"
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentListItem — AI badge", () => {
|
||||
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
describe("CommentListItem — agent avatar stack", () => {
|
||||
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
|
||||
// Internal-chat shape with DISTINCT names so absence-of-duplication is
|
||||
// assertable: creator is the human "Alice", the acting agent is "Researcher".
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: "chat-1",
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
}),
|
||||
);
|
||||
// The AGENT is the primary label (the flipped hierarchy).
|
||||
expect(screen.getByText("Researcher")).toBeDefined();
|
||||
// The human launcher name shows exactly once — it is no longer duplicated as
|
||||
// a separate creator name (that duplication is the bug this fixes).
|
||||
expect(screen.getAllByText("Alice").length).toBe(1);
|
||||
});
|
||||
|
||||
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
|
||||
// aiChatId null => external MCP: the agent IS the account, no human behind.
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: null,
|
||||
agent: { name: "MCP Bot", avatarUrl: null },
|
||||
launcher: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
||||
// No launcher => no dimmed "·" separator in the header.
|
||||
expect(screen.queryByText("·")).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
|
||||
const { container } = renderItem(baseComment({ createdSource: "user" }));
|
||||
// No agent glyph (sparkles) is present for a plain human comment.
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||
renderItem(baseComment({ createdSource: "user" }));
|
||||
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
|
||||
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
||||
// only guards the insertion gate (agent → stack, user → no stack).
|
||||
});
|
||||
|
||||
describe("CommentListItem — suggested edit (#315)", () => {
|
||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||
baseComment({
|
||||
selection: "old wording here",
|
||||
suggestedText: "new wording here",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||
const { container } = renderItem(suggestion(), true);
|
||||
// Old text appears as the selection quote (a single unsplit Text node).
|
||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||
// so it is no longer a single text node — assert the concatenated content.
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// Apply button is present.
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
// No Applied badge yet.
|
||||
expect(screen.queryByText("Applied")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the Apply button when canEdit is false", () => {
|
||||
const { container } = renderItem(suggestion(), false);
|
||||
// Diff still renders (as per-fragment spans, #331)...
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// ...but no Apply button.
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
|
||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the Apply button once the thread is resolved", () => {
|
||||
renderItem(suggestion({ resolvedAt: new Date() }), true);
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("calls the apply mutation when the Apply button is clicked", () => {
|
||||
applyMutateAsync.mockClear();
|
||||
renderItem(suggestion(), true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(applyMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
pageId: "page-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render the diff block for a reply (child) comment", () => {
|
||||
renderItem(
|
||||
suggestion({ parentCommentId: "c-0" }),
|
||||
true,
|
||||
);
|
||||
expect(screen.queryByText("new wording here")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||
baseComment({
|
||||
selection: "old wording here",
|
||||
suggestedText: "new wording here",
|
||||
...over,
|
||||
});
|
||||
|
||||
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||
// regardless of who authored the comment; the tests below use it as the lever
|
||||
// since the currentUser atom is unseeded (null) in this harness.
|
||||
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||
renderItem(suggestion(), false, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides Dismiss when the viewer cannot comment", () => {
|
||||
renderItem(suggestion(), false, false, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||
// currentUser atom is null while the comment is authored by user-1), so the
|
||||
// server would 403 a dismiss — the button must not be shown at all.
|
||||
renderItem(suggestion(), false, true, "member");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss once the thread is resolved", () => {
|
||||
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||
dismissMutateAsync.mockClear();
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
pageId: "page-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowApply predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
|
||||
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
|
||||
expect(canShowApply(c(), true)).toBe(true);
|
||||
});
|
||||
it("false without edit permission", () => {
|
||||
expect(canShowApply(c(), false)).toBe(false);
|
||||
});
|
||||
it("false when no suggestion", () => {
|
||||
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
|
||||
});
|
||||
it("false when already applied", () => {
|
||||
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("false when resolved", () => {
|
||||
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
|
||||
});
|
||||
it("false for a reply comment", () => {
|
||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowDismiss predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
|
||||
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||
});
|
||||
it("false without comment permission", () => {
|
||||
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||
});
|
||||
it("false when not owner and not admin (#338 F5)", () => {
|
||||
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||
});
|
||||
it("false when no suggestion", () => {
|
||||
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||
});
|
||||
it("false when already applied", () => {
|
||||
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("false when resolved", () => {
|
||||
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||
});
|
||||
it("false for a reply comment", () => {
|
||||
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
@@ -11,11 +11,18 @@ import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import ResolveComment from "@/features/comment/components/resolve-comment";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDeleteCommentMutation,
|
||||
useDismissSuggestionMutation,
|
||||
useResolveCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
computeSuggestionDiff,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,6 +31,10 @@ interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -31,6 +42,7 @@ function CommentListItem({
|
||||
comment,
|
||||
pageId,
|
||||
canComment,
|
||||
canEdit,
|
||||
userSpaceRole,
|
||||
}: CommentListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -43,9 +55,29 @@ function CommentListItem({
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const applySuggestionMutation = useApplySuggestionMutation();
|
||||
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||
|
||||
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||
// fragments that actually changed get emphasised inside the red/green block,
|
||||
// instead of striking through / greening the whole line. Memoised on the
|
||||
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||
const suggestionDiff = useMemo(
|
||||
() =>
|
||||
comment.suggestedText != null
|
||||
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||
: null,
|
||||
[comment.selection, comment.suggestedText],
|
||||
);
|
||||
|
||||
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||
// render an action the server will 403.
|
||||
const isOwnerOrAdmin =
|
||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
@@ -95,6 +127,31 @@ function CommentListItem({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplySuggestion() {
|
||||
try {
|
||||
await applySuggestionMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Errors surface via the mutation's onError notification (incl. 409).
|
||||
console.error("Failed to apply suggestion:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissSuggestion() {
|
||||
try {
|
||||
await dismissSuggestionMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Idempotent races are reconciled to success in the mutation's onError;
|
||||
// anything else surfaces there as a notification.
|
||||
console.error("Failed to dismiss suggestion:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
@@ -119,24 +176,44 @@ function CommentListItem({
|
||||
return (
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
showName={false}
|
||||
/>
|
||||
) : (
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
{comment.createdSource === "agent" && (
|
||||
<AiAgentBadge
|
||||
authorName={comment.creator?.name}
|
||||
aiChatId={comment.aiChatId}
|
||||
/>
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{comment.agent.name}
|
||||
</Text>
|
||||
{comment.launcher && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
||||
{comment.launcher.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -150,7 +227,7 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
{isOwnerOrAdmin && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
@@ -191,6 +268,87 @@ function CommentListItem({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
||||
carrying a suggestion. Old text struck-through/red, new text green. */}
|
||||
{!comment.parentCommentId && comment.suggestedText && (
|
||||
<Box className={classes.suggestionBlock}>
|
||||
{comment.selection && (
|
||||
// Old line: read as removed as a whole (line-through/red); only the
|
||||
// changed fragments carry the extra intraline emphasis.
|
||||
<Text size="xs" className={classes.suggestionOld}>
|
||||
{suggestionDiff?.old.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" className={classes.suggestionNew}>
|
||||
{suggestionDiff?.new.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
{comment.suggestionAppliedAt ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
color="green"
|
||||
variant="light"
|
||||
mt={6}
|
||||
aria-label={t("Applied")}
|
||||
>
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
) : (
|
||||
(canShowApply(comment, canEdit) ||
|
||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||
<Group gap="xs" mt={6}>
|
||||
{canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||
without changing the page text. Gated on canComment. */}
|
||||
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismissSuggestion}
|
||||
loading={dismissSuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<CommentEditor defaultContent={content} editable={false} />
|
||||
) : (
|
||||
|
||||
@@ -49,8 +49,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
|
||||
const canComment =
|
||||
(page?.permissions?.canEdit ?? false) ||
|
||||
canEdit ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
// Separate active and resolved comments
|
||||
@@ -137,6 +139,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
@@ -144,6 +147,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</div>
|
||||
@@ -160,7 +164,14 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
||||
[
|
||||
comments,
|
||||
handleAddReply,
|
||||
isLoading,
|
||||
space?.membership?.role,
|
||||
canComment,
|
||||
canEdit,
|
||||
],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
@@ -300,6 +311,7 @@ interface ChildCommentsProps {
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
canEdit?: boolean;
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
@@ -307,6 +319,7 @@ const ChildComments = ({
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
canEdit,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
@@ -325,6 +338,7 @@ const ChildComments = ({
|
||||
comment={childComment}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
@@ -332,6 +346,7 @@ const ChildComments = ({
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,53 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Suggested-edit (#315) "было → стало" diff block. */
|
||||
.suggestionBlock {
|
||||
margin-top: 8px;
|
||||
margin-left: 6px;
|
||||
padding: 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.suggestionOld {
|
||||
text-decoration: line-through;
|
||||
color: var(--mantine-color-red-7);
|
||||
background: var(--mantine-color-red-light);
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.suggestionNew {
|
||||
color: var(--mantine-color-green-9);
|
||||
background: var(--mantine-color-green-light);
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Intraline diff (#331): the fragment that actually changed within the
|
||||
red "before" / green "after" block. It inherits the surrounding red/green
|
||||
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||
container's line-through (old) / green (new) still marks the full line. */
|
||||
.suggestionChanged {
|
||||
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||
within the block. `currentColor` follows the parent's red (old) or green
|
||||
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||
inherited line-through must survive on the changed letters too. */
|
||||
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||
*/
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
applySuggestion: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
resolveComment: vi.fn(),
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
applySuggestion,
|
||||
dismissSuggestion,
|
||||
} from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDismissSuggestionMutation,
|
||||
RQ_KEY,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
const PAGE_ID = "page-1";
|
||||
|
||||
function seededClient(comment: IComment) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const seed: InfiniteData<any> = {
|
||||
pageParams: [undefined],
|
||||
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||
};
|
||||
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function items(queryClient: QueryClient): IComment[] {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||
| InfiniteData<any>
|
||||
| undefined;
|
||||
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
content: "{}",
|
||||
creatorId: "u-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
suggestedText: "new",
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
suggestionAppliedAt: resolvedAt,
|
||||
suggestionAppliedById: "u-1",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
});
|
||||
|
||||
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||
// silently drop the comment.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||
// and keep the comment in the cache.
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 400 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Comment NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// A real (red) error, never the success message.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||
// error (restores the #315 apply idempotency).
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||
// real error surfacing the server message, and must NOT drop the live thread.
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// The live thread is NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// Surfaces the server's specific message as a red error, never a success.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
color: "red",
|
||||
}),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
applySuggestion,
|
||||
createComment,
|
||||
deleteComment,
|
||||
dismissSuggestion,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@@ -176,6 +179,196 @@ function updateCommentInCache(
|
||||
};
|
||||
}
|
||||
|
||||
function removeCommentFromCache(
|
||||
cache: InfiniteData<IPagination<IComment>>,
|
||||
commentId: string,
|
||||
): InfiniteData<IPagination<IComment>> {
|
||||
return {
|
||||
...cache,
|
||||
pages: cache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((comment) => comment.id !== commentId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||
// state through (which relocates it to the resolved tab).
|
||||
function applySuggestionOutcomeToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
pageId: string,
|
||||
commentId: string,
|
||||
data: ISuggestionOutcome,
|
||||
) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (!cache) return;
|
||||
|
||||
if (data.outcome === "deleted") {
|
||||
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||
return;
|
||||
}
|
||||
|
||||
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(pageId),
|
||||
updateCommentInCache(cache, commentId, (comment) => ({
|
||||
...comment,
|
||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||
suggestionAppliedById: data.suggestionAppliedById,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function useApplySuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||
// so we only mutate the cache once the server confirms.
|
||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
},
|
||||
onError: (err: any, variables) => {
|
||||
const status = err?.response?.status;
|
||||
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||
// and report success, the user's intent is already satisfied (restores the
|
||||
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||
//
|
||||
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||
// applied" is a false success that also drops a live thread from the cache.
|
||||
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
return;
|
||||
}
|
||||
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||
// the server's specific message when it carries one.
|
||||
if (status === 400) {
|
||||
const serverMsg = err?.response?.data?.message;
|
||||
notifications.show({
|
||||
message:
|
||||
typeof serverMsg === "string" && serverMsg.length > 0
|
||||
? serverMsg
|
||||
: t("Failed to apply suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 409 => the commented text changed since the suggestion was made. Surface
|
||||
// a specific message (with the current text) rather than a generic error.
|
||||
const currentText = err?.response?.data?.currentText;
|
||||
if (status === 409 && typeof currentText === "string") {
|
||||
const shortText =
|
||||
currentText.length > 80
|
||||
? `${currentText.slice(0, 80)}…`
|
||||
: currentText;
|
||||
notifications.show({
|
||||
title: t(
|
||||
"The commented text changed since this suggestion was made; it was not applied.",
|
||||
),
|
||||
message: shortText,
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to apply suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissSuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
},
|
||||
onError: (err: any, variables) => {
|
||||
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||
// report success, the user's intent (make it disappear) is satisfied.
|
||||
//
|
||||
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||
// a live thread from the cache. Show a real error and keep the comment.
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
return;
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to dismiss suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
@@ -18,6 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function applySuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function dismissSuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
data: Partial<IComment>,
|
||||
): Promise<IComment> {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
import type {
|
||||
AgentInfo,
|
||||
LauncherInfo,
|
||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
||||
|
||||
export interface IComment {
|
||||
id: string;
|
||||
@@ -24,6 +28,18 @@ export interface IComment {
|
||||
createdSource?: string;
|
||||
aiChatId?: 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
|
||||
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
||||
// human behind it (null for an external MCP agent).
|
||||
agent?: AgentInfo | null;
|
||||
launcher?: LauncherInfo | null;
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
@@ -44,6 +60,15 @@ export interface IResolveComment {
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||
// take (drop the comment vs. move it to the resolved tab).
|
||||
export type ISuggestionOutcome = IComment & {
|
||||
outcome?: "deleted" | "resolved";
|
||||
};
|
||||
|
||||
export interface ICommentParams extends QueryParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||
|
||||
// Reconstruct the plain string from a segment stream — the diff must be
|
||||
// lossless (concatenating every fragment yields the original input).
|
||||
const join = (segments: Segment[]): string =>
|
||||
segments.map((s) => s.text).join("");
|
||||
|
||||
// The subset of segments (in order) that the UI would emphasise.
|
||||
const changed = (segments: Segment[]): string[] =>
|
||||
segments.filter((s) => s.changed).map((s) => s.text);
|
||||
|
||||
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||
segments.find((s) => s.text.includes(needle));
|
||||
|
||||
describe("computeSuggestionDiff", () => {
|
||||
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("заведем");
|
||||
expect(join(neu)).toBe("заведём");
|
||||
|
||||
// Old side: exactly the `е` is changed, the rest is common.
|
||||
expect(changed(old)).toEqual(["е"]);
|
||||
expect(old).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "е", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
|
||||
// New side: exactly the `ё` is changed.
|
||||
expect(changed(neu)).toEqual(["ё"]);
|
||||
expect(neu).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "ё", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the differing words changed but keeps the shared word common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff(
|
||||
"привет мир",
|
||||
"здравствуй мир",
|
||||
);
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("привет мир");
|
||||
expect(join(neu)).toBe("здравствуй мир");
|
||||
|
||||
// The shared trailing word stays common on both sides (no per-letter noise
|
||||
// leaking across the differing words into `мир`).
|
||||
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||
|
||||
// The differing words are emphasised somewhere on each side.
|
||||
expect(changed(old).length).toBeGreaterThan(0);
|
||||
expect(changed(neu).length).toBeGreaterThan(0);
|
||||
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||
|
||||
// No changed fragment on either side touches the word `мир`.
|
||||
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||
|
||||
expect(join(old)).toBe("a c");
|
||||
expect(join(neu)).toBe("a b c");
|
||||
|
||||
// Old line has no changed fragment (nothing was removed).
|
||||
expect(changed(old)).toEqual([]);
|
||||
// The inserted word is the only changed fragment on the new side.
|
||||
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(neu)).toEqual(["b "]);
|
||||
});
|
||||
|
||||
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||
|
||||
expect(join(old)).toBe("a b c");
|
||||
expect(join(neu)).toBe("a c");
|
||||
|
||||
// The deleted word is the only changed fragment on the old side.
|
||||
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(old)).toEqual(["b "]);
|
||||
// New line has no changed fragment (nothing was added).
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks everything common for identical strings", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||
|
||||
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(changed(old)).toEqual([]);
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { diffWordsWithSpace, diffChars } from "diff";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||
// comment: it must carry a suggestion, not already be applied or resolved, be a
|
||||
// top-level comment, and the viewer must be able to edit the page.
|
||||
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
||||
return Boolean(
|
||||
canEdit &&
|
||||
comment.suggestedText &&
|
||||
!comment.suggestionAppliedAt &&
|
||||
!comment.resolvedAt &&
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||
// `changed` marks the fragment that actually differs from the other side, so
|
||||
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||
// the whole line.
|
||||
export interface Segment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||
// common vs. removed-and-changed fragments, and the new line split into common
|
||||
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||
// the original strings.
|
||||
export interface SuggestionDiff {
|
||||
old: Segment[];
|
||||
new: Segment[];
|
||||
}
|
||||
|
||||
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||
// so the render emits as few spans as possible and tests stay predictable.
|
||||
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||
if (text === "") return;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.changed === changed) {
|
||||
last.text += text;
|
||||
} else {
|
||||
segments.push({ text, changed });
|
||||
}
|
||||
}
|
||||
|
||||
// Compute an intraline diff between the old `selection` and the new
|
||||
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||
//
|
||||
// Hybrid word + char algorithm (per #331):
|
||||
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||
// `diffChars`: shared characters stay common, differing characters are
|
||||
// marked `changed` on their respective side. This is what keeps a
|
||||
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||
// fragment `changed`.
|
||||
// 4. An unchanged part is `common` on both sides.
|
||||
//
|
||||
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||
export function computeSuggestionDiff(
|
||||
oldStr: string,
|
||||
newStr: string,
|
||||
): SuggestionDiff {
|
||||
const oldSegments: Segment[] = [];
|
||||
const newSegments: Segment[] = [];
|
||||
|
||||
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const next = parts[i + 1];
|
||||
|
||||
// A word replacement: a removed part immediately followed by an added part
|
||||
// (or the reverse). Refine it character-by-character so only the differing
|
||||
// letters are highlighted while shared letters stay common.
|
||||
const isReplacementPair =
|
||||
next &&
|
||||
((part.removed && next.added) || (part.added && next.removed));
|
||||
|
||||
if (isReplacementPair) {
|
||||
const removedPart = part.removed ? part : next;
|
||||
const addedPart = part.added ? part : next;
|
||||
|
||||
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||
for (const cp of charParts) {
|
||||
if (cp.added) {
|
||||
pushSegment(newSegments, cp.value, true);
|
||||
} else if (cp.removed) {
|
||||
pushSegment(oldSegments, cp.value, true);
|
||||
} else {
|
||||
// Shared character: common on both sides.
|
||||
pushSegment(oldSegments, cp.value, false);
|
||||
pushSegment(newSegments, cp.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
i++; // consume the paired part as well
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.added) {
|
||||
// Lone insertion: only present in the new line, wholly changed.
|
||||
pushSegment(newSegments, part.value, true);
|
||||
} else if (part.removed) {
|
||||
// Lone deletion: only present in the old line, wholly changed.
|
||||
pushSegment(oldSegments, part.value, true);
|
||||
} else {
|
||||
// Unchanged: common on both sides.
|
||||
pushSegment(oldSegments, part.value, false);
|
||||
pushSegment(newSegments, part.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
return { old: oldSegments, new: newSegments };
|
||||
}
|
||||
|
||||
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||
export function canShowDismiss(
|
||||
comment: IComment,
|
||||
canComment?: boolean,
|
||||
isOwnerOrAdmin?: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
canComment &&
|
||||
isOwnerOrAdmin &&
|
||||
comment.suggestedText &&
|
||||
!comment.suggestionAppliedAt &&
|
||||
!comment.resolvedAt &&
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// A disabled mic must explain WHY it is unavailable rather than silently saying
|
||||
// "Start dictation". This renders MicButton in its idle+disabled state with a
|
||||
// forwarded reason and asserts the accessible label resolves to that reason's
|
||||
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Pass i18n keys through verbatim so we assert the exact resolved string.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
// Keep both controllers inert and idle so MicButton renders the idle branch.
|
||||
const idleCtl = {
|
||||
status: "idle" as const,
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
audioLevel: 0,
|
||||
errorMessage: null,
|
||||
};
|
||||
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
|
||||
useDictation: () => idleCtl,
|
||||
}));
|
||||
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
|
||||
useStreamingDictation: () => idleCtl,
|
||||
}));
|
||||
|
||||
import { MicButton } from "./mic-button";
|
||||
|
||||
function renderButton(props: React.ComponentProps<typeof MicButton>) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MicButton {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MicButton — disabled reason label", () => {
|
||||
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
|
||||
// report "unsupported" and mask the forwarded reason. Stub both so the button
|
||||
// is considered supported and the availability reason is what surfaces.
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
|
||||
class {};
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn() },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
|
||||
});
|
||||
|
||||
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
|
||||
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
|
||||
const expected =
|
||||
"No connection to the collaboration server — dictation unavailable";
|
||||
// The reason surfaces as the accessible label (and the tooltip text).
|
||||
const button = screen.getByRole("button", { name: expected });
|
||||
expect(button).toBeDefined();
|
||||
// It is marked disabled the Mantine way (data-disabled), NOT the native
|
||||
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
// And it no longer silently reads "Start dictation".
|
||||
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
|
||||
});
|
||||
|
||||
it("reads 'Start dictation' when enabled with no reason", () => {
|
||||
renderButton({ onText: () => {} });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Start dictation" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not advertise 'Start dictation' when disabled with no reason", () => {
|
||||
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
|
||||
// unavailableReason must not get a hoverable mic whose tooltip invites
|
||||
// "Start dictation" on a click that is rejected.
|
||||
renderButton({ onText: () => {}, disabled: true });
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Start dictation" }),
|
||||
).toBeNull();
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDictation } from "@/features/dictation/hooks/use-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";
|
||||
|
||||
interface MicButtonProps {
|
||||
@@ -21,6 +26,9 @@ interface MicButtonProps {
|
||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||
// text progressively as the user pauses; otherwise use the batch controller.
|
||||
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,
|
||||
iconSize,
|
||||
streaming = false,
|
||||
unavailableReason,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// 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 streamingCtl = useStreamingDictation({ onText, onStart });
|
||||
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);
|
||||
|
||||
if (status === "recording") {
|
||||
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
) {
|
||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||
// a confusing second click can't fire while the model loads.
|
||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
||||
// a confusing second click can't fire while the model loads. The error case
|
||||
// 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 (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
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}
|
||||
>
|
||||
<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 (
|
||||
<Tooltip label={t("Start dictation")} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={() => void start()}
|
||||
disabled={disabled}
|
||||
aria-label={t("Start dictation")}
|
||||
>
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
<Tooltip
|
||||
label={reasonLabel ?? t("Start dictation")}
|
||||
withArrow
|
||||
>
|
||||
{icon}
|
||||
</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 { useTranslation } from "react-i18next";
|
||||
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
|
||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||
@@ -26,6 +31,8 @@ interface UseDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||
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
|
||||
@@ -67,6 +74,8 @@ export function useDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
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
|
||||
// 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 (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
// Clear any stale error from a previous attempt.
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
const reason =
|
||||
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
||||
console.error("[dictation] " + reason);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Audio recording is not available in this browser/context"),
|
||||
});
|
||||
const message = dictationErrorMessage("no-media-devices", t);
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -215,19 +225,16 @@ export function useDictation(
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] getUserMedia failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
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 {
|
||||
// Unknown failure: show the real reason instead of a generic string.
|
||||
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
|
||||
}
|
||||
const rawDetail = (err as { message?: string })?.message ?? String(err);
|
||||
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
|
||||
// resolver branch reproduces this hook's original "Could not start
|
||||
// recording: <name>: <detail>" text. Each caller owns its own detail; the
|
||||
// streaming hook intentionally does not add the name.
|
||||
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -249,10 +256,10 @@ export function useDictation(
|
||||
// The stream was acquired but the recorder failed to construct; stop the
|
||||
// tracks so the MediaStream does not leak before bailing out.
|
||||
stopTracks();
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -293,21 +300,14 @@ export function useDictation(
|
||||
.catch((err: unknown) => {
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] transcription failed", err);
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
let message: string;
|
||||
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)}`;
|
||||
}
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage(code, t, {
|
||||
serverMessage,
|
||||
detail,
|
||||
});
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("error");
|
||||
if (errorTimerRef.current !== null) {
|
||||
clearTimeout(errorTimerRef.current);
|
||||
@@ -332,10 +332,10 @@ export function useDictation(
|
||||
stopTracks();
|
||||
recorderRef.current = null;
|
||||
startingRef.current = false;
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
@@ -405,5 +405,5 @@ export function useDictation(
|
||||
};
|
||||
}, [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 { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||
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
|
||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||
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).
|
||||
@@ -60,6 +67,8 @@ export function useStreamingDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
@@ -204,10 +193,14 @@ export function useStreamingDictation(
|
||||
if (epoch !== epochRef.current) return;
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] segment transcription failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: transcriptionErrorMessage(err),
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(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.
|
||||
if (nextEmitSeqRef.current === seq) {
|
||||
nextEmitSeqRef.current += 1;
|
||||
@@ -226,7 +219,7 @@ export function useStreamingDictation(
|
||||
}
|
||||
});
|
||||
},
|
||||
[drainResults, transcriptionErrorMessage],
|
||||
[drainResults, t],
|
||||
);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
@@ -236,6 +229,8 @@ export function useStreamingDictation(
|
||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
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
|
||||
// editor can snapshot the caret position.
|
||||
@@ -354,10 +349,9 @@ export function useStreamingDictation(
|
||||
// actually runs.)
|
||||
console.error("[dictation] VAD init failed", err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${detail}`,
|
||||
});
|
||||
const message = dictationErrorMessage("vad-init-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||
// don't leak it.
|
||||
destroyVad();
|
||||
@@ -379,19 +373,11 @@ export function useStreamingDictation(
|
||||
} catch (err) {
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] VAD.start failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
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}`;
|
||||
}
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
activeRef.current = false;
|
||||
destroyVad();
|
||||
setStatus("idle");
|
||||
@@ -470,5 +456,5 @@ export function useStreamingDictation(
|
||||
};
|
||||
}, [clearTimer, destroyVad]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom } from "jotai";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
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
|
||||
// first load, can be toggled locally without persisting to the server.
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ComponentType,
|
||||
CSSProperties,
|
||||
FC,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
IconBold,
|
||||
IconCode,
|
||||
@@ -29,12 +36,46 @@ import { LinkSelector } from "@/features/editor/components/bubble-menu/link-sele
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
hasStressAfterSelection,
|
||||
toggleStressAccent,
|
||||
} from "./stress-accent";
|
||||
|
||||
// Tabler has no acute-accent glyph (IconGrave is a tombstone), so we ship a
|
||||
// tiny local icon that mirrors the Tabler icon API ({ style, stroke }).
|
||||
function IconStress({
|
||||
style,
|
||||
stroke = 2,
|
||||
}: {
|
||||
style?: React.CSSProperties;
|
||||
stroke?: string | number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={style}
|
||||
>
|
||||
<path d="M5 19l5 -12l5 12" />
|
||||
<path d="M7.5 14h5" />
|
||||
<path d="M13 5l4 -3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof IconBold;
|
||||
// Rendered as <item.icon style={...} stroke={2} />, so the real contract is
|
||||
// just { style?, stroke? }. stroke is string|number to match Tabler's own prop
|
||||
// type; Tabler icons and the local IconStress both satisfy it (no cast needed).
|
||||
icon: ComponentType<{ style?: CSSProperties; stroke?: string | number }>;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
@@ -77,6 +118,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isCode: ctx.editor.isActive("code"),
|
||||
isComment: ctx.editor.isActive("comment"),
|
||||
isSpoiler: ctx.editor.isActive("spoiler"),
|
||||
// A stress accent already sits right after the selection end.
|
||||
isStress: hasStressAfterSelection(ctx.editor.state),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -118,6 +161,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
{
|
||||
name: "Stress",
|
||||
isActive: () => editorState?.isStress,
|
||||
// Toggle the U+0301 combining accent right after the selected letter.
|
||||
// The whole toggle is a single transaction, so one Ctrl+Z reverts it.
|
||||
command: () => {
|
||||
const editor = props.editor;
|
||||
editor.view.dispatch(toggleStressAccent(editor.state));
|
||||
editor.view.focus();
|
||||
},
|
||||
icon: IconStress,
|
||||
},
|
||||
{
|
||||
name: "Clear formatting",
|
||||
// Action, not a toggle — never show an active/highlighted state.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import {
|
||||
STRESS_ACCENT,
|
||||
hasStressAfterSelection,
|
||||
toggleStressAccent,
|
||||
} from "./stress-accent";
|
||||
|
||||
// Minimal ProseMirror schema: paragraph of text with a single `bold` mark.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: {
|
||||
group: "block",
|
||||
content: "text*",
|
||||
toDOM: () => ["p", 0],
|
||||
},
|
||||
text: { group: "inline" },
|
||||
},
|
||||
marks: {
|
||||
bold: { toDOM: () => ["strong", 0] },
|
||||
},
|
||||
});
|
||||
|
||||
function makeState(
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
marked = false,
|
||||
): EditorState {
|
||||
const marks = marked ? [schema.marks.bold.create()] : [];
|
||||
const textNode = schema.text(text, marks);
|
||||
const doc = schema.node("doc", null, [
|
||||
schema.node("paragraph", null, [textNode]),
|
||||
]);
|
||||
const state = EditorState.create({ schema, doc });
|
||||
return state.apply(
|
||||
state.tr.setSelection(TextSelection.create(state.doc, from, to)),
|
||||
);
|
||||
}
|
||||
|
||||
describe("stress-accent", () => {
|
||||
it("uses U+0301 as the combining accent", () => {
|
||||
expect(STRESS_ACCENT).toHaveLength(1);
|
||||
expect(STRESS_ACCENT.codePointAt(0)).toBe(0x0301);
|
||||
});
|
||||
|
||||
it("inserts the accent right after the selected vowel", () => {
|
||||
// "кот", select "о" (positions 2..3).
|
||||
const state = makeState("кот", 2, 3);
|
||||
expect(hasStressAfterSelection(state)).toBe(false);
|
||||
|
||||
const next = state.apply(toggleStressAccent(state));
|
||||
expect(next.doc.textContent).toBe(`ко${STRESS_ACCENT}т`);
|
||||
// Selection is preserved on the letter, so the button reads active.
|
||||
expect(next.selection.from).toBe(2);
|
||||
expect(next.selection.to).toBe(3);
|
||||
expect(hasStressAfterSelection(next)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the accent on a second toggle (round-trips to original)", () => {
|
||||
const state = makeState("кот", 2, 3);
|
||||
const inserted = state.apply(toggleStressAccent(state));
|
||||
const removed = inserted.apply(toggleStressAccent(inserted));
|
||||
|
||||
expect(removed.doc.textContent).toBe("кот");
|
||||
expect(hasStressAfterSelection(removed)).toBe(false);
|
||||
expect(removed.selection.from).toBe(2);
|
||||
expect(removed.selection.to).toBe(3);
|
||||
});
|
||||
|
||||
it("inherits the letter's marks so the accent stays bold", () => {
|
||||
// Whole word is bold; select "о".
|
||||
const state = makeState("кот", 2, 3, true);
|
||||
const next = state.apply(toggleStressAccent(state));
|
||||
|
||||
// The accent lands at positions 3..4 (right after "о")...
|
||||
expect(next.doc.textBetween(3, 4)).toBe(STRESS_ACCENT);
|
||||
// ...inside a bold text node, so it inherits the letter's bold mark.
|
||||
const accentNode = next.doc.nodeAt(3);
|
||||
expect(accentNode?.marks.some((m) => m.type.name === "bold")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles a selection at the end of the doc without throwing", () => {
|
||||
// "а" is the whole paragraph; select it (1..2), end of content.
|
||||
const state = makeState("а", 1, 2);
|
||||
expect(hasStressAfterSelection(state)).toBe(false);
|
||||
|
||||
const next = state.apply(toggleStressAccent(state));
|
||||
expect(next.doc.textContent).toBe(`а${STRESS_ACCENT}`);
|
||||
expect(hasStressAfterSelection(next)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { EditorState, TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
|
||||
// U+0301 COMBINING ACUTE ACCENT — a plain Unicode combining char inserted
|
||||
// right after a vowel to render a Russian-style stress accent over it.
|
||||
// It is stored as literal text (not a TipTap mark), so it survives HTML/
|
||||
// Markdown export, full-text search and public share with zero server or
|
||||
// converter changes.
|
||||
export const STRESS_ACCENT = "́";
|
||||
|
||||
// True when a stress accent already sits immediately after the selection end
|
||||
// (the single char following the selection). Used both for the toolbar
|
||||
// active state and to decide the toggle direction.
|
||||
export function hasStressAfterSelection(state: EditorState): boolean {
|
||||
const { to } = state.selection;
|
||||
const docSize = state.doc.content.size;
|
||||
// Clamp to the doc size so a selection at the very end never reads past it.
|
||||
const afterChar = state.doc.textBetween(to, Math.min(to + 1, docSize));
|
||||
return afterChar === STRESS_ACCENT;
|
||||
}
|
||||
|
||||
// Build a single transaction that toggles the stress accent after the
|
||||
// selection. One transaction => one undo step (Ctrl+Z reverts the toggle).
|
||||
export function toggleStressAccent(state: EditorState): Transaction {
|
||||
const { from, to } = state.selection;
|
||||
const tr = state.tr;
|
||||
|
||||
if (hasStressAfterSelection(state)) {
|
||||
// Toggle off: drop the accent that immediately follows the letter.
|
||||
tr.delete(to, to + 1);
|
||||
} else {
|
||||
// Toggle on: insertText inherits the marks at `to`, so the accent lands
|
||||
// in the same text node as the letter and renders over it even when the
|
||||
// letter is bold / italic / colored.
|
||||
tr.insertText(STRESS_ACCENT, to);
|
||||
}
|
||||
|
||||
// Restore the original selection so the accented letter stays highlighted
|
||||
// and a re-click toggles the accent back off.
|
||||
tr.setSelection(TextSelection.create(tr.doc, from, to));
|
||||
return tr;
|
||||
}
|
||||
+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 type { Editor } from "@tiptap/react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +17,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const streamingDictation =
|
||||
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).
|
||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||
// 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}
|
||||
onStart={handleStart}
|
||||
onText={handleText}
|
||||
disabled={!editor.isEditable}
|
||||
disabled={!dictationAvailability.isEditable}
|
||||
unavailableReason={dictationAvailability.reason ?? undefined}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconLayoutAlignRight,
|
||||
IconFloatLeft,
|
||||
IconFloatRight,
|
||||
IconLayoutColumns,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
@@ -46,6 +47,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||
isInline: ctx.editor.isActive("image", { align: "inline" }),
|
||||
src: imageAttrs?.src || null,
|
||||
alt: imageAttrs?.alt || "",
|
||||
caption: imageAttrs?.caption || "",
|
||||
@@ -126,6 +128,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignImageInline = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setImageAlign("inline")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
@@ -259,6 +269,18 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Inline (side by side)")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageInline}
|
||||
size="lg"
|
||||
aria-label={t("Inline (side by side)")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isInline })}
|
||||
>
|
||||
<IconLayoutColumns size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
@@ -11,6 +11,7 @@ import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||
import { refocusEditorAfterMenuClose } from "./hooks/use-column-row-menu-lifecycle";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface CellChevronProps {
|
||||
@@ -87,6 +88,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
editor.commands.unfreezeHandles();
|
||||
refocusEditorAfterMenuClose(editor);
|
||||
}, [editor]);
|
||||
|
||||
if (!cellDom) return null;
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { refocusEditorAfterMenuClose } from "./use-column-row-menu-lifecycle";
|
||||
|
||||
// A minimal fake editor. `view.dom` is a real element so `.contains()` works,
|
||||
// and `view.focus` is a spy so we assert on it without relying on real DOM
|
||||
// focus (unreliable in jsdom). rAF is stubbed to a `setTimeout(0)` so fake
|
||||
// timers can flush the deferred callback deterministically.
|
||||
function makeEditor() {
|
||||
const dom = document.createElement("div");
|
||||
document.body.appendChild(dom);
|
||||
const focus = vi.fn();
|
||||
const editor = { isDestroyed: false, view: { dom, focus } };
|
||||
return { editor: editor as unknown as Editor, focus, dom };
|
||||
}
|
||||
|
||||
describe("refocusEditorAfterMenuClose", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||
setTimeout(() => cb(0), 0),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("(a) does not refocus the editor when an external <input> is active", () => {
|
||||
const { editor, focus } = makeEditor();
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
refocusEditorAfterMenuClose(editor);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(b) refocuses the editor when a non-focusable element (body) is active", () => {
|
||||
const { editor, focus } = makeEditor();
|
||||
// Ensure focus rests on body: nothing is focused / an <input> was blurred.
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
|
||||
refocusEditorAfterMenuClose(editor);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+34
@@ -11,6 +11,39 @@ interface Args {
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore focus to the editor after a table handle/cell menu closes.
|
||||
*
|
||||
* The grip/chevron menus are Mantine `<Menu>`s with `returnFocus: true`, and
|
||||
* their targets live in a floating/portaled layer OUTSIDE the editor's
|
||||
* contenteditable. After an action (delete row/column, insert, etc.) the menu
|
||||
* closes and Mantine returns focus to that outside target, so ProseMirror's
|
||||
* undo keymap never sees Ctrl+Z until the user clicks back into a cell.
|
||||
*
|
||||
* We defer with `requestAnimationFrame` so this runs AFTER Mantine's
|
||||
* returnFocus, and guard against stealing focus if the user intentionally
|
||||
* moved to another input/editable (e.g. the page title).
|
||||
*/
|
||||
export function refocusEditorAfterMenuClose(editor: Editor) {
|
||||
requestAnimationFrame(() => {
|
||||
if (editor.isDestroyed) return;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
// Already inside the editor — nothing to do.
|
||||
if (active && editor.view.dom.contains(active)) return;
|
||||
// Respect a deliberate move to another field/editable.
|
||||
const tag = active?.tagName;
|
||||
if (
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
active?.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
editor.view.focus(); // pure DOM focus, no extra transaction
|
||||
});
|
||||
}
|
||||
|
||||
export function useColumnRowMenuLifecycle({
|
||||
editor,
|
||||
orientation,
|
||||
@@ -34,6 +67,7 @@ export function useColumnRowMenuLifecycle({
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
editor.commands.unfreezeHandles();
|
||||
refocusEditorAfterMenuClose(editor);
|
||||
}, [editor]);
|
||||
|
||||
return { onOpen, onClose };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
||||
import {
|
||||
isCollabSynced,
|
||||
isBodyEditable,
|
||||
computeDictationAvailability,
|
||||
} from "./editor-sync-state";
|
||||
|
||||
describe("isCollabSynced", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
/**
|
||||
* The collab document is usable only once the provider is Connected AND has
|
||||
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
|
||||
}): boolean {
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import {
|
||||
CustomTypography,
|
||||
undoGuardKey,
|
||||
findChangedRange,
|
||||
mapRangeThroughChange,
|
||||
} from "./custom-typography";
|
||||
|
||||
/**
|
||||
* PR #296 — the collab-safe typography undo-guard is exercised through the REAL
|
||||
* editor path: a fresh Editor with the CustomTypography extension, transactions
|
||||
* tagged exactly the way prosemirror-history / y-tiptap tag undo & remote
|
||||
* changes (`setMeta("history$", …)` and `setMeta(ySyncPluginKey, …)`), plus
|
||||
* direct unit tests of the two pure diff helpers. No hand-poke of plugin state.
|
||||
*
|
||||
* ARMING MECHANISM (verified against custom-typography.ts source):
|
||||
* - A transaction arms the guard only when it is BOTH history/remote
|
||||
* (`getMeta("history$")` truthy, or `isChangeOrigin` via the ySync meta)
|
||||
* AND an undo/redo (`getMeta("history$")` truthy, or ySync
|
||||
* `isUndoRedoOperation`), AND its whole-doc diff is a REPLACE
|
||||
* (change.oldTo > change.from && change.newTo > change.from).
|
||||
* - `history$` is the stringified PluginKey of the single prosemirror-history
|
||||
* plugin; ProseMirror stores meta under `key.key`, so setMeta("history$")
|
||||
* in a test is read identically by the extension's getMeta("history$").
|
||||
*/
|
||||
|
||||
const singlePara = (text: string) => ({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
|
||||
const makeEditor = (text: string) =>
|
||||
new Editor({
|
||||
extensions: [Document, Paragraph, Text, CustomTypography],
|
||||
content: singlePara(text),
|
||||
});
|
||||
|
||||
// Build a before/after EditorState pair by applying one plain transaction.
|
||||
const mutate = (text: string, apply: (tr: any, schema: any) => void) => {
|
||||
const editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: singlePara(text),
|
||||
});
|
||||
const before = editor.state;
|
||||
const tr = before.tr;
|
||||
apply(tr, before.schema);
|
||||
editor.view.dispatch(tr);
|
||||
const after = editor.state;
|
||||
return { before, after, editor };
|
||||
};
|
||||
|
||||
describe("findChangedRange", () => {
|
||||
it("returns null for identical docs", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: singlePara("hello"),
|
||||
});
|
||||
expect(findChangedRange(editor.state, editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("returns the minimal range for a normal middle insertion", () => {
|
||||
// "hello world" (text at 1..12); insert "there " at pos 6.
|
||||
const { before, after, editor } = mutate("hello world", (tr) =>
|
||||
tr.insertText("there ", 6),
|
||||
);
|
||||
expect(findChangedRange(before, after)).toEqual({
|
||||
from: 6,
|
||||
oldTo: 6,
|
||||
newTo: 12,
|
||||
});
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes the INSERTION overlapping-bounds branch (repeated content)", () => {
|
||||
// Insert one more 'a' into "aaaaa" at pos 3. findDiffStart lands at the end
|
||||
// (6) while findDiffEnd reports an end BEFORE it ({a:1,b:2}); both ends must
|
||||
// be pushed forward by the same delta -> a non-degenerate range.
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) =>
|
||||
tr.insertText("a", 3),
|
||||
);
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 6, oldTo: 6, newTo: 7 });
|
||||
// Invariant the guard logic relies on: never degenerate.
|
||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes the DELETION overlapping-bounds branch (F2 fix)", () => {
|
||||
// Delete one repeated 'a' from the middle of "aaaaa" ([3,4)). Here
|
||||
// findDiffEnd reports newTo < start, the symmetric case the old one-sided
|
||||
// normalization missed -> it used to yield a degenerate range (newTo < from).
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(3, 4));
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 5, oldTo: 6, newTo: 5 });
|
||||
// The whole point of F2: from <= newTo (and from <= oldTo) still holds.
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes a multi-char repeated deletion (F2 fix)", () => {
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(2, 4));
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 4, oldTo: 6, newTo: 4 });
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRangeThroughChange", () => {
|
||||
const range = { from: 5, to: 10 };
|
||||
|
||||
it("RELEASES on a strict intersection (edit inside the guarded range)", () => {
|
||||
// change straddles the interior of the guard.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 6, oldTo: 8, newTo: 7 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT release on a boundary touch at the guard END", () => {
|
||||
// Edit begins exactly at range.to (10): from < to is false -> no intersect.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 10, oldTo: 10, newTo: 12 }),
|
||||
).toEqual(range);
|
||||
});
|
||||
|
||||
it("does NOT release on a boundary touch at the guard START", () => {
|
||||
// Edit ends exactly at range.from (5): oldTo > from is false -> no intersect;
|
||||
// it is treated as a change fully before, shifting the guard.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 3, oldTo: 5, newTo: 8 }),
|
||||
).toEqual({ from: 8, to: 13 });
|
||||
});
|
||||
|
||||
it("SHIFTS the guard for a change fully before it", () => {
|
||||
// Insert 2 chars entirely before the range (oldTo 3 <= from 5): +2 delta.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 2, oldTo: 3, newTo: 5 }),
|
||||
).toEqual({ from: 7, to: 12 });
|
||||
});
|
||||
|
||||
it("leaves the guard untouched for a change fully after it", () => {
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 12, oldTo: 14, newTo: 16 }),
|
||||
).toBe(range);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-guard arming (integration)", () => {
|
||||
it("arms {from, to:newTo} on a LOCAL undo-replace (history meta)", () => {
|
||||
// Undo of an em-dash substitution: "a—b" restored to "a--b" — the em-dash
|
||||
// (pos 2..3) is REPLACED by "--", tagged with the history plugin's meta.
|
||||
const editor = makeEditor("a—b");
|
||||
const { state } = editor;
|
||||
const tr = state.tr
|
||||
.replaceWith(2, 3, state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
expect(editor.state.doc.textContent).toBe("a--b");
|
||||
// from = diff start (2), to = newTo = end of the inserted "--" (4).
|
||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 2, to: 4 });
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT arm on a REMOTE change-origin replace (no undo meta)", () => {
|
||||
// Same replace, but tagged only as a y-sync remote change: history/remote
|
||||
// yes, undo/redo NO -> must not arm.
|
||||
const editor = makeEditor("a—b");
|
||||
const { state } = editor;
|
||||
const tr = state.tr
|
||||
.replaceWith(2, 3, state.schema.text("--"))
|
||||
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
expect(editor.state.doc.textContent).toBe("a--b");
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT arm on an ordinary local edit", () => {
|
||||
const editor = makeEditor("a—b");
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.replaceWith(2, 3, editor.state.schema.text("--")),
|
||||
);
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-guard release / shift (integration)", () => {
|
||||
it("RELEASES when a later edit lands inside the guarded region", () => {
|
||||
const editor = makeEditor("a—b");
|
||||
editor.view.dispatch(
|
||||
editor.state.tr
|
||||
.replaceWith(2, 3, editor.state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false }),
|
||||
);
|
||||
const guard = undoGuardKey.getState(editor.state)!;
|
||||
expect(guard).toEqual({ from: 2, to: 4 });
|
||||
|
||||
// Type a character inside the restored region -> guard is dropped.
|
||||
editor.view.dispatch(editor.state.tr.insertText("x", guard.from + 1));
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps and SHIFTS the guard when a later edit lands before it", () => {
|
||||
const editor = makeEditor("zz a—b");
|
||||
// "zz a—b": em-dash at pos 5; replace the 'a' at 4..5 with "--" to arm.
|
||||
editor.view.dispatch(
|
||||
editor.state.tr
|
||||
.replaceWith(4, 5, editor.state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false }),
|
||||
);
|
||||
const guard = undoGuardKey.getState(editor.state)!;
|
||||
expect(guard).toEqual({ from: 4, to: 6 });
|
||||
|
||||
// Insert one char at the very start (before the guard) -> guard shifts +1.
|
||||
editor.view.dispatch(editor.state.tr.insertText("Q", 1));
|
||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 5, to: 7 });
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { InputRule } from "@tiptap/core";
|
||||
import {
|
||||
Plugin,
|
||||
PluginKey,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
// Region restored by the latest undo — while it is intact, typography
|
||||
// input rules overlapping it must not fire again.
|
||||
interface UndoGuardRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// Exported for tests: the plugin key lets a test read the armed guard state,
|
||||
// and the two pure helpers below are unit-tested directly.
|
||||
export const undoGuardKey = new PluginKey<UndoGuardRange | null>(
|
||||
"typographyUndoGuard",
|
||||
);
|
||||
|
||||
// prosemirror-history does not export its plugin key, so template-editor
|
||||
// undo/redo is detected via the stable stringified key. Only one
|
||||
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
|
||||
const HISTORY_META = "history$";
|
||||
|
||||
const isUndoRedoTransaction = (tr: Transaction): boolean => {
|
||||
if (tr.getMeta(HISTORY_META)) {
|
||||
return true;
|
||||
}
|
||||
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
|
||||
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
|
||||
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
|
||||
| { isUndoRedoOperation?: boolean }
|
||||
| undefined;
|
||||
return !!ySyncMeta?.isUndoRedoOperation;
|
||||
};
|
||||
|
||||
interface DocChange {
|
||||
from: number;
|
||||
oldTo: number;
|
||||
newTo: number;
|
||||
}
|
||||
|
||||
// Compute the minimal changed region between two docs. yjs undo/redo (and any
|
||||
// remote change) arrives as a whole-document replace step, so the transaction
|
||||
// step maps are useless — diff the docs to recover the real minimal change.
|
||||
// Returns null when the docs are identical.
|
||||
export const findChangedRange = (
|
||||
oldState: EditorState,
|
||||
newState: EditorState,
|
||||
): DocChange | null => {
|
||||
const start = oldState.doc.content.findDiffStart(newState.doc.content);
|
||||
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
|
||||
if (start == null || end == null) {
|
||||
return null;
|
||||
}
|
||||
let { a: oldTo, b: newTo } = end;
|
||||
// findDiffEnd can report an end BEFORE the diff start when the changed text
|
||||
// abuts repeated content (insertion -> oldTo<start, deletion -> newTo<start).
|
||||
// Push both ends forward by the same delta so the range stays non-degenerate
|
||||
// (from <= oldTo and from <= newTo), matching ProseMirror's own diff bounds.
|
||||
const minTo = Math.min(oldTo, newTo);
|
||||
if (minTo < start) {
|
||||
const delta = start - minTo;
|
||||
oldTo += delta;
|
||||
newTo += delta;
|
||||
}
|
||||
return { from: start, oldTo, newTo };
|
||||
};
|
||||
|
||||
// Map an armed guard range across a single document change described by a diff.
|
||||
// Returns null when the change touches the guarded text itself (the restored
|
||||
// substitution was edited, so the guard must be released).
|
||||
export const mapRangeThroughChange = (
|
||||
range: UndoGuardRange,
|
||||
change: DocChange,
|
||||
): UndoGuardRange | null => {
|
||||
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
|
||||
// typing the suppressed space right after the restored text, or deleting it)
|
||||
// must NOT drop the guard.
|
||||
if (change.from < range.to && change.oldTo > range.from) {
|
||||
return null;
|
||||
}
|
||||
// Change fully before the guard: shift the guard by the length delta.
|
||||
if (change.oldTo <= range.from) {
|
||||
const delta = change.newTo - change.oldTo;
|
||||
return { from: range.from + delta, to: range.to + delta };
|
||||
}
|
||||
// Change fully after the guard: positions are unaffected.
|
||||
return range;
|
||||
};
|
||||
|
||||
// Detect history/remote transactions that may arrive as a whole-document
|
||||
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
|
||||
// (isChangeOrigin is the canonical predicate already used across the app).
|
||||
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
|
||||
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
|
||||
|
||||
export const CustomTypography = Typography.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() ?? []),
|
||||
new Plugin({
|
||||
key: undoGuardKey,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, prev, oldState, newState): UndoGuardRange | null {
|
||||
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
|
||||
const change = findChangedRange(oldState, newState);
|
||||
if (change == null) {
|
||||
// Attribute-only or otherwise content-neutral change: keep the
|
||||
// guard.
|
||||
return prev;
|
||||
}
|
||||
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
|
||||
// (deleted + inserted) — the signature of reverting an input-rule
|
||||
// substitution. Pure insertions/deletions and remote peer edits
|
||||
// must not arm it.
|
||||
if (
|
||||
isUndoRedoTransaction(tr) &&
|
||||
change.oldTo > change.from &&
|
||||
change.newTo > change.from
|
||||
) {
|
||||
return { from: change.from, to: change.newTo };
|
||||
}
|
||||
// Non-arming history/remote change: map the existing guard through
|
||||
// the real diff instead of the (whole-document) step map.
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
return mapRangeThroughChange(prev, change);
|
||||
}
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
if (!tr.docChanged) {
|
||||
return prev;
|
||||
}
|
||||
// Ordinary local edit: minimal step maps are accurate and cheap.
|
||||
let range: UndoGuardRange | null = prev;
|
||||
for (const stepMap of tr.mapping.maps) {
|
||||
const { from: rangeFrom, to: rangeTo } = range;
|
||||
let touched = false;
|
||||
stepMap.forEach((fromA, toA) => {
|
||||
if (fromA < rangeTo && toA > rangeFrom) {
|
||||
touched = true;
|
||||
}
|
||||
});
|
||||
if (touched) {
|
||||
range = null;
|
||||
break;
|
||||
}
|
||||
range = {
|
||||
from: stepMap.map(rangeFrom, 1),
|
||||
to: stepMap.map(rangeTo, -1),
|
||||
};
|
||||
}
|
||||
return range && range.to > range.from ? range : null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
// Wrap every typography rule: skip it when its match overlaps the text
|
||||
// just restored by undo, so an undone substitution is not re-applied.
|
||||
return (this.parent?.() ?? []).map(
|
||||
(rule) =>
|
||||
new InputRule({
|
||||
find: rule.find,
|
||||
undoable: rule.undoable,
|
||||
handler: (props) => {
|
||||
const guard = undoGuardKey.getState(props.state);
|
||||
if (
|
||||
guard &&
|
||||
props.range.from < guard.to &&
|
||||
props.range.to > guard.from
|
||||
) {
|
||||
// Returning null skips this rule and lets the typed character
|
||||
// be inserted as plain text.
|
||||
return null;
|
||||
}
|
||||
return rule.handler(props);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { CustomTypography } from "./custom-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
@@ -245,7 +245,9 @@ export const mainExtensions = [
|
||||
return ReactMarkViewRenderer(SpoilerView);
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
// Typography with an undo guard: does not re-apply a substitution the user
|
||||
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
|
||||
CustomTypography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
normalizeTableColumnWidths,
|
||||
classifyClipboardSelection,
|
||||
} from "./markdown-clipboard";
|
||||
|
||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||
function root(html: string): HTMLElement {
|
||||
@@ -124,3 +128,171 @@ describe("normalizeTableColumnWidths", () => {
|
||||
).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyClipboardSelection", () => {
|
||||
it("serializes a list of 2+ items as markdown", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("leaves a single-item list as plain text", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("serializes a whole table without wrapping bare rows", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: true });
|
||||
});
|
||||
|
||||
it("leaves plain paragraphs as plain text", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("does not wrap when rows are mixed with other block types", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
{ name: "paragraph", childCount: 1 },
|
||||
]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
});
|
||||
|
||||
// Output-level tests for the table clipboard regression: copying a table must
|
||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
||||
// shape that the classifier-flag tests above do not cover.
|
||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
||||
function lines(md: string): string[] {
|
||||
return md
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
||||
// of the padding turndown emits.
|
||||
function isSeparatorRow(line: string): boolean {
|
||||
const compact = line.replace(/\s+/g, "");
|
||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
||||
}
|
||||
|
||||
// Split a pipe-delimited row into trimmed cell values.
|
||||
function cells(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
||||
// const table = document.createElement("table");
|
||||
// const tbody = document.createElement("tbody");
|
||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
||||
// div.appendChild(table);
|
||||
// return htmlToMarkdown(div.innerHTML);
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [c1, c2] of [
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = c1;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = c2;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
const ls = lines(md);
|
||||
|
||||
// Valid GFM: a header/data separator row is present (an empty header is
|
||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
||||
// and no line is a bare cell value on its own.
|
||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
||||
// The cell values land in real pipe-delimited data rows.
|
||||
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
||||
expect(dataRows).toContainEqual(["a", "b"]);
|
||||
expect(dataRows).toContainEqual(["c", "d"]);
|
||||
});
|
||||
|
||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
for (const h of ["Name", "Age"]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = h;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [name, age] of [
|
||||
["Alice", "30"],
|
||||
["Bob", "25"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = name;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = age;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
const ls = lines(md);
|
||||
|
||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||
|
||||
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
||||
// Header row comes first, followed by both data rows.
|
||||
expect(rows[0]).toEqual(["Name", "Age"]);
|
||||
expect(rows).toContainEqual(["Alice", "30"]);
|
||||
expect(rows).toContainEqual(["Bob", "25"]);
|
||||
// Headline regression: the table is NOT concatenated one-value-per-line.
|
||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,24 +27,36 @@ export const MarkdownClipboard = Extension.create({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
const topLevelNodes: { name: string; childCount: number }[] = [];
|
||||
slice.content.forEach((node) => {
|
||||
if (listTypes.includes(node.type.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
} else {
|
||||
topLevelCount++;
|
||||
}
|
||||
topLevelNodes.push({
|
||||
name: node.type.name,
|
||||
childCount: node.childCount,
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasList || topLevelCount < 2) return null;
|
||||
const { asMarkdown, wrapBareRows } =
|
||||
classifyClipboardSelection(topLevelNodes);
|
||||
if (!asMarkdown) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
div.appendChild(fragment);
|
||||
|
||||
if (wrapBareRows) {
|
||||
// A partial table cell-selection serializes to bare <tr> nodes
|
||||
// (prosemirror-tables returns the whole `table` node only when the
|
||||
// entire table is selected). Bare <tr> would be foster-parented
|
||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
tbody.appendChild(fragment);
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
} else {
|
||||
div.appendChild(fragment);
|
||||
}
|
||||
return htmlToMarkdown(div.innerHTML);
|
||||
},
|
||||
handlePaste: (view, event, slice) => {
|
||||
@@ -153,6 +165,55 @@ export const MarkdownClipboard = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Decide whether a copied slice's plain-text clipboard payload should be
|
||||
* serialized as Markdown (instead of ProseMirror's default text serializer,
|
||||
* which joins block leaves with newlines — the "one value per line" bug for
|
||||
* tables).
|
||||
*
|
||||
* Serialize as Markdown for structured content:
|
||||
* - lists with 2+ total items (a single copied bullet stays literal text);
|
||||
* - a whole table (top-level `table` node);
|
||||
* - a partial table cell-selection, which prosemirror-tables copies as bare
|
||||
* `tableRow` nodes (only a full-table selection yields a `table` node).
|
||||
*
|
||||
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
|
||||
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
|
||||
* return asMarkdown=false so a simple text copy stays literal, and internal
|
||||
* copy/paste keeps using the richer text/html clipboard payload.
|
||||
*/
|
||||
export function classifyClipboardSelection(
|
||||
nodes: { name: string; childCount: number }[],
|
||||
): { asMarkdown: boolean; wrapBareRows: boolean } {
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
let hasTable = false;
|
||||
let tableRowCount = 0;
|
||||
let nonRowCount = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (listTypes.includes(node.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
nonRowCount++;
|
||||
} else {
|
||||
if (node.name === "table") hasTable = true;
|
||||
if (node.name === "tableRow") tableRowCount++;
|
||||
else nonRowCount++;
|
||||
topLevelCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bare tableRow nodes at the top level only occur for a partial cell
|
||||
// selection; a slice never mixes bare rows with other block types, so
|
||||
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
|
||||
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
|
||||
const asMarkdown =
|
||||
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
|
||||
return { asMarkdown, wrapBareRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollPosition } from "./use-scroll-position";
|
||||
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(a3) restores at most once per mount even if called again", () => {
|
||||
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
@@ -111,8 +111,12 @@ describe("useScrollPosition", () => {
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate the browser now being at the restored position.
|
||||
setScrollY(500);
|
||||
|
||||
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
||||
// restoreScrollPosition]) must NOT scroll again and yank the reader.
|
||||
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
|
||||
// the window is already at the target and does nothing.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
@@ -162,6 +166,84 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("g1"));
|
||||
|
||||
// The reader shows scroll intent before restore is triggered.
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("h1"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
|
||||
|
||||
// The reader takes over mid-poll: this cancels the in-flight poll.
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
|
||||
// Content of the page grows tall enough and time passes: the cancelled poll
|
||||
// must NOT resurrect and yank the reader.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("i1"));
|
||||
|
||||
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// Restore still happens: the innocuous keypress did not disable it.
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("j1"));
|
||||
|
||||
// Space scrolls the page: this is real scroll intent and must abort restore.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
||||
// Nothing saved.
|
||||
const a = renderHook(() => useScrollPosition("nope"));
|
||||
@@ -221,6 +303,55 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
|
||||
// The static->live editor swap re-invokes restore. The shared budget
|
||||
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
|
||||
// from the FIRST trigger, not restart it on every re-trigger. This pins
|
||||
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
|
||||
// `restoreStartRef.current = Date.now()` on every trigger would push the
|
||||
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("k1"));
|
||||
|
||||
// First trigger at t=0: starts the shared budget and begins polling.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
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(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1900);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
|
||||
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
|
||||
// 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", () => {
|
||||
const err = new Error("storage denied");
|
||||
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
||||
@@ -241,3 +372,23 @@ describe("useScrollPosition", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSavedReadingPosition", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns false when nothing is saved for the page", () => {
|
||||
expect(hasSavedReadingPosition("none")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the saved value is 0 (page stays at the top)", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
|
||||
expect(hasSavedReadingPosition("zero")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a positive position is saved", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
|
||||
expect(hasSavedReadingPosition("deep")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
// Throttle interval for persisting the scroll position while the user reads.
|
||||
const SAVE_THROTTLE_MS = 250;
|
||||
@@ -13,6 +14,18 @@ const RESTORE_POLL_MS = 100;
|
||||
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
||||
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
// Keys that scroll the window. Only these count as scroll intent for keydown;
|
||||
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
|
||||
const SCROLL_KEYS = new Set([
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ", // Space (and Shift+Space) scroll the page
|
||||
]);
|
||||
|
||||
function storageKey(pageId: string): string {
|
||||
return `${STORAGE_PREFIX}${pageId}`;
|
||||
}
|
||||
@@ -44,36 +57,56 @@ function writeStorage(pageId: string, scrollY: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a positive reading position is saved for this page — i.e. the page
|
||||
* will be scrolled away from the top on load. Used by the title editor to avoid
|
||||
* auto-focusing (and thus placing the caret in) the now-off-screen title.
|
||||
* Returns false when nothing is saved or storage is unavailable.
|
||||
*/
|
||||
export function hasSavedReadingPosition(pageId: string): boolean {
|
||||
const y = readStorage(pageId);
|
||||
return typeof y === "number" && y > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and restores the window scroll position per page so a reader keeps
|
||||
* their place across a reload (F5) or reopening the document.
|
||||
*
|
||||
* Returns `restoreScrollPosition`, which the page editor calls once the live
|
||||
* (non-static) content is laid out. 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.
|
||||
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
||||
* (early, while the static/cached content is laid out, and again after the
|
||||
* static->live editor swap); it is idempotent, so re-asserting the same target 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): {
|
||||
restoreScrollPosition: () => void;
|
||||
} {
|
||||
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
||||
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
||||
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
|
||||
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
|
||||
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
|
||||
// (refs would hold the first page's target / already-restored flag) — in that
|
||||
// case the refs must be reset on a pageId change.
|
||||
// hook instance with fresh refs. Restore is idempotent and interaction-gated
|
||||
// (not single-shot): it may be called from several triggers and re-asserts the
|
||||
// SAME captured target, which is a no-op once the window is already positioned.
|
||||
// The per-mount refs that latch are `initialTargetRef` (the captured target)
|
||||
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
|
||||
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
|
||||
// If that `key={page.id}` is ever removed, restore would silently break on the
|
||||
// 2nd page (refs would hold the first page's target / interaction flag) — in
|
||||
// that case the refs must be reset on a pageId change.
|
||||
//
|
||||
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
||||
// handler can overwrite the stored value with a fresh 0 (the page starts
|
||||
// scrolled to top on load). `null` means "not yet captured".
|
||||
const initialTargetRef = useRef<number | null>(null);
|
||||
// Guards so restore runs at most once per page mount.
|
||||
const hasRestoredRef = useRef(false);
|
||||
// Set once the reader shows unambiguous scroll intent; restore must never yank
|
||||
// a reader who has already started scrolling.
|
||||
const userInteractedRef = useRef(false);
|
||||
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
||||
// 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).
|
||||
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
|
||||
// effect below registers handlers that would persist the current (0) scrollY.
|
||||
@@ -114,14 +147,43 @@ export function useScrollPosition(pageId: string): {
|
||||
}
|
||||
};
|
||||
|
||||
// User scroll-intent signals. wheel and touch are unconditional scroll
|
||||
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
|
||||
// shortcuts, lone modifiers, and typing do not abort restore. Our own
|
||||
// window.scrollTo does NOT emit these, so restore can never self-abort via
|
||||
// them. Once the reader shows intent we mark it and cancel any in-flight
|
||||
// restore poll so restore can never yank them back. (Scrollbar-drag via
|
||||
// pointer is an accepted small gap — it is not covered here.)
|
||||
const onUserIntent = (event: Event) => {
|
||||
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
|
||||
// scroll keys count — a shortcut or typing must not abort restore.
|
||||
if (
|
||||
event.type === "keydown" &&
|
||||
!SCROLL_KEYS.has((event as KeyboardEvent).key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
userInteractedRef.current = true;
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("wheel", onUserIntent, { passive: true });
|
||||
window.addEventListener("touchstart", onUserIntent, { passive: true });
|
||||
window.addEventListener("keydown", onUserIntent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("wheel", onUserIntent);
|
||||
window.removeEventListener("touchstart", onUserIntent);
|
||||
window.removeEventListener("keydown", onUserIntent);
|
||||
if (throttleTimer !== null) {
|
||||
window.clearTimeout(throttleTimer);
|
||||
throttleTimer = null;
|
||||
@@ -137,9 +199,8 @@ export function useScrollPosition(pageId: string): {
|
||||
}, [pageId]);
|
||||
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
// Run at most once per page mount.
|
||||
if (hasRestoredRef.current) return;
|
||||
hasRestoredRef.current = true;
|
||||
// The reader took over — never yank them back.
|
||||
if (userInteractedRef.current) return;
|
||||
|
||||
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
||||
if (window.location.hash) return;
|
||||
@@ -148,9 +209,26 @@ export function useScrollPosition(pageId: string): {
|
||||
// Nothing meaningful to restore to.
|
||||
if (targetY <= 0) return;
|
||||
|
||||
const start = Date.now();
|
||||
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
|
||||
// never run two concurrent polls against the same target.
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Share one timeout budget across re-triggers instead of restarting it.
|
||||
if (restoreStartRef.current === null) {
|
||||
restoreStartRef.current = Date.now();
|
||||
}
|
||||
const start = restoreStartRef.current;
|
||||
|
||||
const tryRestore = () => {
|
||||
// Bail mid-poll if the reader started scrolling while we were waiting.
|
||||
if (userInteractedRef.current) {
|
||||
pollTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||
@@ -158,10 +236,12 @@ export function useScrollPosition(pageId: string): {
|
||||
// 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) {
|
||||
window.scrollTo({
|
||||
top: Math.min(targetY, Math.max(maxScroll, 0)),
|
||||
behavior: "auto",
|
||||
});
|
||||
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
||||
// Redundancy guard: re-asserting the SAME target when already positioned
|
||||
// is a no-op, so this hook can be called from multiple triggers safely.
|
||||
if (Math.abs(window.scrollY - top) > 1) {
|
||||
window.scrollTo({ top, behavior: "auto" });
|
||||
}
|
||||
pollTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -175,3 +255,37 @@ export function useScrollPosition(pageId: string): {
|
||||
|
||||
return { restoreScrollPosition };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires `useScrollPosition` to the page editor's static->live swap lifecycle.
|
||||
*
|
||||
* Extracted from PageEditor so the exact restore triggers (their deps and the
|
||||
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
|
||||
* Behaviour is unchanged: `restoreScrollPosition` is idempotent, so re-asserting
|
||||
* the same target from either trigger is a no-op.
|
||||
*
|
||||
* @param pageId the page whose scroll position is persisted/restored.
|
||||
* @param editor the tiptap editor instance, or `null` until it is ready.
|
||||
* @param showStatic whether the static (cached) content is still shown.
|
||||
*/
|
||||
export function useScrollRestoreOnSwap(
|
||||
pageId: string,
|
||||
editor: Editor | null,
|
||||
showStatic: boolean,
|
||||
): void {
|
||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||
|
||||
// Restore as early as the static (cached) content is laid out, before paint,
|
||||
// so the reader's position is applied without a visible jump. Aborts itself if
|
||||
// the reader has already started scrolling (handled inside the hook).
|
||||
useLayoutEffect(() => {
|
||||
restoreScrollPosition();
|
||||
}, [restoreScrollPosition]);
|
||||
|
||||
// Re-assert once after the static -> live editor swap in case the swap reset
|
||||
// the window scroll. Idempotent: a no-op when the position is already correct,
|
||||
// and a no-op after the reader has interacted.
|
||||
useLayoutEffect(() => {
|
||||
if (!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 };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTitleAutofocus } from "./use-title-autofocus";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
function fakeEditor(overrides = {}) {
|
||||
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
|
||||
}
|
||||
|
||||
describe("useTitleAutofocus", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips auto-focus when a saved reading position exists", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "saved"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
|
||||
});
|
||||
|
||||
it("does not focus before initialization", () => {
|
||||
const editor = fakeEditor({ isInitialized: false });
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh2"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the pending focus on unmount", () => {
|
||||
const editor = fakeEditor();
|
||||
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
|
||||
unmount();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { hasSavedReadingPosition } from "./use-scroll-position";
|
||||
|
||||
// Delay before auto-focusing the title on load — guards a tiptap init race
|
||||
// ("Cannot access view['hasFocus']" if focused too early).
|
||||
const TITLE_AUTOFOCUS_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
|
||||
* will be restored (then the viewport scrolls away from the top, and focusing the
|
||||
* top-of-page title would drop the caret off-screen). When it does focus, it uses
|
||||
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
|
||||
* (tiptap's focus scrolls the focused node into view by default, which otherwise
|
||||
* yanks the window to the top and fights scroll-position restoration).
|
||||
*
|
||||
* Extracted from TitleEditor so this exact decision is unit-testable.
|
||||
*
|
||||
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
|
||||
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
|
||||
* per page. It is read synchronously on first render, before any scroll-save
|
||||
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
|
||||
* synchronous capture of `initialTargetRef`.
|
||||
*/
|
||||
export function useTitleAutofocus(
|
||||
titleEditor: Editor | null,
|
||||
pageId: string,
|
||||
): void {
|
||||
const hasSavedScrollRef = useRef<boolean | null>(null);
|
||||
if (hasSavedScrollRef.current === null) {
|
||||
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSavedScrollRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// guard against "Cannot access view['hasFocus']" before init
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end", { scrollIntoView: false });
|
||||
}, TITLE_AUTOFOCUS_DELAY_MS);
|
||||
// Clear the pending focus if the editor changes or the component unmounts
|
||||
// (also fixes the previously-uncancelled timer).
|
||||
return () => clearTimeout(timer);
|
||||
}, [titleEditor]);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
|
||||
//
|
||||
// The real UX wiring lives in the exported `useScrollRestoreOnSwap` hook (two
|
||||
// useLayoutEffects around useScrollPosition), which PageEditor calls with the
|
||||
// same signature. A FULL PageEditor component test is impractical here and has no
|
||||
// precedent in this client: PageEditor directly constructs a
|
||||
// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
|
||||
// collab extensions, reads jotai atoms, react-router params, the shared
|
||||
// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
|
||||
// the static->live swap (`showStatic` -> false) is gated on
|
||||
// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
|
||||
// by driving the mocked collab provider's async sync callbacks. The heaviest
|
||||
// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
|
||||
// single leaf component with ONE mocked query; nothing mounts a feature root of
|
||||
// this weight. Reproducing all of that would test the mocks, not the wiring.
|
||||
//
|
||||
// So this file tests the REAL `useScrollRestoreOnSwap` hook — the exact code
|
||||
// PageEditor imports and calls — driving its `showStatic`/`editor` inputs the way
|
||||
// the swap does. Because it exercises the real hook (not a copy), dropping the
|
||||
// `&& editor` guard or changing the effect deps makes these tests fail; they
|
||||
// guard the production code directly (verified: removing `&& editor` reddens the
|
||||
// first test).
|
||||
//
|
||||
// Both tests observe the real effect via `window.scrollTo`. The stubbed
|
||||
// `window.scrollTo` never mutates `window.scrollY`, and the target is left
|
||||
// unreached, so every restore invocation that passes the guard yields exactly one
|
||||
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
|
||||
|
||||
function setScrollY(value: number): void {
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value });
|
||||
}
|
||||
function setScrollHeight(value: number): void {
|
||||
Object.defineProperty(document.documentElement, "scrollHeight", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
function setInnerHeight(value: number): void {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value });
|
||||
}
|
||||
|
||||
// Minimal stand-in for the tiptap editor: the hook only truthiness-checks it.
|
||||
const fakeEditor = { id: "editor" } as unknown as Editor;
|
||||
|
||||
// Thin host that calls the REAL hook so a rerender drives showStatic/editor
|
||||
// exactly like the page-editor swap does.
|
||||
function Host({
|
||||
pageId,
|
||||
showStatic,
|
||||
editor,
|
||||
}: {
|
||||
pageId: string;
|
||||
showStatic: boolean;
|
||||
editor: Editor | null;
|
||||
}) {
|
||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
setScrollY(0);
|
||||
setScrollHeight(0);
|
||||
setInnerHeight(800);
|
||||
window.scrollTo = vi.fn();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
it("re-invokes restore after the swap, with the [showStatic, editor] deps/guard", () => {
|
||||
// Target is immediately reachable, so each restore that passes the guard
|
||||
// scrolls synchronously. `window.scrollY` stays 0 (stubbed scrollTo never
|
||||
// updates it), so scrollTo is called once per effective restore — a proxy for
|
||||
// the restore invocation count.
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(2000); // maxScroll = 1200 >= 500: reachable, no polling.
|
||||
|
||||
// Pre-swap: static content shown, live editor not ready. Only the early
|
||||
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
|
||||
const { rerender } = render(
|
||||
<Host pageId="guard" showStatic={true} editor={null} />,
|
||||
);
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Collab reports synced (showStatic flips false) but the editor is not ready
|
||||
// yet: the swap effect re-runs (deps [showStatic, editor] changed) but the
|
||||
// `&& editor` guard must keep it a no-op. The early effect does NOT re-fire
|
||||
// (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} />);
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The static -> live swap completes (showStatic false AND editor present): the
|
||||
// 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} />);
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
|
||||
|
||||
// Pre-swap: the early restore runs but content is too short, so it starts
|
||||
// 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(
|
||||
<Host pageId="peg" showStatic={true} editor={null} />,
|
||||
);
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// The live content is now laid out tall enough to reach the target.
|
||||
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||
|
||||
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
|
||||
// hook, whose synchronous tryRestore now reaches the target and scrolls.
|
||||
act(() => {
|
||||
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
collabExtensions,
|
||||
mainExtensions,
|
||||
} 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 { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
dictationAvailabilityAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
@@ -78,7 +79,8 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { useScrollPosition } 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 ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import {
|
||||
computeDictationAvailability,
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
@@ -138,12 +141,12 @@ export default function PageEditor({
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
@@ -450,6 +453,22 @@ export default function PageEditor({
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
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(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -472,25 +491,55 @@ export default function PageEditor({
|
||||
);
|
||||
}, [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(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
// Restore the saved reading position once the live content is laid out.
|
||||
useEffect(() => {
|
||||
if (!showStatic && editor) restoreScrollPosition();
|
||||
}, [showStatic, editor, restoreScrollPosition]);
|
||||
// Restore the reader's scroll position across the static -> live editor swap.
|
||||
// The wiring (early pre-paint restore + post-swap re-assert) lives in the hook
|
||||
// so its triggers/guard are directly unit-testable.
|
||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
<div
|
||||
ref={swapWrapperRef}
|
||||
style={
|
||||
reservedHeight != null ? { minHeight: reservedHeight } : undefined
|
||||
}
|
||||
>
|
||||
{showStatic ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Surface the pre-sync read-only window so edits typed before the
|
||||
@@ -578,6 +627,7 @@ export default function PageEditor({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedLookupProvider>
|
||||
</TransclusionLookupProvider>
|
||||
|
||||
@@ -71,3 +71,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline image rows (#284): center the anonymous line boxes formed by
|
||||
consecutive [data-image-align="inline"] node-view containers. A row has no
|
||||
DOM wrapper of its own, so its horizontal placement is controlled by the
|
||||
text-align of the nearest block ancestor (the editor root or a nested
|
||||
block container: blockquote, callout, list item, table cell, details).
|
||||
Centering is enabled only in containers that actually hold an inline
|
||||
image (:has), and every other child of such a container gets its default
|
||||
alignment back so ordinary text is unaffected. Explicit per-block
|
||||
alignment from the toolbar is an inline style and still wins. Browsers
|
||||
without :has() degrade to left-pinned rows. */
|
||||
.ProseMirror:has(> [data-image-align="inline"]),
|
||||
.ProseMirror :has(> [data-image-align="inline"]) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
|
||||
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -167,13 +168,7 @@ export function TitleEditor({
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
useTitleAutofocus(titleEditor, pageId);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -99,12 +99,13 @@ const HistoryItem = memo(function HistoryItem({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAgentEdit && (
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
{isAgentEdit && historyItem.agent && (
|
||||
<AgentAvatarStack
|
||||
agent={historyItem.agent}
|
||||
launcher={historyItem.launcher}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
// The history row owns the modal: close it when the badge deep-links
|
||||
// into the chat (the badge no longer reaches into page-history).
|
||||
// The history row owns the modal: close it when the stack deep-links
|
||||
// into the chat (the stack no longer reaches into page-history).
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
LauncherInfo,
|
||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
||||
|
||||
interface IPageHistoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,4 +29,9 @@ export interface IPageHistory {
|
||||
// (when present) deep-links to the chat that produced the edit.
|
||||
lastUpdatedSource?: string;
|
||||
lastUpdatedAiChatId?: string | null;
|
||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
||||
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
|
||||
// human behind it (null for an external MCP agent).
|
||||
agent?: AgentInfo | null;
|
||||
launcher?: LauncherInfo | null;
|
||||
}
|
||||
|
||||
@@ -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 { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<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
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
@@ -87,28 +101,58 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{/* Desktop: full labeled buttons. */}
|
||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{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>
|
||||
</Paper>
|
||||
|
||||
@@ -13,20 +13,30 @@ export type OpenMap = Record<string, boolean>;
|
||||
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
||||
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
||||
|
||||
// Single source of truth for the open-map localStorage key prefix. Exported so
|
||||
// the logout cache sweep (tree-data-atom.ts) removes keys by the SAME prefix
|
||||
// used to write them — a rename here can never silently desync the cleanup.
|
||||
export const OPEN_TREE_NODES_KEY_PREFIX = "openTreeNodes:";
|
||||
|
||||
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
||||
// key prevents accounts that share a browser origin from leaking tree state.
|
||||
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
||||
// so the first render already has the saved state — no collapse-then-expand
|
||||
// flicker on reload, and writes never run against an un-hydrated empty map.
|
||||
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
||||
atomWithStorage<OpenMap>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
|
||||
getOnInit: true,
|
||||
}),
|
||||
atomWithStorage<OpenMap>(
|
||||
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
|
||||
{},
|
||||
openTreeNodesStorage,
|
||||
{ getOnInit: true },
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
||||
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
||||
const scopeKeyAtom = atom((get) => {
|
||||
// Shared by the open-map atom below and the persisted tree-data atom
|
||||
// (tree-data-atom.ts) so both caches are scoped identically.
|
||||
export const scopeKeyAtom = atom((get) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
||||
const userId = currentUser?.user?.id ?? "anon";
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import type { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
// The persisted tree-data atom hydrates from localStorage ONCE, at family-atom
|
||||
// creation (`getOnInit: true`). To exercise hydration deterministically each
|
||||
// test imports a FRESH module instance (fresh atomFamily) after seeding the
|
||||
// storage stub from vitest.setup.ts. jotai itself is externalized by vitest, so
|
||||
// `createStore` can stay a static import — atoms are plain objects and any
|
||||
// store works with any module instance.
|
||||
import { createStore } from "jotai";
|
||||
|
||||
// Storage key for the default scope: no currentUser -> "anon:anon" (see
|
||||
// scopeKeyAtom in open-tree-nodes-atom.ts) with the `v1` cache-shape version.
|
||||
const ANON_KEY = "treeData:v1:anon:anon";
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
async function freshImport() {
|
||||
vi.resetModules();
|
||||
const treeDataModule = await import("./tree-data-atom");
|
||||
const userModule = await import(
|
||||
"@/features/user/atoms/current-user-atom"
|
||||
);
|
||||
return {
|
||||
treeDataAtom: treeDataModule.treeDataAtom,
|
||||
flushPendingTreeDataWrites: treeDataModule.flushPendingTreeDataWrites,
|
||||
clearPersistedTreeCaches: treeDataModule.clearPersistedTreeCaches,
|
||||
currentUserAtom: userModule.currentUserAtom,
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Every persisted tree key currently in storage — asserting on the whole
|
||||
// prefix (not one known key) catches writes that resurrect under ANY scope.
|
||||
function persistedTreeDataKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && key.startsWith("treeData:v1:")) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function currentUser(workspaceId: string, userId: string): ICurrentUser {
|
||||
return {
|
||||
user: { id: userId },
|
||||
workspace: { id: workspaceId },
|
||||
} as unknown as ICurrentUser;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("treeDataAtom (localStorage-persisted)", () => {
|
||||
it("reads [] from a fresh store with empty storage", async () => {
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists through the debounced setItem and hydrates a fresh module back", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
// Second write inside the debounce window — must coalesce into ONE flush
|
||||
// carrying only the latest value.
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS / 2);
|
||||
store.set(treeDataAtom, [node("a"), node("b")]);
|
||||
|
||||
// Nothing flushed yet: the write is trailing-debounced.
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(localStorage.getItem(ANON_KEY)!)).toEqual([
|
||||
node("a"),
|
||||
node("b"),
|
||||
]);
|
||||
|
||||
// A fresh module (fresh atom family -> getOnInit re-reads storage) and a
|
||||
// fresh store hydrate the persisted tree back — the reload scenario.
|
||||
const second = await freshImport();
|
||||
const store2 = createStore();
|
||||
expect(store2.get(second.treeDataAtom)).toEqual([node("a"), node("b")]);
|
||||
});
|
||||
|
||||
it("reads [] (without throwing) when storage holds corrupted JSON", async () => {
|
||||
localStorage.setItem(ANON_KEY, "{definitely not JSON!!!");
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads [] when storage holds valid JSON of a non-array shape", async () => {
|
||||
localStorage.setItem(ANON_KEY, JSON.stringify({ id: "not-a-tree" }));
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports functional-updater writes", async () => {
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
store.set(treeDataAtom, (prev) => [...prev, node("b")]);
|
||||
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("isolates trees between (workspace, user) scopes", async () => {
|
||||
const { treeDataAtom, currentUserAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
||||
|
||||
// Another account on the same browser origin must NOT see u1's tree.
|
||||
store.set(currentUserAtom, currentUser("w2", "u2"));
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
|
||||
store.set(treeDataAtom, [node("b")]);
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["b"]);
|
||||
|
||||
// Switching back resolves the original scope's tree untouched.
|
||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("clearPersistedTreeCaches removes all tree keys and discards pending writes", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Stale caches across scopes plus an UNRELATED key that must survive.
|
||||
localStorage.setItem("treeData:v1:a:b", JSON.stringify([node("stale")]));
|
||||
localStorage.setItem("openTreeNodes:a:b", JSON.stringify({ p1: true }));
|
||||
localStorage.setItem("currentUser", JSON.stringify({ user: { id: "b" } }));
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// Queue a debounced write (not flushed yet) for the anon scope.
|
||||
store.set(treeDataAtom, [node("pending")]);
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
|
||||
clearPersistedTreeCaches();
|
||||
|
||||
// Both prefixed caches are swept; the unrelated key is untouched.
|
||||
expect(localStorage.getItem("treeData:v1:a:b")).toBeNull();
|
||||
expect(localStorage.getItem("openTreeNodes:a:b")).toBeNull();
|
||||
expect(localStorage.getItem("currentUser")).toBe(
|
||||
JSON.stringify({ user: { id: "b" } }),
|
||||
);
|
||||
|
||||
// The queued write was DISCARDED, not merely delayed: the debounce timer
|
||||
// firing later must not resurrect a tree key after logout.
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearPersistedTreeCaches discards queued writes even when flushed DIRECTLY", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
||||
await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// Queue a debounced write, then clear. Calling the flush directly (not via
|
||||
// the debounce timer) isolates the pending-queue discard from the timer
|
||||
// cancel: if the queue survived, this flush would resurrect the key even
|
||||
// though the timer never fired.
|
||||
store.set(treeDataAtom, [node("pending")]);
|
||||
clearPersistedTreeCaches();
|
||||
flushPendingTreeDataWrites();
|
||||
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(persistedTreeDataKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips persisting a tree over the size cap and warns exactly once", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
||||
|
||||
const { treeDataAtom, flushPendingTreeDataWrites } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// One node whose name alone serializes to > MAX_SERIALIZED_LENGTH (~4M).
|
||||
const huge = node("big");
|
||||
huge.name = "x".repeat(4_000_001);
|
||||
|
||||
store.set(treeDataAtom, [huge]);
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
|
||||
// The oversized serialization is skipped: the key is never written.
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Editing the still-oversized tree fires another debounced write, but the
|
||||
// "too large" warn is gated by the once-flag — no per-tick console spam.
|
||||
store.set(treeDataAtom, [huge, node("big2")]);
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
flushPendingTreeDataWrites();
|
||||
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[tree] cached tree too large to persist; skipping",
|
||||
ANON_KEY,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables persistence after clearPersistedTreeCaches: NEW writes never reach storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
||||
await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
clearPersistedTreeCaches();
|
||||
|
||||
// The resurrection scenario: a websocket tree event lands while `await
|
||||
// logout()` is still in flight, AFTER the sweep. The write must not be
|
||||
// queued, must not arm a new debounce timer, and must not survive the
|
||||
// beforeunload flush fired by the logout redirect.
|
||||
store.set(treeDataAtom, [node("late")]);
|
||||
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
flushPendingTreeDataWrites(); // what the beforeunload handler runs
|
||||
|
||||
expect(persistedTreeDataKeys()).toEqual([]);
|
||||
|
||||
// Only PERSISTENCE is disabled: the in-memory atom keeps working, so the
|
||||
// UI stays intact during the brief pre-redirect window.
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["late"]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,206 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { appendNodeChildren } from "../utils";
|
||||
import {
|
||||
OPEN_TREE_NODES_KEY_PREFIX,
|
||||
scopeKeyAtom,
|
||||
} from "./open-tree-nodes-atom";
|
||||
|
||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||
// The sidebar tree is persisted to localStorage so a page reload can paint the
|
||||
// last-known tree IMMEDIATELY (no blank sidebar while the root query runs) and
|
||||
// then reconcile with the server in the background. localStorage is a BOOT
|
||||
// CACHE only — the in-memory atom stays the source of truth while the app runs.
|
||||
|
||||
// Trailing-debounce machinery for the localStorage writes. The tree is
|
||||
// rewritten on every lazy load / drag / socket event; serializing a large tree
|
||||
// on each update would burn CPU and thrash the storage quota, so writes are
|
||||
// coalesced (~500 ms per burst) and only the latest value per key is flushed.
|
||||
const WRITE_DEBOUNCE_MS = 500;
|
||||
|
||||
// Single source of truth for the tree-cache localStorage key prefix. The `v1`
|
||||
// segment versions the cached node shape (bump it when SpaceTreeNode changes
|
||||
// incompatibly). Shared by the storage key construction below AND the logout
|
||||
// sweep in clearPersistedTreeCaches() so the two can never drift apart.
|
||||
export const TREE_DATA_KEY_PREFIX = "treeData:v1:";
|
||||
|
||||
// Size guard: skip persisting trees whose JSON exceeds ~4M chars. localStorage
|
||||
// quota is typically ~5 MB per origin; a huge tree must not evict everything
|
||||
// else or spam QuotaExceededError on every debounce tick.
|
||||
const MAX_SERIALIZED_LENGTH = 4_000_000;
|
||||
|
||||
const pendingWrites = new Map<string, SpaceTreeNode[]>();
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let writeFailureWarned = false;
|
||||
|
||||
// Persistence kill-switch, armed by clearPersistedTreeCaches(). Once set, the
|
||||
// debounced setItem and the flush become no-ops so nothing can be written back
|
||||
// to localStorage AFTER the logout sweep: a websocket tree event landing while
|
||||
// `await logout()` is still in flight would otherwise re-queue a write that
|
||||
// the `beforeunload` flush (fired by the redirect) silently resurrects.
|
||||
// Intentionally never reset: every caller of clearPersistedTreeCaches()
|
||||
// immediately navigates away with a full page load
|
||||
// (window.location.replace/href), so this module instance is torn down anyway.
|
||||
// Only PERSISTENCE stops — the in-memory atoms keep working, so the UI stays
|
||||
// intact during the brief pre-redirect window.
|
||||
let persistenceDisabled = false;
|
||||
|
||||
function writeNow(key: string, value: SpaceTreeNode[]): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized.length > MAX_SERIALIZED_LENGTH) {
|
||||
// Warn ONCE, like the quota branch below: a >4M-char tree re-serializes on
|
||||
// every ~500ms debounce tick while it's edited, so an un-gated warn would
|
||||
// spam the console on each flush.
|
||||
if (!writeFailureWarned) {
|
||||
writeFailureWarned = true;
|
||||
console.warn("[tree] cached tree too large to persist; skipping", key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, serialized);
|
||||
} catch (err) {
|
||||
// QuotaExceededError, private mode, jsdom shims without working storage…
|
||||
// The cache is best-effort: warn once, keep the in-memory tree working.
|
||||
if (!writeFailureWarned) {
|
||||
writeFailureWarned = true;
|
||||
console.warn("[tree] failed to persist tree cache", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exported so tests can force the debounced write synchronously; production
|
||||
// code must never need it (the beforeunload hook below covers reloads).
|
||||
export function flushPendingTreeDataWrites(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (persistenceDisabled) {
|
||||
// Belt-and-braces: after logout nothing may reach localStorage, even via
|
||||
// the beforeunload flush racing the redirect. Drop anything queued.
|
||||
pendingWrites.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of pendingWrites) {
|
||||
writeNow(key, value);
|
||||
}
|
||||
pendingWrites.clear();
|
||||
}
|
||||
|
||||
// Logout hygiene: the tree cache stores PAGE TITLES, so leaving it behind
|
||||
// would keep them readable in localStorage on a shared machine after logout.
|
||||
// Sweep by key prefix (not just the current scope) so stale scopes — old
|
||||
// users, the `anon:anon` fallback — are purged too. Pending debounced writes
|
||||
// are DISCARDED first (not flushed): a queued write firing after the sweep
|
||||
// would silently resurrect a removed key.
|
||||
export function clearPersistedTreeCaches(): void {
|
||||
// Disable persistence FIRST so no write can be queued (or flushed) between
|
||||
// the sweep below and the full-page navigation every caller performs next.
|
||||
persistenceDisabled = true;
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
pendingWrites.clear();
|
||||
try {
|
||||
// Collect matching keys BEFORE removing: deleting while iterating
|
||||
// `localStorage.key(i)` shifts the indices and skips entries.
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (
|
||||
key !== null &&
|
||||
(key.startsWith(TREE_DATA_KEY_PREFIX) ||
|
||||
key.startsWith(OPEN_TREE_NODES_KEY_PREFIX))
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: disabled storage / jsdom shims must never break logout.
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the pending debounced write on unload so a reload right after a tree
|
||||
// change doesn't lose the newest state (the debounce would otherwise eat it).
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.addEventListener === "function"
|
||||
) {
|
||||
window.addEventListener("beforeunload", flushPendingTreeDataWrites);
|
||||
}
|
||||
|
||||
// Custom sync storage for the tree cache. Deliberately NO `subscribe` key:
|
||||
// cross-tab sync would REPLACE this tab's tree wholesale and clobber in-flight
|
||||
// lazy loads; websockets already keep every open tab live. Each tab keeps its
|
||||
// own in-memory tree — localStorage only seeds the next boot.
|
||||
const treeDataStorage = {
|
||||
getItem: (key: string, initialValue: SpaceTreeNode[]): SpaceTreeNode[] => {
|
||||
// Defensive: jsdom test shims may lack methods, stored JSON may be
|
||||
// corrupted or of a wrong shape. Any failure falls back to the empty tree.
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return initialValue;
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as SpaceTreeNode[]) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
},
|
||||
setItem: (key: string, newValue: SpaceTreeNode[]): void => {
|
||||
// After logout the cache must stay purged: neither queue the write nor arm
|
||||
// a new flush timer (see persistenceDisabled above). The in-memory atom
|
||||
// value is unaffected — only the localStorage mirror is frozen.
|
||||
if (persistenceDisabled) return;
|
||||
pendingWrites.set(key, newValue);
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flushPendingTreeDataWrites, WRITE_DEBOUNCE_MS);
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
pendingWrites.delete(key);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* best-effort cache — ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// One persisted tree per (workspace, user) — same scoping rationale as the
|
||||
// open-map atom (accounts sharing a browser origin must not leak trees).
|
||||
// `getOnInit: true` reads localStorage synchronously at atom init, so the very
|
||||
// first render already has the cached tree — no blank-then-jump sidebar.
|
||||
const treeDataFamily = atomFamily((scopeKey: string) =>
|
||||
atomWithStorage<SpaceTreeNode[]>(
|
||||
`${TREE_DATA_KEY_PREFIX}${scopeKey}`,
|
||||
[],
|
||||
treeDataStorage,
|
||||
{ getOnInit: true },
|
||||
),
|
||||
);
|
||||
|
||||
// Public facade — same read value (SpaceTreeNode[]) and same setter shape
|
||||
// (value OR functional updater) as the previous in-memory atom, transparently
|
||||
// routed to the persisted tree of the current workspace/user.
|
||||
export const treeDataAtom = atom(
|
||||
(get) => get(treeDataFamily(get(scopeKeyAtom))),
|
||||
(
|
||||
get,
|
||||
set,
|
||||
update: SpaceTreeNode[] | ((prev: SpaceTreeNode[]) => SpaceTreeNode[]),
|
||||
) => {
|
||||
const target = treeDataFamily(get(scopeKeyAtom));
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: SpaceTreeNode[]) => SpaceTreeNode[])(get(target))
|
||||
: update;
|
||||
set(target, next);
|
||||
},
|
||||
);
|
||||
|
||||
// Atom
|
||||
export const appendNodeChildrenAtom = atom(
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, act, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// Same isolation strategy as space-tree.expand-all.test.tsx: everything that
|
||||
// would otherwise need a real server / router / DnD stack is mocked. Here we
|
||||
// additionally CAPTURE the DocTree props (onToggle + data) so the test can
|
||||
// drive a lazy-load expand exactly as a row click would, and we control
|
||||
// fetchAllAncestorChildren to assert the fresh fetch happens.
|
||||
|
||||
const fetchAllAncestorChildrenMock = vi.fn();
|
||||
|
||||
// Holder mutated by the DocTree stub each render so the test can read the
|
||||
// latest tree it was handed and invoke its onToggle callback.
|
||||
const docTree: {
|
||||
onToggle?: (id: string, isOpen: boolean) => void | Promise<void>;
|
||||
data: unknown[];
|
||||
} = { data: [] };
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: vi.fn(),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the server data-load effect stays
|
||||
// inert (isDataLoaded never flips), so refreshOpenBranches never runs and the
|
||||
// test exercises ONLY the boot-prune + handleToggle lazy-load path against
|
||||
// the hydrated cache we seed into the atom below.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
||||
fetchAllAncestorChildrenMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ pageSlug: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
extractPageSlugId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
isCompactPageTreeEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Capture the props DocTree is rendered with instead of rendering anything.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: (props: { onToggle: (id: string, isOpen: boolean) => void; data: unknown[] }) => {
|
||||
docTree.onToggle = props.onToggle;
|
||||
docTree.data = props.data;
|
||||
return null;
|
||||
},
|
||||
ROW_HEIGHT_COMPACT: 28,
|
||||
ROW_HEIGHT_STANDARD: 32,
|
||||
}));
|
||||
vi.mock("./space-tree-row", () => ({
|
||||
SpaceTreeRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
// In-memory open-map (the real one is localStorage-backed and crashes under the
|
||||
// jsdom shim). Empty at start of each test -> every branch is COLLAPSED, which
|
||||
// is exactly the state we need to prove the boot-prune. `scopeKeyAtom` is
|
||||
// re-exported because the persisted tree-data atom resolves its scope through it.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
const base = atom<OpenMap>({});
|
||||
const openTreeNodesAtom = atom(
|
||||
(get) => get(base),
|
||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
||||
: update;
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
||||
return { openTreeNodesAtom, scopeKeyAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import {
|
||||
treeDataAtom,
|
||||
flushPendingTreeDataWrites,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// The scopeKeyAtom mock resolves to this fixed scope, so the persisted
|
||||
// tree-data atom hydrates from exactly this localStorage key at mount
|
||||
// (getOnInit + atomWithStorage's onMount both read it).
|
||||
const CACHE_KEY = "treeData:v1:test-workspace:test-user";
|
||||
|
||||
function child(
|
||||
id: string,
|
||||
parentPageId: string,
|
||||
hasChildren = false,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId,
|
||||
hasChildren,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// A hydrated boot cache: a COLLAPSED branch (not in the open-map) that still
|
||||
// carries a stale cached child — the exact shape a previous session left behind
|
||||
// after the branch was expanded then collapsed then persisted.
|
||||
function cachedTreeWithCollapsedBranch(): SpaceTreeNode[] {
|
||||
return [
|
||||
{
|
||||
id: "branch",
|
||||
slugId: "slug-branch",
|
||||
name: "branch",
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: true,
|
||||
children: [child("stale", "branch")],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchAllAncestorChildrenMock.mockReset();
|
||||
docTree.onToggle = undefined;
|
||||
docTree.data = [];
|
||||
// Flush any pending debounced write from a previous test before clearing.
|
||||
flushPendingTreeDataWrites();
|
||||
try {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree boot-cache prune (#159 #8 stale collapsed children)", () => {
|
||||
it("drops a collapsed cached branch's children on boot and fetches fresh on first expand", async () => {
|
||||
// Server returns FRESH children on the lazy-load: the stale cached child is
|
||||
// gone, a renamed/new one takes its place.
|
||||
fetchAllAncestorChildrenMock.mockResolvedValue([child("fresh", "branch")]);
|
||||
|
||||
// Simulate the localStorage-hydrated boot cache: seed the persisted key
|
||||
// BEFORE mount so the atom hydrates it (store.set would be clobbered by
|
||||
// atomWithStorage's onMount re-reading storage — this is the real path).
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify(cachedTreeWithCollapsedBranch()),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Boot-prune ran at mount: the COLLAPSED branch's cached children were
|
||||
// dropped to the unloaded shape ([]), so the stale child is no longer there.
|
||||
const branchAfterBoot = docTree.data.find(
|
||||
(n) => (n as SpaceTreeNode).id === "branch",
|
||||
) as SpaceTreeNode;
|
||||
expect(branchAfterBoot.children).toEqual([]);
|
||||
expect(branchAfterBoot.hasChildren).toBe(true);
|
||||
|
||||
// First expand of the collapsed branch after boot must lazy-load fresh
|
||||
// children (before this fix the cached children were kept and the fetch
|
||||
// was skipped, showing stale data).
|
||||
await act(async () => {
|
||||
await docTree.onToggle!("branch", true);
|
||||
});
|
||||
|
||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledWith({
|
||||
pageId: "branch",
|
||||
spaceId: "space-1",
|
||||
});
|
||||
|
||||
// The fresh children replaced the stale cache in the live tree.
|
||||
await waitFor(() => {
|
||||
const branch = store
|
||||
.get(treeDataAtom)
|
||||
.find((n) => n.id === "branch")!;
|
||||
expect(branch.children.map((c) => c.id)).toEqual(["fresh"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,8 @@ vi.mock("@mantine/core", () => ({
|
||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||
// shape (value OR functional updater) so the component's open-state logic runs
|
||||
// unchanged while staying inside the test store.
|
||||
// unchanged while staying inside the test store. `scopeKeyAtom` is also
|
||||
// re-exported (the real module exports it for the persisted tree-data atom).
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
@@ -86,11 +87,17 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
return { openTreeNodesAtom };
|
||||
// Fixed scope key: the tree-data atom family resolves through this, so all
|
||||
// tests read/write the same (empty at start of each test) storage key.
|
||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
||||
return { openTreeNodesAtom, scopeKeyAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import {
|
||||
treeDataAtom,
|
||||
flushPendingTreeDataWrites,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -134,6 +141,10 @@ function renderTree(store: ReturnType<typeof createStore>) {
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// The tree-data atom persists via a ~500 ms trailing debounce; flush it NOW
|
||||
// (cancelling the timer) so a previous test's pending write can't land in
|
||||
// storage mid-test after the clear below.
|
||||
flushPendingTreeDataWrites();
|
||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
openBranches,
|
||||
closeIds,
|
||||
loadedOpenBranchIds,
|
||||
pruneCollapsedChildren,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -199,45 +200,81 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
const openIdsRef = useRef(openIds);
|
||||
openIdsRef.current = openIds;
|
||||
|
||||
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
|
||||
// the children of every currently-open, already-loaded branch of THIS space,
|
||||
// Boot-cache hygiene (#159 #8): the localStorage-hydrated tree carries the
|
||||
// children of every branch ever expanded, including ones now COLLAPSED. Their
|
||||
// first expand would skip the lazy-load and render stale children (a
|
||||
// rename/move/delete missed while offline). Drop the cached children of every
|
||||
// COLLAPSED branch ONCE at mount so its first expand fetches fresh via
|
||||
// handleToggle — exactly as it did before the tree was cached. OPEN branches
|
||||
// keep their children and are refreshed by refreshOpenBranches instead, so
|
||||
// this runs before any expand and never double-fetches an open branch.
|
||||
const prunedBootCacheRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (prunedBootCacheRef.current) return;
|
||||
prunedBootCacheRef.current = true;
|
||||
setData((prev) => pruneCollapsedChildren(prev, openIdsRef.current));
|
||||
}, [setData]);
|
||||
|
||||
// Re-fetch and reconcile the children of every currently-open, already-loaded
|
||||
// branch of THIS space. Shared by the socket reconnect handler and the
|
||||
// post-load cache refresh below. The ROOT level is reconciled separately by
|
||||
// the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped
|
||||
// (lazy-load fetches it fresh on expand). Reads refs so it always sees the
|
||||
// latest tree/open-state/space without re-creating the callback.
|
||||
const refreshOpenBranches = useCallback(async () => {
|
||||
const effectSpaceId = spaceIdRef.current;
|
||||
const branchIds = loadedOpenBranchIds(
|
||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||
openIdsRef.current,
|
||||
);
|
||||
if (branchIds.length === 0) return;
|
||||
for (const id of branchIds) {
|
||||
try {
|
||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||
// reconcile sees the server's CURRENT children (handler-order
|
||||
// independent — no reliance on the global reconnect invalidation).
|
||||
const fresh = await fetchAllAncestorChildren(
|
||||
{ pageId: id, spaceId: effectSpaceId },
|
||||
{ fresh: true },
|
||||
);
|
||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||
} catch (err) {
|
||||
console.error("[tree] open branch refresh failed", err);
|
||||
}
|
||||
}
|
||||
}, [setData]);
|
||||
|
||||
// Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches
|
||||
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
||||
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
||||
// The ROOT level is reconciled separately by the root-query refetch +
|
||||
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
|
||||
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
|
||||
// the initial connect, so every `connect` it sees is a reconnect; the rare
|
||||
// No first-connect guard is needed: space-tree usually mounts AFTER the
|
||||
// initial connect, so every `connect` it sees is a reconnect; the rare
|
||||
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
const onConnect = async () => {
|
||||
const effectSpaceId = spaceIdRef.current;
|
||||
const branchIds = loadedOpenBranchIds(
|
||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||
openIdsRef.current,
|
||||
);
|
||||
if (branchIds.length === 0) return;
|
||||
for (const id of branchIds) {
|
||||
try {
|
||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||
// reconcile sees the server's CURRENT children (handler-order
|
||||
// independent — no reliance on the global reconnect invalidation).
|
||||
const fresh = await fetchAllAncestorChildren(
|
||||
{ pageId: id, spaceId: effectSpaceId },
|
||||
{ fresh: true },
|
||||
);
|
||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||
} catch (err) {
|
||||
console.error("[tree] reconnect branch refresh failed", err);
|
||||
}
|
||||
}
|
||||
const onConnect = () => {
|
||||
refreshOpenBranches();
|
||||
};
|
||||
socket.on("connect", onConnect);
|
||||
return () => {
|
||||
socket.off("connect", onConnect);
|
||||
};
|
||||
}, [socket, setData]);
|
||||
}, [socket, refreshOpenBranches]);
|
||||
|
||||
// Post-load cache refresh: the sidebar paints instantly from the
|
||||
// localStorage-cached tree, so children of open branches may be stale. Once
|
||||
// the server root set has been merged for this space (isDataLoaded flips
|
||||
// true), refresh every open, already-loaded branch ONCE per space per mount.
|
||||
// dataRef.current is already up to date here: refs are assigned during
|
||||
// render, and this effect runs after the merge-triggered re-render commit.
|
||||
const refreshedSpacesRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!isDataLoaded) return;
|
||||
if (refreshedSpacesRef.current.has(spaceId)) return;
|
||||
refreshedSpacesRef.current.add(spaceId);
|
||||
refreshOpenBranches();
|
||||
}, [isDataLoaded, spaceId, refreshOpenBranches]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (id: string, isOpen: boolean) => {
|
||||
@@ -333,12 +370,17 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
|
||||
return (
|
||||
<div className={classes.treeContainer}>
|
||||
{/* "No pages yet" only after the SERVER confirmed the space is empty —
|
||||
never while just the localStorage cache is empty. */}
|
||||
{isDataLoaded && filteredData.length === 0 && (
|
||||
<Text size="xs" c="dimmed" py="xs" px="sm">
|
||||
{t("No pages yet")}
|
||||
</Text>
|
||||
)}
|
||||
{isDataLoaded && filteredData.length > 0 && (
|
||||
{/* Cache-first paint: render as soon as ANY data exists (synchronous
|
||||
localStorage hydration) instead of waiting for the server round-trip;
|
||||
the background merge/refresh reconciles it afterwards. */}
|
||||
{filteredData.length > 0 && (
|
||||
<DocTree<SpaceTreeNode>
|
||||
data={filteredData}
|
||||
openIds={openIds}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useStore } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
createdPage.title,
|
||||
);
|
||||
navigate(pageUrl);
|
||||
// On mobile the create action is triggered from inside the off-canvas
|
||||
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||
// alone leaves that drawer open on top of the freshly created page, so the
|
||||
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||
// No-op on desktop, where the mobile drawer atom is already false.
|
||||
setMobileSidebar(false);
|
||||
},
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
[
|
||||
spaceId,
|
||||
createPageMutation,
|
||||
setData,
|
||||
store,
|
||||
navigate,
|
||||
spaceSlug,
|
||||
setMobileSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
closeIds,
|
||||
mergeRootTrees,
|
||||
loadedOpenBranchIds,
|
||||
pruneCollapsedChildren,
|
||||
sortPositionKeys,
|
||||
pageToTreeNode,
|
||||
} from "./utils";
|
||||
@@ -438,3 +439,62 @@ describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
|
||||
expect(ids.sort()).toEqual(["a", "a1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneCollapsedChildren", () => {
|
||||
// Signature: pruneCollapsedChildren(tree: SpaceTreeNode[], openIds:
|
||||
// ReadonlySet<string>): SpaceTreeNode[]. Collapsed nodes (id NOT in openIds)
|
||||
// are reset to `children: []` (hasChildren untouched); open nodes keep their
|
||||
// children but are recursed into so a collapsed branch nested under an open
|
||||
// one is still pruned.
|
||||
//
|
||||
// Fixture:
|
||||
// open "p" (in openIds, hasChildren)
|
||||
// └─ collapsed "c" (NOT in openIds) with STALE child "g"
|
||||
// collapsed "t" (NOT in openIds) with child "t1"
|
||||
// Only "p" is open.
|
||||
function fixture() {
|
||||
const grandchild = treeNode("g"); // stale, cached under the collapsed child
|
||||
const collapsedChild = treeNode("c", [grandchild]);
|
||||
const openParent = treeNode("p", [collapsedChild]);
|
||||
const topCollapsed = treeNode("t", [treeNode("t1")]);
|
||||
return { openParent, collapsedChild, topCollapsed };
|
||||
}
|
||||
|
||||
it("keeps an OPEN parent's children and recurses to prune a nested collapsed branch; prunes a top-level collapsed node", () => {
|
||||
const { openParent, topCollapsed } = fixture();
|
||||
const tree = [openParent, topCollapsed];
|
||||
const result = pruneCollapsedChildren(tree, new Set(["p"]));
|
||||
|
||||
// (a) OPEN parent keeps its children (not cleared) and hasChildren stays true.
|
||||
const p = result[0];
|
||||
expect(p.id).toBe("p");
|
||||
expect(p.hasChildren).toBe(true);
|
||||
expect(p.children).toHaveLength(1);
|
||||
|
||||
// (b) The nested COLLAPSED child under the open parent is pruned to
|
||||
// `children: []` by the recursion, with hasChildren preserved. This is the
|
||||
// open-keep + recurse branch that F1's empty-open-set fixture never hits.
|
||||
const c = p.children[0];
|
||||
expect(c.id).toBe("c");
|
||||
expect(c.children).toEqual([]);
|
||||
expect(c.hasChildren).toBe(true);
|
||||
|
||||
// (c) The top-level collapsed node is pruned to `children: []`, hasChildren kept.
|
||||
const t = result[1];
|
||||
expect(t.id).toBe("t");
|
||||
expect(t.children).toEqual([]);
|
||||
expect(t.hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate the input tree (returns fresh nodes)", () => {
|
||||
const { openParent, collapsedChild, topCollapsed } = fixture();
|
||||
const tree = [openParent, topCollapsed];
|
||||
pruneCollapsedChildren(tree, new Set(["p"]));
|
||||
|
||||
// Originals are untouched: the collapsed child still carries its stale grandchild.
|
||||
expect(collapsedChild.children).toHaveLength(1);
|
||||
expect(collapsedChild.children[0].id).toBe("g");
|
||||
expect(openParent.children[0]).toBe(collapsedChild);
|
||||
expect(topCollapsed.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +293,41 @@ export function loadedOpenBranchIds(
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-cache hygiene (#159 #8): the persisted tree keeps the children of EVERY
|
||||
* branch ever expanded — collapsing a branch never prunes them. So on reload a
|
||||
* COLLAPSED branch hydrates with its old cached children, and `handleToggle`
|
||||
* skips the lazy-load on first expand (children already present) → it shows
|
||||
* STALE children (renamed / moved / deleted while the user was offline) with no
|
||||
* reconcile. `refreshOpenBranches` only refreshes OPEN branches, so collapsed
|
||||
* ones slip through.
|
||||
*
|
||||
* Fix: drop the cached children of every node NOT in the persisted open-set,
|
||||
* resetting it to the canonical UNLOADED shape (`children: []`, `hasChildren`
|
||||
* untouched — see pageToTreeNode). Its first expand then lazy-loads fresh, just
|
||||
* as it did before the tree was cached to localStorage. OPEN branches keep
|
||||
* their children (refreshOpenBranches reconciles those, so they must not be
|
||||
* dropped here) and are recursed into so a collapsed branch nested under an
|
||||
* open one is pruned too.
|
||||
*/
|
||||
export function pruneCollapsedChildren(
|
||||
tree: SpaceTreeNode[],
|
||||
openIds: ReadonlySet<string>,
|
||||
): SpaceTreeNode[] {
|
||||
return tree.map((node) => {
|
||||
const hasLoadedChildren = !!node.children && node.children.length > 0;
|
||||
if (!openIds.has(node.id)) {
|
||||
// Collapsed: drop the whole cached subtree so it reads as unloaded.
|
||||
return hasLoadedChildren ? { ...node, children: [] } : node;
|
||||
}
|
||||
// Open: keep it, but recurse into its children (a nested collapsed branch
|
||||
// must still be pruned).
|
||||
return hasLoadedChildren
|
||||
? { ...node, children: pruneCollapsedChildren(node.children, openIds) }
|
||||
: node;
|
||||
});
|
||||
}
|
||||
|
||||
// Collect every node id in the tree (roots, branches, leaves). Used by
|
||||
// collapseAll to clear the open-state map for all current-space nodes.
|
||||
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user