Compare commits
185 Commits
0c7d67fe2a
...
v0.93.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77eeada693 | ||
|
|
06bfca5fdb | ||
| e5bc82c7f1 | |||
|
|
5418e259a6 | ||
|
|
10bff229d6 | ||
|
|
9797751b0a | ||
|
|
ba37907f50 | ||
|
|
267bafdd73 | ||
|
|
b21433af4e | ||
|
|
85fd4afa85 | ||
|
|
d9fa804197 | ||
|
|
e8775c45b0 | ||
|
|
ec4622a1b8 | ||
|
|
33c52045a2 | ||
|
|
85db20f9f2 | ||
| 084eafd0bb | |||
|
|
455a554054 | ||
|
|
7e26239c3f | ||
|
|
bc0c49db05 | ||
|
|
b5ce51581f | ||
|
|
0fbaebd108 | ||
|
|
18105ff6db | ||
|
|
3936c482d9 | ||
|
|
a20f4c3876 | ||
|
|
31fcb764d7 | ||
|
|
3f46496192 | ||
|
|
cecb560fce | ||
|
|
c596e17a40 | ||
|
|
3953ecdb17 | ||
|
|
3147b6ddf4 | ||
|
|
7c57a386b2 | ||
|
|
a2ded7ecfb | ||
|
|
bed3d3d286 | ||
|
|
c486750b2a | ||
|
|
8016b1c540 | ||
|
|
d45ca00bcc | ||
|
|
a11c87c4dc | ||
|
|
6928817cee | ||
|
|
c78177c28b | ||
|
|
b597841cf0 | ||
|
|
317fdb9424 | ||
|
|
40f68e95fb | ||
|
|
342bb47b30 | ||
|
|
e9ceb0f899 | ||
|
|
c0d312d8f5 | ||
|
|
5215913533 | ||
|
|
e52f069fc6 | ||
|
|
ff342ca705 | ||
|
|
afbc6b2202 | ||
|
|
099d31f594 | ||
|
|
212bcea4d7 | ||
|
|
05a7a4001f | ||
|
|
5344a9bdde | ||
|
|
d79f709742 | ||
|
|
2b4ec0bfcc | ||
|
|
e19849d980 | ||
|
|
20b9f61c3e | ||
|
|
81823fce1e | ||
|
|
b98c9d51c6 | ||
|
|
75c7c29cc8 | ||
|
|
64818cf9df | ||
|
|
262a0707d9 | ||
|
|
70c26f356a | ||
|
|
881610f5df | ||
|
|
4bf6d9f36b | ||
|
|
0944e0f455 | ||
|
|
d7681b4fb6 | ||
|
|
d105397dcf | ||
|
|
8b8b05e005 | ||
|
|
4f5a08cba0 | ||
|
|
3695dbdf7f | ||
|
|
ab51239cab | ||
|
|
4fa8882c58 | ||
|
|
eae68ba11f | ||
|
|
730486ad12 | ||
|
|
5f3a3d3ec0 | ||
|
|
f63719a21c | ||
|
|
877806e0ce | ||
|
|
0caceb614b | ||
|
|
987a4fd32e | ||
|
|
d96f94a80a | ||
|
|
8414114dc8 | ||
|
|
41efacbe3d | ||
|
|
4348608ee4 | ||
|
|
bd377ca4a8 | ||
|
|
e0aac5aa04 | ||
|
|
f6e216cb87 | ||
|
|
90d3fab483 | ||
|
|
1f457b060c | ||
|
|
424761753e | ||
|
|
b7ea8c850e | ||
|
|
8191c37daa | ||
|
|
39f3eacf89 | ||
|
|
bc1ea792f5 | ||
|
|
98769155d3 | ||
|
|
4f46f91db4 | ||
|
|
692c0abe13 | ||
|
|
c5f44a6eee | ||
|
|
a6ba19f0dc | ||
|
|
ada1dce739 | ||
|
|
8ee4279d30 | ||
|
|
6a052b88b4 | ||
|
|
79d096ed7a | ||
|
|
a15cccf557 | ||
|
|
22887c474a | ||
|
|
4536d27ad2 | ||
|
|
a85dd607bd | ||
|
|
b8655ae52c | ||
|
|
c9eb495688 | ||
|
|
859223db1a | ||
|
|
b53b0c651e | ||
|
|
be17391e18 | ||
|
|
19ae6a0efa | ||
|
|
7a03321d43 | ||
|
|
2b3fc926cc | ||
|
|
e9e9f74ec6 | ||
|
|
52efd37fd9 | ||
|
|
d80a419963 | ||
|
|
6128920264 | ||
|
|
cf29a0fc11 | ||
|
|
4fe42ead56 | ||
|
|
41f3944e79 | ||
|
|
46688074d8 | ||
|
|
f650d2591b | ||
|
|
f72e44c9b7 | ||
|
|
8fcce6a674 | ||
|
|
c718b2a6de | ||
|
|
0c46f60ddf | ||
|
|
90e9b0a3f4 | ||
|
|
4c1d1aa2ee | ||
|
|
4b31128e24 | ||
|
|
127d26c057 | ||
|
|
45cf4140eb | ||
|
|
ec128d54b4 | ||
|
|
cedea4072b | ||
|
|
1e650262a4 | ||
|
|
f1980cf425 | ||
|
|
965cbb32e5 | ||
|
|
0b969c8675 | ||
|
|
b20ffd1b91 | ||
|
|
949a251553 | ||
|
|
234ae759f5 | ||
|
|
151bd7a0e0 | ||
|
|
689f435630 | ||
|
|
1982ef0f23 | ||
|
|
4bfb143288 | ||
|
|
f8bb4b37ce | ||
|
|
d11cf0112f | ||
|
|
36ae4bd3d3 | ||
|
|
be2530a0b9 | ||
|
|
587a940959 | ||
|
|
71fc58dbed | ||
|
|
9aff427ad8 | ||
|
|
caac5c7f36 | ||
|
|
3672093f56 | ||
|
|
20a1780977 | ||
|
|
cac7abc395 | ||
|
|
4430784094 | ||
|
|
680995247a | ||
|
|
5d5f61fc6e | ||
|
|
52c5be4fa4 | ||
|
|
394d3e58fc | ||
|
|
ceee2a76ca | ||
|
|
bfd79b94bc | ||
|
|
932a4080f7 | ||
|
|
e0b3b3d9a5 | ||
|
|
1c83a8ae15 | ||
|
|
4d17befb0d | ||
|
|
42671c0901 | ||
|
|
39ae89264d | ||
|
|
393bca4dab | ||
|
|
bd28dbfe2b | ||
|
|
31d6498b24 | ||
|
|
046132afc7 | ||
|
|
b7b1fb773e | ||
|
|
acf3df9e9d | ||
|
|
1483e021d1 | ||
|
|
4a00dfc3b2 | ||
|
|
87ce969a6f | ||
|
|
30c3189220 | ||
|
|
fb01c07b71 | ||
|
|
b197cbedef | ||
|
|
b38b71eb51 | ||
|
|
b81819ef63 | ||
|
|
059f2bd7e5 |
78
.env.example
78
.env.example
@@ -2,6 +2,38 @@
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# --- Security / reverse proxy ---
|
||||
# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
|
||||
# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
|
||||
# from proxies on the configured trusted networks. Deploy this app behind a
|
||||
# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For`
|
||||
# with the real client IP. If XFF is trusted from an untrusted source, any
|
||||
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
|
||||
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
|
||||
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
|
||||
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
|
||||
#
|
||||
# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For.
|
||||
# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted
|
||||
# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip.
|
||||
# This is the safe default for the common case where the reverse proxy runs on
|
||||
# loopback or a private network; req.ip still resolves to the real client.
|
||||
# WARNING: this changed the previous default of trust-all. If your reverse proxy
|
||||
# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the
|
||||
# proxy's IP — set TRUST_PROXY accordingly. Accepted values:
|
||||
# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites
|
||||
# X-Forwarded-For; otherwise clients can spoof their IP)
|
||||
# - false never trust X-Forwarded-For (req.ip is the socket peer)
|
||||
# - <int> number of trusted proxy hops in front of the app
|
||||
# - <list> comma-separated CIDR/IP list of trusted proxies, e.g.
|
||||
# `127.0.0.1, 10.0.0.0/8`
|
||||
# TRUST_PROXY=
|
||||
|
||||
# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that
|
||||
# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you
|
||||
# change APP_SECRET after setup, every stored AI API key becomes undecryptable —
|
||||
# you must re-enter them in AI settings — and all existing sessions/JWTs are
|
||||
# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB.
|
||||
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||
|
||||
@@ -69,15 +101,55 @@ DEBUG_DB=false
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
|
||||
# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance
|
||||
# MCP server (community): the embedded /mcp endpoint authenticates PER USER.
|
||||
# An MCP client authenticates with one of:
|
||||
# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own
|
||||
# Docmost login/password. The server validates the credentials and the MCP
|
||||
# session then acts under that user's permissions (edits attributed to them).
|
||||
# - Bearer access JWT: `Authorization: Bearer <access-jwt>` (the user's
|
||||
# `authToken` cookie value). Validated as an ACCESS token.
|
||||
#
|
||||
# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR
|
||||
# Bearer credentials and these are set, the MCP session falls back to this
|
||||
# shared service account (back-compat; useful for CI/scripts). Leave BLANK to
|
||||
# require per-user credentials.
|
||||
MCP_DOCMOST_EMAIL=
|
||||
MCP_DOCMOST_PASSWORD=
|
||||
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
||||
# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on
|
||||
# the workspace MCP toggle and network isolation (do not expose the port publicly).
|
||||
# Optional shared guard for the /mcp endpoint. When set, every /mcp request must
|
||||
# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now
|
||||
# carries the per-user credentials). When unset, /mcp relies on the per-user
|
||||
# credentials above plus the workspace MCP toggle and network isolation (do not
|
||||
# expose the port publicly).
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped
|
||||
# to the single share tree, but every call spends real tokens on the workspace
|
||||
# owner's configured AI provider.
|
||||
#
|
||||
# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only
|
||||
# effective behind a trusted reverse proxy that OVERWRITES (not appends)
|
||||
# X-Forwarded-For with the real client IP. The app runs with trustProxy, so
|
||||
# without such a proxy an attacker can rotate X-Forwarded-For to evade the
|
||||
# per-IP limit. Put this endpoint (and the app) behind a proxy you control that
|
||||
# sets X-Forwarded-For to the real client IP.
|
||||
#
|
||||
# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent,
|
||||
# keyed by the server-resolved workspace id) bounds the owner's bill even if the
|
||||
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
||||
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
||||
# offline is safer than an unbounded bill). Override the hourly cap below
|
||||
# (default: 300 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300
|
||||
#
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
||||
|
||||
7
.github/workflows/develop.yml
vendored
7
.github/workflows/develop.yml
vendored
@@ -3,7 +3,7 @@ name: Develop
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -18,7 +18,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -19,7 +19,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
||||
40
.github/workflows/test.yml
vendored
Normal file
40
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Required for the client suite, which resolves @docmost/editor-ext via its
|
||||
# dist build (the server suite also rebuilds it through its own pretest).
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r test
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -216,7 +216,7 @@ pnpm --filter server migration:latest # apply all pending
|
||||
pnpm --filter server migration:down # revert last
|
||||
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
|
||||
```
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data.
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||
|
||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||
|
||||
@@ -240,7 +240,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
|
||||
|
||||
### The two AI subsystems (the main fork additions)
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`.
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
|
||||
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
@@ -263,7 +263,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
## CI / release
|
||||
|
||||
- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub).
|
||||
- The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context.
|
||||
|
||||
@@ -280,4 +280,4 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
|
||||
|
||||
## Planning docs
|
||||
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -10,6 +10,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
|
||||
an anonymous AI assistant on public shares, server-side voice dictation, an
|
||||
editor footnotes model, live page-template embeds, and sandboxed arbitrary-HTML
|
||||
embeds — plus a large batch of security hardening and test coverage.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
|
||||
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
|
||||
`X-MCP-Token` header. The `Authorization` header is now reserved for per-user
|
||||
HTTP Basic / Bearer access-JWT credentials, so each `/mcp` request
|
||||
authenticates as a specific user (the `MCP_DOCMOST_*` service account is only
|
||||
a fallback). Existing MCP clients (e.g. Claude Desktop) configured with
|
||||
`Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <MCP_TOKEN>` instead. See `MCP_TOKEN` in `.env.example`. As a
|
||||
one-time aid, the server logs a single migration warning when it sees the
|
||||
old-style header.
|
||||
|
||||
### Added
|
||||
|
||||
- **AI agent roles**: admin-defined assistant personas with an optional
|
||||
per-role model override, selectable in chat.
|
||||
- **Anonymous AI assistant on public shares**: public-share visitors can chat
|
||||
with a selectable agent-role identity that reuses the internal chat
|
||||
presentation, with per-request output-token caps and a fail-closed Redis
|
||||
limiter.
|
||||
- **Voice dictation (STT)**: server-side speech-to-text with a mic button in
|
||||
the chat and the editor, OpenRouter STT support, an endpoint test, and real
|
||||
provider-error surfacing.
|
||||
- **Footnotes**: an editor footnotes model (inline references + a definitions
|
||||
list).
|
||||
- **Page templates**: live whole-page embed (MVP) with a template-marker icon
|
||||
in the page tree and a working Refresh action.
|
||||
- **Arbitrary HTML/CSS/JS embeds**: a sandboxed-iframe embed block gated by a
|
||||
per-workspace toggle (default OFF); insertable by any member when the toggle
|
||||
is on.
|
||||
- Admin-only **"Analytics / tracker"** workspace setting: a raw HTML/JS snippet
|
||||
injected into the `<head>` of public share pages only (for analytics such as
|
||||
Google Analytics or Yandex.Metrika), kept separate from the member-facing
|
||||
HTML-embed feature.
|
||||
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
|
||||
embedded `/mcp` endpoint.
|
||||
- **Page tree**: Expand all / Collapse all for the space tree, and
|
||||
server-authoritative realtime tree updates.
|
||||
- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a
|
||||
current-context-size readout, an agent step cap raised 8→20 with a forced
|
||||
final text answer, and auto-collapse of the chat window on page focus.
|
||||
- **AI settings**: a Clear control inside the API-key field and an endpoint
|
||||
status dot bound to "configured × enabled".
|
||||
- **Client**: an always-visible space grid replacing the space-switcher popover,
|
||||
removal of the sidebar Overview item, tighter comments-panel density, and no
|
||||
auto-open of the comments panel when adding a comment.
|
||||
|
||||
### Changed
|
||||
|
||||
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
|
||||
when the workspace HTML-embed toggle is on, can be inserted by any member
|
||||
(previously admin-only). Turning the toggle off hides existing embeds and
|
||||
stops serving them on public share pages.
|
||||
- Remove the server-side role-based stripping of HTML-embed blocks from the
|
||||
write paths (collab/REST/MCP, page create/duplicate, import, transclusion
|
||||
unsync); sandboxing makes per-write gating unnecessary. The only remaining
|
||||
server-side strip is the public-share read path, which still honors the
|
||||
workspace HTML-embed toggle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AI chat: preserve scroll position during streaming, record chats that fail on
|
||||
their first turn, and resolve the current page for agent context behind
|
||||
proxies.
|
||||
- AI roles: guard `update()` against concurrent soft-delete; harden the model
|
||||
override, role-name uniqueness, and id validation; sandwich the safety
|
||||
framework around the role persona.
|
||||
- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw.
|
||||
- Footnotes: survive duplicate-id definitions without collab divergence.
|
||||
- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at
|
||||
serve time on authenticated read paths and the plain page-create path.
|
||||
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
||||
in-flight page-embed id, and add defense-in-depth workspace checks.
|
||||
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
||||
- Import: surface the real error cause from `/pages/import` instead of a generic
|
||||
400.
|
||||
|
||||
### Security
|
||||
|
||||
- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions;
|
||||
close a brute-force limiter check-then-act race.
|
||||
- Public share: block restricted descendants in the anonymous assistant, cap
|
||||
per-request output, fail closed when Redis is unavailable, and reject non-text
|
||||
message parts to close a size-cap bypass.
|
||||
- Make `trustProxy` env-configurable with a safe default.
|
||||
|
||||
### Internal
|
||||
|
||||
- CI: gate the `develop` and release image builds on the test suite, run the
|
||||
suites on push/PR, and build the `:develop` image on push to `develop`.
|
||||
- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and
|
||||
the release procedure, add migration-ordering guidance, and prune implemented
|
||||
plans.
|
||||
- A large batch of new server/client test coverage.
|
||||
|
||||
## [0.91.0] - 2026-06-18
|
||||
|
||||
Gitmost is a community-focused fork of Docmost. This release drops the
|
||||
@@ -92,5 +195,6 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||
Docker image to the GHCR registry.
|
||||
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||
|
||||
11
README.md
11
README.md
@@ -101,6 +101,9 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs.
|
||||
- ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP).
|
||||
- ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it.
|
||||
- ✅ **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.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -108,14 +111,11 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
|
||||
### Planned
|
||||
|
||||
- 🔭 **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). See [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Viewer comments** — let read-only viewers leave comments.
|
||||
- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
|
||||
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
||||
- 🔭 **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. See [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.
|
||||
|
||||
## Getting started
|
||||
@@ -158,6 +158,11 @@ the existing data directory is reused as-is:
|
||||
start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the
|
||||
`page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the
|
||||
> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every
|
||||
> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all
|
||||
> existing sessions. Pick it once, keep it stable, and back it up together with your database.
|
||||
|
||||
### Notes
|
||||
|
||||
- **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the
|
||||
|
||||
12
README.ru.md
12
README.ru.md
@@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов.
|
||||
- ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP).
|
||||
- ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия.
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
|
||||
### В планах
|
||||
|
||||
- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||
- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||
- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||
|
||||
## С чего начать
|
||||
@@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис
|
||||
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
|
||||
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT
|
||||
> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров
|
||||
> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить
|
||||
> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите
|
||||
> неизменным и бэкапьте вместе с базой данных.
|
||||
|
||||
|
||||
## Возможности
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.91.0",
|
||||
"version": "0.93.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Successfully imported": "Successfully imported",
|
||||
"Successfully restored": "Successfully restored",
|
||||
"System settings": "System settings",
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
@@ -473,6 +474,7 @@
|
||||
"Make sub-pages public too": "Make sub-pages public too",
|
||||
"Allow search engines to index page": "Allow search engines to index page",
|
||||
"Open page": "Open page",
|
||||
"Open source page": "Open source page",
|
||||
"Page": "Page",
|
||||
"Delete public share link": "Delete public share link",
|
||||
"Delete share": "Delete share",
|
||||
@@ -529,6 +531,7 @@
|
||||
"Add 2FA method": "Add 2FA method",
|
||||
"Backup codes": "Backup codes",
|
||||
"Disable": "Disable",
|
||||
"disabled": "disabled",
|
||||
"Invalid verification code": "Invalid verification code",
|
||||
"New backup codes have been generated": "New backup codes have been generated",
|
||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||
@@ -977,6 +980,9 @@
|
||||
"Page menu": "Page menu",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand all": "Expand all",
|
||||
"Collapse all": "Collapse all",
|
||||
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
|
||||
"Comment menu": "Comment menu",
|
||||
"Group menu": "Group menu",
|
||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||
@@ -1122,10 +1128,24 @@
|
||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||
"AI chat": "AI chat",
|
||||
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
||||
"Ask a question…": "Ask a question…",
|
||||
"Thinking…": "Thinking…",
|
||||
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
||||
"Public share assistant": "Public share assistant",
|
||||
"Enabled": "Enabled",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
"Public assistant model": "Public assistant model",
|
||||
"Defaults to the chat model": "Defaults to the chat model",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.",
|
||||
"Assistant identity": "Assistant identity",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"AI agent": "AI agent",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"Send": "Send",
|
||||
"Stop": "Stop",
|
||||
"Chat menu": "Chat menu",
|
||||
@@ -1162,6 +1182,10 @@
|
||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||
"Test endpoint": "Test endpoint",
|
||||
"Save endpoints": "Save endpoints",
|
||||
"Configured and enabled": "Configured and enabled",
|
||||
"Configured but disabled": "Configured but disabled",
|
||||
"Enabled but not configured": "Enabled but not configured",
|
||||
"Not configured": "Not configured",
|
||||
"External tools": "External tools",
|
||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||
@@ -1195,5 +1219,41 @@
|
||||
"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)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)"
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)",
|
||||
"Agent role": "Agent role",
|
||||
"Universal assistant": "Universal assistant",
|
||||
"Add role": "Add role",
|
||||
"Edit role": "Edit role",
|
||||
"Role name": "Role name",
|
||||
"e.g. Proofreader": "e.g. Proofreader",
|
||||
"Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.",
|
||||
"Optional. A short note about what this role does.": "Optional. A short note about what this role does.",
|
||||
"Instructions": "Instructions",
|
||||
"The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.",
|
||||
"Model provider override": "Model provider override",
|
||||
"Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.",
|
||||
"Model override": "Model override",
|
||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||
"Agent roles": "Agent roles",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
"No roles configured": "No roles configured",
|
||||
"Delete role": "Delete role",
|
||||
"Are you sure you want to delete this role?": "Are you sure you want to delete this role?",
|
||||
"HTML embed": "HTML embed",
|
||||
"Edit HTML embed": "Edit HTML embed",
|
||||
"HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace",
|
||||
"Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Height (px, blank = auto)",
|
||||
"advanced": "advanced",
|
||||
"Enable HTML embed": "Enable HTML embed",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
"Analytics / tracker": "Analytics / tracker",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Successfully imported": "Успешно импортировано",
|
||||
"Successfully restored": "Успешно восстановлено",
|
||||
"System settings": "Системные настройки",
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
@@ -391,6 +392,13 @@
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Footnote": "Сноска",
|
||||
"Insert a footnote reference.": "Вставить ссылку на сноску.",
|
||||
"Footnotes": "Примечания",
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -471,6 +479,7 @@
|
||||
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
|
||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
||||
"Open page": "Открыть страницу",
|
||||
"Open source page": "Открыть исходную страницу",
|
||||
"Page": "Страница",
|
||||
"Delete public share link": "Удалить публичную ссылку",
|
||||
"Delete share": "Удалить общий доступ",
|
||||
@@ -659,6 +668,9 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
|
||||
@@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
||||
|
||||
/**
|
||||
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
|
||||
* (no role). Consulted ONLY when creating a chat (its first message): the server
|
||||
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
|
||||
* null when starting a new chat. It does NOT affect already-created chats.
|
||||
*/
|
||||
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
|
||||
export const selectedAiRoleIdAtom = atom(null as string | null);
|
||||
|
||||
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
|
||||
// ChatThread — so it survives the thread remount that happens when a brand-new
|
||||
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* In the collapsed state the header expands the window on click, so hint that
|
||||
it is clickable (override the drag `grab` cursor). */
|
||||
.minimized .dragBar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dragBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader, Select, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -32,10 +33,15 @@ import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-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";
|
||||
@@ -102,10 +108,16 @@ export default function AiChatWindow() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
// The role chosen for the next new chat (null = universal assistant).
|
||||
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
// Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag),
|
||||
// which would otherwise close over a stale value. Kept in sync below.
|
||||
const minimizedRef = useRef(minimized);
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); initialized lazily on first open so
|
||||
@@ -123,16 +135,29 @@ export default function AiChatWindow() {
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
// The new-chat picker only offers ENABLED roles. The list endpoint returns
|
||||
// all live roles (so the admin settings section can manage disabled ones), so
|
||||
// we filter to `enabled` here, client-side, for the composer picker only.
|
||||
const enabledRoles = useMemo(
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// The page the user is currently viewing, derived from the route (same
|
||||
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
|
||||
// so the query is disabled and `openPage` is null. This is passed to the
|
||||
// chat thread as context so the agent knows what "this page"/"the current
|
||||
// page" refers to; the agent still reads/writes via its CASL-enforced page
|
||||
// tools using the id.
|
||||
const { pageSlug } = useParams();
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
// resolves regardless of where this component is mounted. On a non-page route
|
||||
// the match is null, so `pageSlug` is undefined, the query is disabled and
|
||||
// `openPage` is null. This is passed to the chat thread as context so the
|
||||
// agent knows what "this page"/"the current page" refers to; the agent still
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
const { data: openPageData } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
@@ -144,7 +169,9 @@ export default function AiChatWindow() {
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
}, [setActiveChatId, setDraft]);
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -238,8 +265,31 @@ export default function AiChatWindow() {
|
||||
useLayoutEffect(() => {
|
||||
if (!windowOpen) return;
|
||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
||||
// Always show the window expanded on (re)open: a collapsed state from a
|
||||
// previous open session must not stick. Runs before paint so the first
|
||||
// frame is already expanded. The composer's autofocus is a focus INSIDE the
|
||||
// window (not an outside mousedown), so it cannot self-collapse the window.
|
||||
setMinimized(false);
|
||||
}, [windowOpen]);
|
||||
|
||||
// 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
|
||||
// open→reset transition. Capture phase so a page handler's stopPropagation in
|
||||
// the bubble phase can't hide the event from us; the in-window/portal guards
|
||||
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
|
||||
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
|
||||
setMinimized(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
|
||||
// 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.
|
||||
@@ -287,10 +337,21 @@ export default function AiChatWindow() {
|
||||
el.style.top = `${nt}px`;
|
||||
};
|
||||
|
||||
const up = (): void => {
|
||||
const up = (ev: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
// 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
|
||||
// `minimized` captured by useCallback([]).
|
||||
if (
|
||||
minimizedRef.current &&
|
||||
isHeaderClick(sx, sy, ev.clientX, ev.clientY)
|
||||
) {
|
||||
setMinimized(false);
|
||||
return;
|
||||
}
|
||||
const el2 = winRef.current;
|
||||
// Persist the final position back into state (preserving the size) so
|
||||
// re-renders keep it.
|
||||
@@ -334,14 +395,49 @@ export default function AiChatWindow() {
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
>
|
||||
{/* drag bar / header */}
|
||||
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||
excludes the buttons). The keyboard/screen-reader Expand affordance
|
||||
lives on the title element below — NOT on this container — so we never
|
||||
nest the Minimize/Close <button>s inside an element with
|
||||
role="button" (invalid ARIA: nested interactive controls). */}
|
||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
style={{ flex: "none" }}
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
{/* When minimized, the title doubles as the keyboard Expand button:
|
||||
it carries role/tabIndex/aria-label and an Enter/Space handler, and
|
||||
unlike the dragBar it contains no nested <button>s. When expanded it
|
||||
is a plain, non-focusable label. */}
|
||||
<span
|
||||
className={classes.title}
|
||||
role={minimized ? "button" : undefined}
|
||||
tabIndex={minimized ? 0 : undefined}
|
||||
aria-label={minimized ? t("Expand") : undefined}
|
||||
onKeyDown={
|
||||
minimized
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setMinimized(false);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
{activeChat?.roleName && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
|
||||
{activeChat.roleName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{contextTokens > 0 && (
|
||||
@@ -400,7 +496,16 @@ export default function AiChatWindow() {
|
||||
>
|
||||
<div
|
||||
className={classes.historyHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setHistoryOpen((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
@@ -432,6 +537,29 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role picker — only for a NEW chat (before it is created). Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead.
|
||||
Defaults to "Universal assistant" (no role). */}
|
||||
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
|
||||
<div style={{ padding: "4px 8px 0" }}>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Agent role")}
|
||||
value={selectedRoleId ?? ""}
|
||||
onChange={(value) => setSelectedRoleId(value || null)}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={[
|
||||
{ value: "", label: t("Universal assistant") },
|
||||
...enabledRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
{waitingForHistory ? (
|
||||
@@ -444,6 +572,8 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,10 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
@@ -61,6 +65,7 @@ export default function ChatThread({
|
||||
chatId,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -84,6 +89,12 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
const roleIdRef = useRef<string | null>(roleId ?? null);
|
||||
roleIdRef.current = roleId ?? null;
|
||||
|
||||
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||
//
|
||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||
@@ -119,6 +130,9 @@ export default function ChatThread({
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
@@ -134,6 +148,13 @@ export default function ChatThread({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onFinish: () => onTurnFinished(),
|
||||
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
|
||||
// new chat that fails on its first turn would never invalidate the chat list
|
||||
// nor adopt the server-created chat id (the server still creates the row and
|
||||
// saves the error message). Run the same post-turn path on error so the
|
||||
// failed chat appears in history immediately instead of after a manual
|
||||
// refresh. The error itself is still surfaced via `error` below.
|
||||
onError: () => onTurnFinished(),
|
||||
});
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
@@ -115,11 +115,28 @@ export default function ConversationList({
|
||||
classes.conversationItem,
|
||||
isActive && classes.conversationItemActive,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate on Enter/Space like a native button; the inner menu
|
||||
// button stops propagation so its own keys never reach this row.
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(chat.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -3,18 +3,31 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
* links stay clickable). The anonymous public share passes true so internal
|
||||
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the dimmed assistant label. Defaults to "AI agent" when
|
||||
* absent; the public share passes the configured identity (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +42,12 @@ function isToolPart(type: string): boolean {
|
||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
||||
* text parts on each delta is what makes the answer stream in progressively.
|
||||
*/
|
||||
export default function MessageItem({ message }: MessageItemProps) {
|
||||
export default function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -50,7 +68,7 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
@@ -58,7 +76,9 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
const html = renderChatMarkdown(part.text);
|
||||
const html = renderChatMarkdown(part.text, {
|
||||
neutralizeInternalLinks,
|
||||
});
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
@@ -78,7 +98,13 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
}
|
||||
|
||||
if (isToolPart(part.type)) {
|
||||
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
/**
|
||||
* Content shown when the transcript is empty and no turn is in flight.
|
||||
* Defaults to the internal chat's prompt. The public share passes its own
|
||||
* documentation-focused copy. This is purely the empty-state text; the
|
||||
* streaming/typing/markdown/tool-card paths below are shared verbatim.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
|
||||
* citation links. Defaults to true (internal chat). The public share passes
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
* Defaults to false (internal chat). The public share passes true so internal
|
||||
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the assistant's dimmed row label and typing indicator.
|
||||
* Defaults to "AI agent" when absent. The public share passes the configured
|
||||
* identity (agent role) name; the internal chat omits it.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
@@ -28,7 +50,7 @@ const BOTTOM_THRESHOLD = 40;
|
||||
* - the last (assistant) message has no non-empty text and no tool part.
|
||||
* Once any text/tool part arrives, MessageItem renders it and this hides.
|
||||
*/
|
||||
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last) return true; // submitted with nothing rendered yet.
|
||||
@@ -45,7 +67,14 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
* earlier messages, streamed deltas no longer yank them back down.
|
||||
*/
|
||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
export default function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
||||
@@ -108,9 +137,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
if (messages.length === 0 && !typing) {
|
||||
return (
|
||||
<Center className={classes.messages}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
{emptyState ?? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -119,9 +150,15 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
))}
|
||||
{typing && <TypingIndicator />}
|
||||
{typing && <TypingIndicator assistantName={assistantName} />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the typing-indicator bridging logic that the internal
|
||||
* chat and the public share widget now share. This is the behavior that decides
|
||||
* whether the animated "AI agent is typing…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: UIMessage["parts"],
|
||||
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||
|
||||
describe("showTypingIndicator", () => {
|
||||
it("is hidden when not streaming", () => {
|
||||
expect(showTypingIndicator([], false)).toBe(false);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming with no messages yet (just submitted)", () => {
|
||||
expect(showTypingIndicator([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the last message is still the user's", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the assistant row has no visible content", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once a tool part appears (even before any text)", () => {
|
||||
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [toolPart])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolUiPart;
|
||||
/**
|
||||
* Whether to render page citation links. Defaults to true (the internal chat,
|
||||
* where the reader is authenticated and the `/p/{id}` links resolve). The
|
||||
* public share passes false: an anonymous reader cannot open internal pages,
|
||||
* so the links would 404/redirect to login. Suppressing them keeps the card
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
|
||||
* agent DID (the agent writes without confirmation — D2), its run state
|
||||
* (running / done / error), and citation link(s) to any referenced page(s).
|
||||
*/
|
||||
export default function ToolCallCard({ part }: ToolCallCardProps) {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = toolCitations(part);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
/**
|
||||
* Display name for the dimmed label and the "… is typing…" line. Defaults to
|
||||
* "AI agent" when absent; the public share passes the configured identity
|
||||
* (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
|
||||
* latest assistant message has no visible content yet (no rendered text/tool
|
||||
* parts). It covers the gap between sending and the first streamed token, and is
|
||||
* replaced by the real assistant message once content starts arriving.
|
||||
* Live "… is typing…" placeholder shown while a turn is in flight but the latest
|
||||
* assistant message has no visible content yet (no rendered text/tool parts). It
|
||||
* covers the gap between sending and the first streamed token, and is replaced by
|
||||
* the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
||||
* so it reads as the assistant's bubble taking shape.
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* as the assistant's bubble taking shape. The label and typing line use the
|
||||
* configured identity name when provided, otherwise the generic "AI agent".
|
||||
*/
|
||||
export default function TypingIndicator() {
|
||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = resolveAssistantName(assistantName);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{name ?? t("AI agent")}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<span className={classes.typingDots} aria-hidden="true">
|
||||
@@ -26,7 +38,7 @@ export default function TypingIndicator() {
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("AI agent is typing…")}
|
||||
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -8,18 +8,26 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
createAiRole,
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoles,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -114,3 +122,79 @@ export function useDeleteAiChatMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the workspace's agent roles. Available to any workspace member (used by
|
||||
* the chat-creation role picker and the admin management section). `enabled`
|
||||
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||
*/
|
||||
export function useAiRolesQuery(enabled: boolean = true) {
|
||||
return useQuery<IAiRole[], Error>({
|
||||
queryKey: AI_ROLES_RQ_KEY,
|
||||
queryFn: () => getAiRoles(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||
mutationFn: (data) => createAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Created successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||
mutationFn: (data) => updateAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: true }, Error, string>({
|
||||
mutationFn: (id) => deleteAiRole(id),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Deleted successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -46,3 +49,33 @@ export async function renameAiChat(data: {
|
||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** List the workspace's agent roles. */
|
||||
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Create a role (admin). */
|
||||
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update a role (admin). */
|
||||
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Soft-delete a role (admin). */
|
||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,63 @@ export interface IAiChat {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
// The agent role bound to this chat, if any (immutable after creation).
|
||||
roleId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response (the bound role's badge).
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: string | null;
|
||||
roleEmoji?: string | null;
|
||||
}
|
||||
|
||||
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
|
||||
export type AiRoleDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
/** Optional per-role model override (mirrors `model_config`). */
|
||||
export interface IAiRoleModelConfig {
|
||||
driver?: AiRoleDriver;
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent role (mirrors the server role views). A role replaces the agent's
|
||||
* persona (instructions) and may optionally override the model. The safety
|
||||
* framework is always still applied server-side.
|
||||
*
|
||||
* The list endpoint returns the FULL view to admins and a reduced picker view to
|
||||
* ordinary members, so the admin-only fields (`instructions`, `modelConfig`,
|
||||
* `createdAt`, `updatedAt`) are optional here — present only for admins.
|
||||
*/
|
||||
export interface IAiRole {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
export interface IAiRoleUpdate {
|
||||
id: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveAssistantName } from "./assistant-name";
|
||||
|
||||
describe("resolveAssistantName", () => {
|
||||
it("returns a real name unchanged", () => {
|
||||
expect(resolveAssistantName("Ada")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from a real name", () => {
|
||||
expect(resolveAssistantName(" Ada ")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("returns null for a whitespace-only name (the reason for .trim())", () => {
|
||||
expect(resolveAssistantName(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the name is undefined", () => {
|
||||
expect(resolveAssistantName(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(resolveAssistantName("")).toBeNull();
|
||||
});
|
||||
});
|
||||
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Pure helper for resolving the assistant's display name. Kept free of React so
|
||||
// it can be unit-tested in isolation (see assistant-name.test.ts) and shared by
|
||||
// the components that render the assistant identity (TypingIndicator, MessageItem).
|
||||
|
||||
/**
|
||||
* Resolve the assistant's display name from the optional configured identity.
|
||||
*
|
||||
* Returns the trimmed name when it has visible (non-whitespace) characters, or
|
||||
* `null` when the name is absent or whitespace-only. Callers fall back to a
|
||||
* generic "AI agent" label on `null`. The `.trim()` is why a name of " " must
|
||||
* resolve to `null` rather than rendering an empty label.
|
||||
*/
|
||||
export function resolveAssistantName(assistantName?: string): string | null {
|
||||
const name = assistantName?.trim();
|
||||
return name ? name : null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "./collapse-helpers";
|
||||
|
||||
describe("shouldCollapseOnOutsidePointer", () => {
|
||||
let windowEl: HTMLDivElement;
|
||||
let inside: HTMLSpanElement;
|
||||
let portal: HTMLDivElement;
|
||||
let portalChild: HTMLButtonElement;
|
||||
let page: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The floating window with a child node.
|
||||
windowEl = document.createElement("div");
|
||||
inside = document.createElement("span");
|
||||
windowEl.appendChild(inside);
|
||||
|
||||
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
|
||||
portal = document.createElement("div");
|
||||
portal.setAttribute("data-portal", "true");
|
||||
portalChild = document.createElement("button");
|
||||
portal.appendChild(portalChild);
|
||||
|
||||
// An unrelated page element.
|
||||
page = document.createElement("div");
|
||||
|
||||
document.body.append(windowEl, portal, page);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns false for a target inside the window", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a target inside a Mantine portal", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a target on the page (outside window and portals)", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when there is no window element", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-Element target", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeaderClick", () => {
|
||||
it("treats a zero-movement press as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement within the threshold as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
|
||||
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement beyond the threshold (either axis) as a drag", () => {
|
||||
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
|
||||
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a custom threshold", () => {
|
||||
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
|
||||
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
|
||||
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
|
||||
|
||||
/**
|
||||
* Decide whether an outside pointer (mousedown) should collapse the chat window.
|
||||
*
|
||||
* Returns true only when the pointer target is genuinely "on the page": NOT
|
||||
* inside the window element AND NOT inside a Mantine portal. Mantine renders
|
||||
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
|
||||
* notifications into portals tagged with `data-portal="true"`; clicks on those
|
||||
* are part of operating the chat, so they must not collapse it.
|
||||
*/
|
||||
export function shouldCollapseOnOutsidePointer(
|
||||
target: EventTarget | null,
|
||||
windowEl: HTMLElement | null,
|
||||
): boolean {
|
||||
if (!windowEl) return false;
|
||||
if (!(target instanceof Element)) return false;
|
||||
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
|
||||
if (windowEl.contains(target)) return false;
|
||||
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
|
||||
// notifications). data-portal="true" reliably excludes all of them.
|
||||
if (target.closest("[data-portal]")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-vs-drag discrimination for the window header: a press whose pointer
|
||||
* moved less than `threshold` px on both axes between mousedown and mouseup is
|
||||
* treated as a click (which expands a collapsed window), not a drag (which
|
||||
* repositions it).
|
||||
*/
|
||||
export function isHeaderClick(
|
||||
downX: number,
|
||||
downY: number,
|
||||
upX: number,
|
||||
upY: number,
|
||||
threshold = 4,
|
||||
): boolean {
|
||||
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
|
||||
}
|
||||
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeChatError } from "./error-message";
|
||||
|
||||
// Identity translator: assert on the raw English key so the tests do not depend
|
||||
// on the i18n catalog.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('surfaces a provider "402: ..." stream error verbatim', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t)).toBe(
|
||||
"402: Insufficient credits",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
|
||||
// A provider message mentioning the number 403 must be surfaced verbatim,
|
||||
// never folded into the "AI chat is disabled" gating message.
|
||||
const msg = "429: rate limited after 403 attempts";
|
||||
expect(describeChatError(msg, t)).toBe(msg);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":403} body to the disabled message', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"AI chat is disabled for this workspace.",
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":503} body to the not-configured message', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic message for empty input", () => {
|
||||
expect(describeChatError("", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
/**
|
||||
* Tests for the internal-link neutralization used by the anonymous public
|
||||
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
|
||||
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
|
||||
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
|
||||
* routes. With the flag ON those links are made inert (href removed) while the
|
||||
* visible text and the rest of the markdown formatting are preserved; genuinely
|
||||
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
|
||||
* with a safe rel/target, while absolute links back to our OWN origin are
|
||||
* neutralized too. With the flag OFF (internal default) links keep their href so
|
||||
* the authenticated chat is unchanged.
|
||||
*/
|
||||
|
||||
/** Parse the rendered HTML and return the first <a> element (or null). */
|
||||
function firstAnchor(html: string): HTMLAnchorElement | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.querySelector("a");
|
||||
}
|
||||
|
||||
describe("renderChatMarkdown — internal link neutralization", () => {
|
||||
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
// Visible link text is preserved.
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes bare-fragment links when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[here](#section)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[y](https://example.com/x)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("https://example.com/x");
|
||||
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a!.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
|
||||
// An LLM can emit an absolute URL back at our own host (e.g.
|
||||
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
|
||||
// relative `/p/...` link, not kept clickable as if it were external.
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
|
||||
// javascript:, data:, and protocol-relative `//...` must never stay
|
||||
// clickable on the anonymous share — they are not genuinely external
|
||||
// http(s) links to a different host, so the href is dropped (or sanitized
|
||||
// away entirely by DOMPurify).
|
||||
for (const markdown of [
|
||||
"[a](javascript:alert(1))",
|
||||
"[b](data:text/html,<script>alert(1)</script>)",
|
||||
"[c](//evil.com/x)",
|
||||
]) {
|
||||
const html = renderChatMarkdown(markdown, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
// Either the anchor was stripped of its href, or DOMPurify removed the
|
||||
// unsafe href outright; in both cases nothing dangerous remains.
|
||||
if (a !== null) {
|
||||
expect(a.hasAttribute("href")).toBe(false);
|
||||
expect(a.hasAttribute("target")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
|
||||
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`);
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe(ownOrigin);
|
||||
});
|
||||
|
||||
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
|
||||
// A neutralizing render first, then an internal render: the internal link
|
||||
// must survive (the hook is removed after the share render).
|
||||
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,66 @@
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface RenderChatMarkdownOptions {
|
||||
/**
|
||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||
* Used by the anonymous public share: the assistant's answer can contain
|
||||
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
|
||||
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
|
||||
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
|
||||
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
|
||||
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
|
||||
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
|
||||
* neutralized too. Defaults to false — the internal chat keeps internal links
|
||||
* clickable for authenticated users.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
|
||||
* anonymous reader to follow. A link qualifies only if it is absolute
|
||||
* `http(s)://` AND its host differs from the app's own origin
|
||||
* (`window.location.host`): absolute links back to our OWN host (e.g.
|
||||
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
|
||||
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
|
||||
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
|
||||
* parse) is treated as internal/unsafe and neutralized — fail closed.
|
||||
*/
|
||||
function isExternalHttpUrl(href: string): boolean {
|
||||
const value = href.trim();
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
try {
|
||||
// External only if it points at a DIFFERENT host than the app's own origin.
|
||||
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
|
||||
// internal and must be neutralized, same as relative `/p/...` links.
|
||||
return new URL(value).host !== window.location.host;
|
||||
} catch {
|
||||
return false; // unparseable -> treat as internal/unsafe, neutralize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
|
||||
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
|
||||
* for the duration of a single sanitize call (added then removed in
|
||||
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
|
||||
*/
|
||||
function neutralizeInternalLinksHook(node: Element): void {
|
||||
if (node.nodeName !== "A") return;
|
||||
const href = node.getAttribute("href");
|
||||
if (href !== null && isExternalHttpUrl(href)) {
|
||||
// Genuinely external link: keep it, but force a safe rel/target.
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
node.setAttribute("target", "_blank");
|
||||
return;
|
||||
}
|
||||
// Internal/relative/fragment link (or no href): make it inert text. Drop the
|
||||
// href and any target so it is no longer clickable; the visible text stays.
|
||||
node.removeAttribute("href");
|
||||
node.removeAttribute("target");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
|
||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||
* for that branch (the caller renders the raw text fallback instead).
|
||||
*/
|
||||
export function renderChatMarkdown(markdown: string): string {
|
||||
export function renderChatMarkdown(
|
||||
markdown: string,
|
||||
options: RenderChatMarkdownOptions = {},
|
||||
): string {
|
||||
if (!markdown) return "";
|
||||
const html = markdownToHtml(markdown);
|
||||
if (typeof html !== "string") return "";
|
||||
return DOMPurify.sanitize(html);
|
||||
|
||||
if (!options.neutralizeInternalLinks) {
|
||||
// Internal chat: unchanged behavior, no hook registered.
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Public share: register the neutralization hook only for THIS sanitize call,
|
||||
// then remove it immediately so it can never affect the internal chat (hooks
|
||||
// are global on the shared DOMPurify instance).
|
||||
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
|
||||
try {
|
||||
return DOMPurify.sanitize(html);
|
||||
} finally {
|
||||
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
|
||||
// robust to any other afterSanitizeAttributes hook registered in future.
|
||||
DOMPurify.removeHook(
|
||||
"afterSanitizeAttributes",
|
||||
neutralizeInternalLinksHook,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
|
||||
describe("toolCitations", () => {
|
||||
it("emits one citation per searchPages item with a /p/{id} href", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [
|
||||
{ id: "p1", title: "First" },
|
||||
{ id: "p2", title: "Second" },
|
||||
],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p1", title: "First", href: "/p/p1" },
|
||||
{ pageId: "p2", title: "Second", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops searchPages items missing an id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p2", title: "Kept", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
|
||||
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-updatePageContent",
|
||||
state: "output-available",
|
||||
input: { pageId: "host-1", title: "From input" },
|
||||
output: { pageId: "host-1" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers output.id over input.pageId when both exist", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "input-id", title: "Input title" },
|
||||
output: { id: "output-id", title: "Output title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the state is not output-available", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
output: { id: "p1", title: "Pending" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a page-op output with no resolvable id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: {},
|
||||
output: { title: "Only a title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-denied" to error', () => {
|
||||
expect(toolRunState("output-denied")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-available" to done', () => {
|
||||
expect(toolRunState("output-available")).toBe("done");
|
||||
});
|
||||
|
||||
it('maps "input-available" to running', () => {
|
||||
expect(toolRunState("input-available")).toBe("running");
|
||||
});
|
||||
|
||||
it("maps undefined to running", () => {
|
||||
expect(toolRunState(undefined)).toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@
|
||||
*
|
||||
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
|
||||
* its `state` is one of input-streaming / input-available / output-available /
|
||||
* output-error (we only surface running / done / error). The server tools are:
|
||||
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
|
||||
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
|
||||
* output-error (we only surface running / done / error). The full toolset the
|
||||
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
|
||||
* complete Docmost toolset); friendly action-log labels exist ONLY for the
|
||||
* tools listed in `toolLabelKey` below — every other tool falls through to the
|
||||
* generic "Ran tool {{name}}" label.
|
||||
*/
|
||||
|
||||
/** A tool UI part as it arrives from `useChat` / persisted history. */
|
||||
@@ -38,6 +40,11 @@ export interface ToolCitation {
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
export function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
|
||||
export function getToolName(part: ToolUiPart): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "";
|
||||
|
||||
@@ -116,8 +116,8 @@ function CommentListItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
@@ -126,7 +126,7 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
@@ -177,7 +177,7 @@ function CommentListItem({
|
||||
tabIndex={0}
|
||||
aria-label={t("Jump to comment selection")}
|
||||
>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
<Text size="xs">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ function CommentListWithTabs() {
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
p="xs"
|
||||
mb="xs"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
@@ -145,7 +145,7 @@ function CommentListWithTabs() {
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Divider my={2} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
padding: 6px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
@@ -32,6 +28,9 @@
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* Denser comments: override the global 16px ProseMirror body size with 14px
|
||||
and tighten the rhythm vs. the comment header. Scoped to the comment
|
||||
editor only - the page editor is unaffected. */
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
@@ -39,7 +38,9 @@
|
||||
word-break: break-word;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for a single footnote definition: a decorative number marker, the
|
||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||
* The number is derived from the document (not stored).
|
||||
*/
|
||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
const { node, editor } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
// Read the cached number from the numbering plugin (computed once per doc
|
||||
// change) rather than recomputing the whole map on every render.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-footnote-def=""
|
||||
data-id={id}
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
<span className={classes.definitionMarker} contentEditable={false}>
|
||||
{number}.
|
||||
</span>
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={handleBack}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
getFootnoteNumber,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* Read the plain text of the footnote definition with `id` directly from the
|
||||
* editor state. No sub-editor: the popover is read-only.
|
||||
*/
|
||||
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
|
||||
let text = "";
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
text = node.textContent;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function FootnoteReferenceView(props: NodeViewProps) {
|
||||
const { node, editor, selected } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Number is derived (not stored). Read it from the numbering plugin's cached
|
||||
// map (computed once per doc change) instead of walking the whole document on
|
||||
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
const defText = open ? getDefinitionText(editor, id) : "";
|
||||
|
||||
const position = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
computePosition(anchor, popup, {
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
|
||||
const cleanup = autoUpdate(anchor, popup, position);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
popup.contains(e.target as Node) ||
|
||||
anchor.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
const handleGoTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
editor.commands.scrollToFootnote(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" style={{ display: "inline" }}>
|
||||
<sup
|
||||
ref={(el) => (anchorRef.current = el)}
|
||||
data-footnote-ref=""
|
||||
data-id={id}
|
||||
className={`${classes.reference} ${selected ? classes.selected : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// The decoration sets --footnote-number; provide a fallback inline.
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
aria-label={t("Footnote {{number}}", { number })}
|
||||
role="button"
|
||||
/>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={classes.popover}
|
||||
role="tooltip"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className={classes.popoverHeader}>
|
||||
<span className={classes.popoverNumber}>
|
||||
{t("Footnote {{number}}", { number })}
|
||||
</span>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleGoTo}
|
||||
aria-label={t("Go to footnote")}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className={classes.popoverBody}>
|
||||
{defText || t("Empty footnote")}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
||||
*/
|
||||
export default function FootnotesListView(_props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.list} contentEditable={false}>
|
||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
||||
</div>
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
clampHeight,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender,
|
||||
} from "./html-embed-sandbox";
|
||||
|
||||
describe("buildSandboxSrcdoc", () => {
|
||||
it("embeds the user source verbatim", () => {
|
||||
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
|
||||
expect(out).toContain("<div id='x'>hello</div>");
|
||||
});
|
||||
|
||||
it("injects the height-postMessage bootstrap after the source", () => {
|
||||
const out = buildSandboxSrcdoc("<p>body</p>");
|
||||
// The bootstrap is appended AFTER the source.
|
||||
expect(out.indexOf("<p>body</p>")).toBeLessThan(
|
||||
out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
|
||||
);
|
||||
// It reports its height to the parent via postMessage with the agreed type.
|
||||
expect(out).toContain("parent.postMessage");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
// It observes resizes so the parent can keep the iframe sized to fit.
|
||||
expect(out).toContain("ResizeObserver");
|
||||
expect(out).toContain('addEventListener("load"');
|
||||
});
|
||||
|
||||
it("handles an empty source (still injects the bootstrap)", () => {
|
||||
const out = buildSandboxSrcdoc("");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRender (render policy)", () => {
|
||||
it("read-only renders regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldRender(false, false)).toBe(true);
|
||||
expect(shouldRender(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT render", () => {
|
||||
expect(shouldRender(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON renders", () => {
|
||||
expect(shouldRender(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampHeight", () => {
|
||||
it("clamps below the lower bound up to MIN_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(0)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(-100)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT - 1)).toBe(MIN_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("clamps above the upper bound down to MAX_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT + 1)).toBe(MAX_IFRAME_HEIGHT);
|
||||
expect(clampHeight(999999)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("passes a value within range through unchanged", () => {
|
||||
expect(clampHeight(150)).toBe(150);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrustedHeightMessage (resize message guard)", () => {
|
||||
// Stand-ins for window objects; identity is all the guard compares.
|
||||
const ownWindow = {} as Window;
|
||||
const foreignWindow = {} as Window;
|
||||
const iframeEl = { contentWindow: ownWindow };
|
||||
|
||||
const validData = { type: HTML_EMBED_HEIGHT_MESSAGE, height: 300 };
|
||||
|
||||
it("accepts a same-source message with a finite numeric height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, iframeEl),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a message from a DIFFERENT source (foreign window)", () => {
|
||||
// A page can postMessage anything; only our own iframe's contentWindow is
|
||||
// trusted. This is the core security check.
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: foreignWindow, data: validData },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong-type message even from the right source", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: "something-else", height: 300 } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: NaN } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an Infinity height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{
|
||||
source: ownWindow,
|
||||
data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: Infinity },
|
||||
},
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when the iframe element / contentWindow is null", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, null),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: null, data: validData },
|
||||
{ contentWindow: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe sandbox attributes", () => {
|
||||
it("uses EXACTLY allow-scripts allow-popups allow-forms (no allow-same-origin)", () => {
|
||||
expect(HTML_EMBED_SANDBOX).toBe("allow-scripts allow-popups allow-forms");
|
||||
// The critical security invariant: opaque origin => no session/cookie access.
|
||||
expect(HTML_EMBED_SANDBOX).not.toContain("allow-same-origin");
|
||||
});
|
||||
|
||||
it("the NodeView renders the embed via srcDoc (not src), set to the sandbox doc", () => {
|
||||
// The iframe carries the generated srcdoc; it never loads an external URL.
|
||||
const srcdoc = buildSandboxSrcdoc("<p>hi</p>");
|
||||
expect(srcdoc).toContain("<p>hi</p>");
|
||||
expect(srcdoc).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
|
||||
expect(canEdit(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit when the toggle is OFF", () => {
|
||||
expect(canEdit(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit in read-only mode (no edit affordance)", () => {
|
||||
expect(canEdit(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Pure helpers for the HTML embed node view. Kept out of the React component so
|
||||
* the sandbox srcdoc builder and the render/edit policy can be unit-tested
|
||||
* against a bare environment with no Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/** postMessage type the sandboxed iframe uses to report its content height. */
|
||||
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
export const MIN_IFRAME_HEIGHT = 40;
|
||||
export const MAX_IFRAME_HEIGHT = 4000;
|
||||
export const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
/**
|
||||
* Sandbox tokens for the embed iframe. Intentionally does NOT include
|
||||
* `allow-same-origin`: the content must run in an opaque ("null") origin so it
|
||||
* cannot read the viewer's cookies/session/API.
|
||||
*/
|
||||
export const HTML_EMBED_SANDBOX = "allow-scripts allow-popups allow-forms";
|
||||
|
||||
/** Clamp a reported/configured height into the sane iframe bounds. */
|
||||
export function clampHeight(h: number): number {
|
||||
return Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the auto-resize `message` handler. Returns the clamped numeric
|
||||
* height ONLY when the event is a trusted resize report; otherwise null.
|
||||
*
|
||||
* Trusted means ALL of:
|
||||
* - `event.source` is this iframe's own `contentWindow` (the sandboxed srcdoc
|
||||
* has an opaque "null" origin, so we cannot match by `event.origin` — we
|
||||
* match by source instead). A message from any OTHER window is rejected.
|
||||
* - the payload `type` is exactly our agreed resize message type.
|
||||
* - the reported `height` is a finite number (rejects NaN/Infinity).
|
||||
*/
|
||||
export function isTrustedHeightMessage(
|
||||
event: Pick<MessageEvent, "source" | "data">,
|
||||
iframeEl: { contentWindow: Window | null } | null,
|
||||
): boolean {
|
||||
// Reject when there is no contentWindow to match against; otherwise a `null`
|
||||
// event.source would spuriously equal a `null` contentWindow.
|
||||
if (!iframeEl?.contentWindow) return false;
|
||||
if (event.source !== iframeEl.contentWindow) return false;
|
||||
const data = event.data as { type?: string; height?: number } | null;
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return false;
|
||||
return Number.isFinite(Number(data.height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `srcdoc` document for the sandboxed embed iframe.
|
||||
*
|
||||
* The user's `source` is placed verbatim, then a small bootstrap <script> is
|
||||
* appended at the end of the body. The iframe is rendered with a sandbox that
|
||||
* does NOT include `allow-same-origin`, so this content runs in an opaque
|
||||
* ("null") origin and cannot read the viewer's cookies/session/API — it is
|
||||
* harmless. The bootstrap measures the document height and reports it to the
|
||||
* parent via postMessage on load and whenever the content resizes, so the
|
||||
* parent can size the iframe to fit (auto-resize mode).
|
||||
*/
|
||||
export function buildSandboxSrcdoc(source: string): string {
|
||||
const bootstrap = `
|
||||
<script>
|
||||
(function () {
|
||||
var lastSent = -1;
|
||||
var scheduled = false;
|
||||
function measure() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
return Math.max(
|
||||
doc ? doc.scrollHeight : 0,
|
||||
body ? body.scrollHeight : 0
|
||||
);
|
||||
}
|
||||
function flush() {
|
||||
scheduled = false;
|
||||
var height = measure();
|
||||
// Only report when the height actually changed by more than 1px. This
|
||||
// damps the iframe self-measure feedback loop: content sized to the iframe
|
||||
// viewport would otherwise oscillate as the parent resizes the frame in
|
||||
// response to each report.
|
||||
if (Math.abs(height - lastSent) <= 1) return;
|
||||
lastSent = height;
|
||||
parent.postMessage(
|
||||
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
function reportHeight() {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(flush);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
window.addEventListener("load", reportHeight);
|
||||
// Report an initial height now (runs during parse, before load/images
|
||||
// settle); the load handler and ResizeObserver refine it as content changes.
|
||||
reportHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
try {
|
||||
var ro = new ResizeObserver(reportHeight);
|
||||
ro.observe(document.documentElement);
|
||||
} catch (e) {
|
||||
// ResizeObserver unavailable/failed: the load handler still reports once.
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
return `${source || ""}${bootstrap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace master toggle is OFF). An anonymous viewer has no workspace and
|
||||
* thus reads `featureEnabled` as false, so we must NOT gate rendering on it
|
||||
* here — we render exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
|
||||
* the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldRender(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode and is offered only when
|
||||
* the workspace master toggle is ON. The block renders in a sandboxed iframe
|
||||
* (no same-origin access), so authoring is allowed to ANY member — there is no
|
||||
* admin requirement.
|
||||
*/
|
||||
export function canEdit(isEditable: boolean, featureEnabled: boolean): boolean {
|
||||
return isEditable && featureEnabled;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.htmlEmbedNodeView {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Fallback container used only for the empty, non-editor case. */
|
||||
.htmlEmbedContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The sandboxed iframe the embed source is rendered into. */
|
||||
.htmlEmbedFrame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Edit affordance overlay, only shown while editing the document. */
|
||||
.htmlEmbedToolbar {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.htmlEmbedNodeView:hover .htmlEmbedToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Placeholder card shown when the source is empty (edit mode only). */
|
||||
.htmlEmbedPlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--mantine-color-gray-4);
|
||||
border-radius: 8px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
|
||||
@mixin dark {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
}
|
||||
|
||||
.htmlEmbedSelected {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconEdit } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
clampHeight,
|
||||
DEFAULT_IFRAME_HEIGHT,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender as computeShouldRender,
|
||||
} from "./html-embed-sandbox.ts";
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { source, height } = node.attrs as {
|
||||
source: string;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
|
||||
// the workspace toggle is a feature switch, not a security gate. When OFF (the
|
||||
// default) we render a neutral placeholder in the editor and nothing else.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
|
||||
|
||||
const shouldRender = computeShouldRender(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(source || "");
|
||||
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
|
||||
|
||||
// True when the author pinned an explicit height; otherwise we auto-resize to
|
||||
// the iframe's reported content height.
|
||||
const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
|
||||
|
||||
// Auto-resize height tracked in state. Seeded to the default and updated from
|
||||
// the iframe's postMessage reports (see effect below) regardless of mode, so
|
||||
// switching a fixed-height embed back to auto immediately reflects the last
|
||||
// reported content height instead of staying pinned to the old fixed value.
|
||||
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
|
||||
|
||||
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
|
||||
|
||||
// Auto-resize: accept height messages ONLY from this iframe's own content
|
||||
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
|
||||
// match by event.origin — we match by event.source instead. We track the
|
||||
// reported height even while a fixed height is in effect, so toggling back to
|
||||
// auto shows the current content height with no iframe reload.
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (!isTrustedHeightMessage(event, iframeRef.current)) return;
|
||||
const next = Number((event.data as { height?: number }).height);
|
||||
setAutoHeight(clampHeight(next));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
setDraft(source || "");
|
||||
setDraftHeight(height ?? "");
|
||||
setModalOpen(true);
|
||||
}, [source, height]);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (editor.isEditable) {
|
||||
updateAttributes({
|
||||
source: draft,
|
||||
height: draftHeight === "" ? null : Number(draftHeight),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
|
||||
|
||||
// The edit affordance is only meaningful in edit mode and is offered only when
|
||||
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
|
||||
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-drag-handle
|
||||
className={clsx(classes.htmlEmbedNodeView, {
|
||||
[classes.htmlEmbedSelected]: selected,
|
||||
})}
|
||||
>
|
||||
{canEdit && (
|
||||
<div className={classes.htmlEmbedToolbar}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={t("Edit HTML embed")}
|
||||
onClick={openEditor}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldRender ? (
|
||||
// Feature disabled for this workspace AND we're in the editable editor:
|
||||
// render a neutral placeholder so an existing embed is visibly inert for
|
||||
// the author. Read-only / share viewers never hit this branch
|
||||
// (`shouldRender` is always true there) — they render exactly the
|
||||
// source the server chose to serve.
|
||||
<div className={classes.htmlEmbedPlaceholder}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">
|
||||
{t("HTML embed is disabled in this workspace")}
|
||||
</Text>
|
||||
</div>
|
||||
) : source ? (
|
||||
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
|
||||
// scripts run in an opaque origin and cannot touch the viewer's
|
||||
// session/cookies/API.
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={classes.htmlEmbedFrame}
|
||||
sandbox={HTML_EMBED_SANDBOX}
|
||||
srcDoc={srcdoc}
|
||||
title={t("HTML embed")}
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: effectiveHeight }}
|
||||
/>
|
||||
) : canEdit ? (
|
||||
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">{t("Click to add HTML / CSS / JS")}</Text>
|
||||
</div>
|
||||
) : (
|
||||
// Empty source, non-editor: render nothing visible.
|
||||
<div className={classes.htmlEmbedContent} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={t("Edit HTML embed")}
|
||||
size="lg"
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={10}
|
||||
maxRows={24}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder={t("<script>...</script>")}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
data-autofocus
|
||||
/>
|
||||
<NumberInput
|
||||
mt="md"
|
||||
label={t("Height (px, blank = auto)")}
|
||||
value={draftHeight}
|
||||
onChange={(value) =>
|
||||
setDraftHeight(
|
||||
value === "" || value === null ? "" : Number(value),
|
||||
)
|
||||
}
|
||||
min={MIN_IFRAME_HEIGHT}
|
||||
max={MAX_IFRAME_HEIGHT}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave}>{t("Save")}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
const okResult: PageTemplateLookup = {
|
||||
sourcePageId: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "Template",
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("decideEmbedState", () => {
|
||||
it("returns no_source when sourcePageId is null", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: null,
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("no_source");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: ["root", "p1"],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "host",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
|
||||
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("too_deep");
|
||||
});
|
||||
|
||||
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
|
||||
const chain = Array.from(
|
||||
{ length: PAGE_EMBED_MAX_DEPTH },
|
||||
(_, i) => `a${i}`,
|
||||
);
|
||||
chain[0] = "p1"; // also a cycle
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns unavailable when no lookup context is mounted", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: false,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("returns loading when available but the result is not back yet", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("loading");
|
||||
});
|
||||
|
||||
it("returns no_access when the result status is no_access", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "no_access" },
|
||||
}),
|
||||
).toBe("no_access");
|
||||
});
|
||||
|
||||
it("returns not_found when the result status is not_found", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "not_found" },
|
||||
}),
|
||||
).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns ok for a resolved template (happy path)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
/**
|
||||
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
|
||||
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
|
||||
* branch logic is unit-testable in isolation; the node view maps each outcome
|
||||
* to a placeholder or the embedded content.
|
||||
*/
|
||||
export type EmbedState =
|
||||
| "no_source" // no sourcePageId picked yet
|
||||
| "cycle" // self-embed or an ancestor already shows this page
|
||||
| "too_deep" // nesting depth limit reached
|
||||
| "unavailable" // no lookup context (e.g. public share)
|
||||
| "loading" // context present, result not back yet
|
||||
| "ok" // resolved template content to render
|
||||
| "no_access" // server says the viewer can't see the page
|
||||
| "not_found"; // server says the page no longer exists
|
||||
|
||||
export interface DecideEmbedStateInput {
|
||||
sourcePageId: string | null;
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Host page id; a top-level self-embed must be caught against it. */
|
||||
hostPageId: string | null;
|
||||
/** Whether a lookup context is mounted (false on public shares in MVP). */
|
||||
available: boolean;
|
||||
/** The lookup result, or null while still loading. */
|
||||
result: PageTemplateLookup | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a pageEmbed should render. The order matters: cycle and depth
|
||||
* guards run first (before any lookup is even consulted), then availability,
|
||||
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
|
||||
*/
|
||||
export function decideEmbedState({
|
||||
sourcePageId,
|
||||
chain,
|
||||
hostPageId,
|
||||
available,
|
||||
result,
|
||||
}: DecideEmbedStateInput): EmbedState {
|
||||
if (!sourcePageId) return "no_source";
|
||||
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
|
||||
if (isCycle) return "cycle";
|
||||
|
||||
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
|
||||
|
||||
if (!available) return "unavailable";
|
||||
if (!result) return "loading";
|
||||
|
||||
if (!("status" in result)) return "ok";
|
||||
if (result.status === "no_access") return "no_access";
|
||||
return "not_found";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
|
||||
/**
|
||||
* Tiny probe that renders the current ancestry context as serialized data
|
||||
* attributes so tests can assert the accumulated chain / threaded hostPageId
|
||||
* without mounting the heavy Tiptap node view.
|
||||
*/
|
||||
function AncestryProbe({ testId = "probe" }: { testId?: string }) {
|
||||
const { chain, hostPageId } = usePageEmbedAncestry();
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
data-chain={chain.join(",")}
|
||||
data-chain-length={String(chain.length)}
|
||||
data-host={hostPageId ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PageEmbedAncestryProvider", () => {
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
render(<AncestryProbe />);
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-chain")).toBe("");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("0");
|
||||
expect(probe.getAttribute("data-host")).toBe("");
|
||||
});
|
||||
|
||||
it("accumulates sourcePageId into the chain across nested providers", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b">
|
||||
<PageEmbedAncestryProvider sourcePageId="c">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// Chain is built outermost -> innermost.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a,b,c");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("3");
|
||||
});
|
||||
|
||||
it("threads the host page id from the outermost provider down the tree", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host-page">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="ignored">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// The first host wins (parent.hostPageId ?? hostPageId); deeper hosts are
|
||||
// ignored so the original host is preserved for self-embed detection.
|
||||
expect(probe.getAttribute("data-host")).toBe("host-page");
|
||||
});
|
||||
|
||||
it("does not add an entry to the chain when sourcePageId is missing", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId={null}>
|
||||
<PageEmbedAncestryProvider>
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// null / undefined sources are pass-through: chain stays ["a"], host kept.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a");
|
||||
expect(probe.getAttribute("data-host")).toBe("host");
|
||||
});
|
||||
|
||||
it("adopts a host provided only at a deeper level when the root had none", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="late-host">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-host")).toBe("late-host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { createContext, useContext, useMemo } from "react";
|
||||
|
||||
/** Hard cap on nesting depth for whole-page embeds (cycle/runaway guard). */
|
||||
export const PAGE_EMBED_MAX_DEPTH = 5;
|
||||
|
||||
type AncestryValue = {
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Includes the host page id so a top-level self-embed is also caught. */
|
||||
hostPageId: string | null;
|
||||
};
|
||||
|
||||
const PageEmbedAncestryContext = createContext<AncestryValue>({
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Carries the ancestor `sourcePageId` chain down the nested read-only editors.
|
||||
* The node view reads it to detect cycles (current id already in the chain) and
|
||||
* to enforce a hard depth limit before mounting a deeper nested editor.
|
||||
*/
|
||||
export function PageEmbedAncestryProvider({
|
||||
sourcePageId,
|
||||
hostPageId,
|
||||
children,
|
||||
}: {
|
||||
sourcePageId?: string | null;
|
||||
hostPageId?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const parent = useContext(PageEmbedAncestryContext);
|
||||
const value = useMemo<AncestryValue>(() => {
|
||||
const nextHost = parent.hostPageId ?? hostPageId ?? null;
|
||||
if (!sourcePageId) {
|
||||
return { chain: parent.chain, hostPageId: nextHost };
|
||||
}
|
||||
return {
|
||||
chain: [...parent.chain, sourcePageId],
|
||||
hostPageId: nextHost,
|
||||
};
|
||||
}, [parent, sourcePageId, hostPageId]);
|
||||
|
||||
return (
|
||||
<PageEmbedAncestryContext.Provider value={value}>
|
||||
{children}
|
||||
</PageEmbedAncestryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageEmbedAncestry() {
|
||||
return useContext(PageEmbedAncestryContext);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { EditorProvider } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { UniqueID } from "@docmost/editor-ext";
|
||||
|
||||
type Props = {
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only nested renderer for embedded whole-page content. Same pattern as
|
||||
* the transclusion read-only renderer: drop uniqueID/globalDragHandle, never
|
||||
* write back, and isolate pointer/drag events from the host editor. Nested
|
||||
* `pageEmbed`/`transclusionReference` nodes inside the content render with
|
||||
* their own views (the cycle/depth guard lives in the node view itself).
|
||||
*/
|
||||
export default function PageEmbedContent({ content }: Props) {
|
||||
const extensions = useMemo(() => {
|
||||
const filtered = mainExtensions.filter(
|
||||
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
|
||||
);
|
||||
return [
|
||||
...filtered,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph", "transclusionSource"],
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onDragStart={stop}
|
||||
onDragOver={stop}
|
||||
onDrop={stop}
|
||||
>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={extensions}
|
||||
content={content as any}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
// Mock the API module the provider calls. Hoisted by vitest before the import.
|
||||
const lookupTemplate = vi.fn();
|
||||
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
|
||||
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
|
||||
}));
|
||||
|
||||
// Imported AFTER the mock is declared so the provider picks up the mock.
|
||||
import {
|
||||
PageEmbedLookupProvider,
|
||||
usePageEmbedLookup,
|
||||
} from "./page-embed-lookup-context";
|
||||
|
||||
function ok(id: string): PageTemplateLookup {
|
||||
return {
|
||||
sourcePageId: id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `T-${id}`,
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
|
||||
function Probe({
|
||||
id,
|
||||
sink,
|
||||
}: {
|
||||
id: string;
|
||||
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
|
||||
}) {
|
||||
const api = usePageEmbedLookup(id);
|
||||
sink(api);
|
||||
return <div>{api.result ? "loaded" : "pending"}</div>;
|
||||
}
|
||||
|
||||
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lookupTemplate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
<Probe id="p1" sink={(x) => (b = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Subscriptions run in effects + the 10ms debounce batches them together.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
expect(b!.result).toEqual(ok("p1"));
|
||||
});
|
||||
|
||||
it("batches two distinct ids subscribed within the window into one call", async () => {
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={() => {}} />
|
||||
<Probe id="p2" sink={() => {}} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate.mock.calls[0][0]).toEqual({
|
||||
sourcePageIds: ["p1", "p2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() clears the cache and re-fetches", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// refresh resolves once the next batch flush completes.
|
||||
await act(async () => {
|
||||
const p = a!.refresh();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Initial subscription enqueues a lookup that rejects.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
// The error message is surfaced, not swallowed.
|
||||
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
|
||||
|
||||
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
|
||||
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
|
||||
let resolved = false;
|
||||
await act(async () => {
|
||||
const p = a!.refresh().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
expect(resolved).toBe(true);
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lookupTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
type ContextValue = {
|
||||
subscribe: (s: {
|
||||
sourcePageId: string;
|
||||
setResult: (r: PageTemplateLookup) => void;
|
||||
}) => () => void;
|
||||
refresh: (sourcePageId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const PageEmbedLookupContext = createContext<ContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Batching/de-dup lookup context for whole-page embeds (pageEmbed). Mirrors the
|
||||
* transclusion lookup context but keys purely on `sourcePageId`. On public
|
||||
* shares there is no lookup in MVP, so the context simply isn't mounted (the
|
||||
* node view renders a placeholder when the context is absent).
|
||||
*
|
||||
* NOTE (intentional near-duplicate of `transclusion-lookup-context.tsx`): this
|
||||
* provider duplicates that file's batching / de-dup / cache machinery; only the
|
||||
* lookup key (sourcePageId here vs sourcePageId+transclusionId there) and the
|
||||
* API call differ. Unifying them now would mean a generic, parameterised lookup
|
||||
* provider — a larger client refactor that isn't worth it for just two
|
||||
* consumers. Per Gitea #94, extract a shared generic provider when a THIRD
|
||||
* lookup consumer appears; until then keep the two in sync by hand. (Tracked,
|
||||
* deliberately deferred — not forgotten.)
|
||||
*/
|
||||
export function PageEmbedLookupProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const subscribersRef = useRef(new Map<string, Array<(r: PageTemplateLookup) => void>>());
|
||||
const queueRef = useRef(new Set<string>());
|
||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resultCacheRef = useRef(new Map<string, PageTemplateLookup>());
|
||||
const inFlightRef = useRef(new Set<string>());
|
||||
const pendingRef = useRef(new Map<string, Array<() => void>>());
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
tickRef.current = null;
|
||||
const ids = Array.from(queueRef.current);
|
||||
queueRef.current.clear();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
for (const id of ids) inFlightRef.current.add(id);
|
||||
|
||||
const resolveWaiters = (id: string) => {
|
||||
const waiters = pendingRef.current.get(id);
|
||||
if (!waiters) return;
|
||||
pendingRef.current.delete(id);
|
||||
for (const w of waiters) w();
|
||||
};
|
||||
|
||||
try {
|
||||
const { items } = await lookupTemplate({ sourcePageIds: ids });
|
||||
const returned = new Set<string>();
|
||||
for (const r of items) {
|
||||
returned.add(r.sourcePageId);
|
||||
resultCacheRef.current.set(r.sourcePageId, r);
|
||||
inFlightRef.current.delete(r.sourcePageId);
|
||||
const subs = subscribersRef.current.get(r.sourcePageId);
|
||||
if (subs) {
|
||||
for (const set of subs) set(r);
|
||||
}
|
||||
resolveWaiters(r.sourcePageId);
|
||||
}
|
||||
// Harden against a partial/short server response: any requested id not
|
||||
// present in `items` would otherwise stay in `inFlightRef` forever
|
||||
// (subscribe/refresh are guarded by `!inFlightRef.has(id)`) and its
|
||||
// refresh() promise would never resolve. Clear + resolve those ids,
|
||||
// mirroring the catch branch, so no id can be stranded in-flight.
|
||||
for (const id of ids) {
|
||||
if (!returned.has(id)) {
|
||||
inFlightRef.current.delete(id);
|
||||
resolveWaiters(id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure: errors must never be swallowed silently.
|
||||
console.error("[pageEmbed] template lookup failed", err);
|
||||
for (const id of ids) {
|
||||
inFlightRef.current.delete(id);
|
||||
resolveWaiters(id);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(id: string) => {
|
||||
queueRef.current.add(id);
|
||||
if (tickRef.current === null) {
|
||||
tickRef.current = setTimeout(flush, 10);
|
||||
}
|
||||
},
|
||||
[flush],
|
||||
);
|
||||
|
||||
const subscribe = useCallback<ContextValue["subscribe"]>(
|
||||
({ sourcePageId, setResult }) => {
|
||||
const list = subscribersRef.current.get(sourcePageId) ?? [];
|
||||
list.push(setResult);
|
||||
subscribersRef.current.set(sourcePageId, list);
|
||||
|
||||
const cached = resultCacheRef.current.get(sourcePageId);
|
||||
if (cached) {
|
||||
setResult(cached);
|
||||
} else if (!inFlightRef.current.has(sourcePageId)) {
|
||||
enqueue(sourcePageId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const cur = subscribersRef.current.get(sourcePageId) ?? [];
|
||||
const next = cur.filter((x) => x !== setResult);
|
||||
if (next.length === 0) subscribersRef.current.delete(sourcePageId);
|
||||
else subscribersRef.current.set(sourcePageId, next);
|
||||
};
|
||||
},
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
const refresh = useCallback<ContextValue["refresh"]>(
|
||||
(sourcePageId) =>
|
||||
new Promise<void>((resolve) => {
|
||||
resultCacheRef.current.delete(sourcePageId);
|
||||
inFlightRef.current.delete(sourcePageId);
|
||||
const waiters = pendingRef.current.get(sourcePageId) ?? [];
|
||||
waiters.push(resolve);
|
||||
pendingRef.current.set(sourcePageId, waiters);
|
||||
enqueue(sourcePageId);
|
||||
}),
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<ContextValue>(
|
||||
() => ({ subscribe, refresh }),
|
||||
[subscribe, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageEmbedLookupContext.Provider value={value}>
|
||||
{children}
|
||||
</PageEmbedLookupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageEmbedLookup(sourcePageId: string | null | undefined): {
|
||||
result: PageTemplateLookup | null;
|
||||
refresh: () => Promise<void>;
|
||||
available: boolean;
|
||||
} {
|
||||
const ctx = useContext(PageEmbedLookupContext);
|
||||
const [result, setResult] = useState<PageTemplateLookup | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !sourcePageId) return;
|
||||
const unsubscribe = ctx.subscribe({ sourcePageId, setResult });
|
||||
return unsubscribe;
|
||||
}, [ctx, sourcePageId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!ctx || !sourcePageId) return;
|
||||
await ctx.refresh(sourcePageId);
|
||||
}, [ctx, sourcePageId]);
|
||||
|
||||
return { result, refresh, available: Boolean(ctx) };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Modal, ScrollArea, TextInput, Text, UnstyledButton, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IconFileText, IconSearch } from "@tabler/icons-react";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
|
||||
|
||||
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
|
||||
|
||||
type PickerDetail = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
/** Host page id, used to forbid self-embed in the picker. */
|
||||
hostPageId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal page picker for inserting a `pageEmbed`. Queries search-suggestions
|
||||
* with `onlyTemplates` so only template-flagged pages are offered. Forbids
|
||||
* selecting the current (host) page (self-embed guard at insertion time).
|
||||
* Mounted once per editor; opened via a CustomEvent dispatched by the slash
|
||||
* command item.
|
||||
*/
|
||||
export default function PageEmbedPicker() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const detailRef = useRef<PickerDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<PickerDetail>).detail;
|
||||
if (!detail?.editor) return;
|
||||
detailRef.current = detail;
|
||||
setQuery("");
|
||||
setOpened(true);
|
||||
};
|
||||
document.addEventListener(PAGE_EMBED_PICKER_EVENT, handler);
|
||||
return () => document.removeEventListener(PAGE_EMBED_PICKER_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["page-embed-template-picker", query],
|
||||
queryFn: () => searchSuggestions(buildPickerQuery(query)),
|
||||
enabled: opened,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const hostPageId = detailRef.current?.hostPageId;
|
||||
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
|
||||
|
||||
const handleSelect = (page: IPage) => {
|
||||
const detail = detailRef.current;
|
||||
if (!detail) return;
|
||||
const { editor, range } = detail;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertPageEmbed({ sourcePageId: page.id })
|
||||
.run();
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={t("Embed page")}
|
||||
size="md"
|
||||
>
|
||||
<TextInput
|
||||
placeholder={t("Search templates...")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
autoFocus
|
||||
mb="sm"
|
||||
/>
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
{pages.length === 0 && !isFetching && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No templates found")}
|
||||
</Text>
|
||||
)}
|
||||
{pages.map((page) => (
|
||||
<UnstyledButton
|
||||
key={page.id}
|
||||
onClick={() => handleSelect(page)}
|
||||
style={{ display: "block", width: "100%", padding: "8px 4px" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{page.icon ? (
|
||||
<span>{page.icon}</span>
|
||||
) : (
|
||||
<IconFileText size={16} />
|
||||
)}
|
||||
<Text size="sm" truncate>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
function page(id: string): IPage {
|
||||
return { id, title: id, slugId: `slug-${id}` } as IPage;
|
||||
}
|
||||
|
||||
describe("excludeHost", () => {
|
||||
it("drops the host page from the results (self-embed guard)", () => {
|
||||
const result = excludeHost([page("a"), page("host"), page("b")], "host");
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns all pages when hostPageId is undefined", () => {
|
||||
const result = excludeHost([page("a"), page("b")], undefined);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops null/blank entries", () => {
|
||||
const result = excludeHost(
|
||||
[page("a"), null as unknown as IPage, page("b")],
|
||||
"host",
|
||||
);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPickerQuery", () => {
|
||||
it("passes onlyTemplates:true with the query and page inclusion", () => {
|
||||
expect(buildPickerQuery("foo")).toEqual({
|
||||
query: "foo",
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an empty query", () => {
|
||||
expect(buildPickerQuery("").query).toBe("");
|
||||
expect(buildPickerQuery("").onlyTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
|
||||
|
||||
/**
|
||||
* Self-embed guard at insertion time: drop the host page (and any null/blank
|
||||
* entries) from the picker results so the current page can't embed itself.
|
||||
*/
|
||||
export function excludeHost(
|
||||
pages: IPage[],
|
||||
hostPageId: string | undefined,
|
||||
): IPage[] {
|
||||
return pages.filter((p) => p && p.id !== hostPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the search-suggestions query for the template picker. Always restricts
|
||||
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
|
||||
* inline query args in PageEmbedPicker.
|
||||
*/
|
||||
export function buildPickerQuery(query: string): SearchSuggestionParams {
|
||||
return {
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDots,
|
||||
IconEyeOff,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconRepeat,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "../transclusion/transclusion.module.css";
|
||||
import { usePageEmbedLookup } from "./page-embed-lookup-context";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import PageEmbedContent from "./page-embed-content";
|
||||
|
||||
function Placeholder({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classes.placeholder}>
|
||||
<span className={classes.placeholderIcon}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageEmbedView(props: NodeViewProps) {
|
||||
const isEditable = props.editor.isEditable;
|
||||
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
|
||||
const [openMenus, setOpenMenus] = useState(0);
|
||||
const trackOpen = (open: boolean) =>
|
||||
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.includeWrap}
|
||||
data-editable={isEditable ? "true" : "false"}
|
||||
data-focused={isEditable && props.selected ? "true" : "false"}
|
||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
||||
contentEditable={false}
|
||||
>
|
||||
<ErrorBoundary
|
||||
resetKeys={[sourcePageId]}
|
||||
onError={(err) =>
|
||||
// Never swallow: log the full error with the offending source id.
|
||||
console.error("[pageEmbed] render error", { sourcePageId, err })
|
||||
}
|
||||
fallback={
|
||||
<Placeholder
|
||||
icon={<IconAlertTriangle size={18} stroke={1.6} />}
|
||||
label="Failed to load this embedded page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PageEmbedBody {...props} trackOpen={trackOpen} />
|
||||
</ErrorBoundary>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function PageEmbedBody({
|
||||
editor,
|
||||
node,
|
||||
deleteNode,
|
||||
trackOpen,
|
||||
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
|
||||
const isEditable = editor.isEditable;
|
||||
const ancestry = usePageEmbedAncestry();
|
||||
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
||||
|
||||
const { result, refresh, available } = usePageEmbedLookup(sourcePageId);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
|
||||
// Evaluated before any nested editor is rendered.
|
||||
const embedState = decideEmbedState({
|
||||
sourcePageId,
|
||||
chain: ancestry.chain,
|
||||
hostPageId: ancestry.hostPageId,
|
||||
available,
|
||||
result,
|
||||
});
|
||||
|
||||
const sourceTitle =
|
||||
result && !("status" in result) ? result.title : null;
|
||||
const sourceIcon = result && !("status" in result) ? result.icon : null;
|
||||
// The app routes pages by slugId, not the raw UUID. Build the link from the
|
||||
// resolved slugId (the `/p/:pageSlug` route redirects to the full URL).
|
||||
const sourceSlugId =
|
||||
result && !("status" in result) ? result.slugId : null;
|
||||
const sourceHref = sourceSlugId
|
||||
? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined)
|
||||
: null;
|
||||
|
||||
const controls = isEditable ? (
|
||||
<div
|
||||
className={classes.includeControls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Tooltip label={t("Refresh")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
disabled={!sourcePageId}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const header =
|
||||
// Render the badge whenever the source resolves (sourceHref), not only when
|
||||
// it has a title/icon — the title link is now the single way to open the
|
||||
// source, so it must not disappear when title and icon are both empty.
|
||||
sourceTitle || sourceIcon || sourceHref ? (
|
||||
<div className={classes.transclusionBadge}>
|
||||
{sourceIcon ? `${sourceIcon} ` : <IconFileText size={12} />}
|
||||
{sourceHref ? (
|
||||
<Link
|
||||
to={sourceHref}
|
||||
style={{ borderBottom: "none", textDecoration: "none" }}
|
||||
title={t("Open source page")}
|
||||
aria-label={t("Open source page")}
|
||||
>
|
||||
{sourceTitle || t("Untitled")}
|
||||
</Link>
|
||||
) : (
|
||||
sourceTitle || t("Untitled")
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (embedState === "no_source") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("No page selected")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "cycle") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Circular embed: this page is already shown above")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "too_deep") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Embed nesting limit reached")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "unavailable") {
|
||||
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
label={t("Embedded page is not available here")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "loading") {
|
||||
body = <div style={{ minHeight: 24 }} />;
|
||||
} else if (embedState === "ok" && result && !("status" in result)) {
|
||||
body = (
|
||||
<PageEmbedAncestryProvider
|
||||
sourcePageId={sourcePageId}
|
||||
hostPageId={hostPageId}
|
||||
>
|
||||
{/*
|
||||
Tiptap's EditorProvider consumes `content` only at initial mount, so a
|
||||
changed `content` prop (e.g. after Refresh re-fetches fresh content)
|
||||
would not update the read-only sub-editor. Key on the source's
|
||||
updatedAt to remount PageEmbedContent (and its inner EditorProvider)
|
||||
whenever the source page changes, applying the refreshed content.
|
||||
*/}
|
||||
<PageEmbedContent
|
||||
key={result.sourceUpdatedAt}
|
||||
content={result.content}
|
||||
/>
|
||||
</PageEmbedAncestryProvider>
|
||||
);
|
||||
} else if (embedState === "no_access") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
label={t("You don't have access to this page")}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("The embedded page no longer exists")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls}
|
||||
{header}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSuggestionItems,
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
} from "./menu-items";
|
||||
|
||||
// Gating coverage for the workspace-level "HTML embed" slash item. The gate is
|
||||
// read from the persisted `currentUser` localStorage entry (the same payload
|
||||
// `currentUserAtom` writes). It must default to OFF, only show when the toggle
|
||||
// is explicitly true, and never throw on a broken/garbage stored value.
|
||||
|
||||
const KEY = "currentUser";
|
||||
|
||||
function setCurrentUser(value: unknown): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("isHtmlEmbedFeatureEnabled (workspace toggle gate)", () => {
|
||||
it("is OFF when no currentUser is persisted (default)", () => {
|
||||
localStorage.removeItem(KEY);
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when the toggle is absent from workspace settings", () => {
|
||||
setCurrentUser({ workspace: { settings: {} } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when the toggle is explicitly false", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is ON only when the toggle is exactly true", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw and returns false on a broken localStorage value", () => {
|
||||
// Invalid JSON: JSON.parse throws; the gate must swallow it -> false.
|
||||
localStorage.setItem(KEY, "{not valid json");
|
||||
expect(() => isHtmlEmbedFeatureEnabled()).not.toThrow();
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function hasHtmlEmbedItem(query = "html"): boolean {
|
||||
const groups = getSuggestionItems({ query });
|
||||
return Object.values(groups)
|
||||
.flat()
|
||||
.some((item) => item.title === "HTML embed");
|
||||
}
|
||||
|
||||
describe("getSuggestionItems — HTML embed item gating", () => {
|
||||
it("hides the HTML embed item when the toggle is OFF (default)", () => {
|
||||
localStorage.removeItem(KEY);
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the HTML embed item when the toggle is explicitly false", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the HTML embed item when the toggle is ON", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
|
||||
expect(hasHtmlEmbedItem()).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the item without throwing on a broken localStorage value", () => {
|
||||
localStorage.setItem(KEY, "{not valid json");
|
||||
expect(() => getSuggestionItems({ query: "html" })).not.toThrow();
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
IconArrowsMaximize,
|
||||
} from "@tabler/icons-react";
|
||||
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@@ -366,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDetails().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setFootnote().run(),
|
||||
},
|
||||
{
|
||||
title: "Callout",
|
||||
description: "Insert callout notice.",
|
||||
@@ -535,6 +546,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Embed page",
|
||||
description: "Insert a live, read-only copy of another page.",
|
||||
searchTerms: [
|
||||
"template",
|
||||
"embed",
|
||||
"embed page",
|
||||
"page",
|
||||
"live",
|
||||
"include",
|
||||
"reuse",
|
||||
],
|
||||
icon: IconArrowsMaximize,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(PAGE_EMBED_PICKER_EVENT, {
|
||||
detail: { editor, range, hostPageId },
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "2 Columns",
|
||||
description: "Split content into two columns.",
|
||||
@@ -587,6 +621,21 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.insertColumns({ layout: "five_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "HTML embed",
|
||||
description: "Embed raw HTML, CSS and JavaScript (sandboxed).",
|
||||
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
|
||||
icon: IconCode,
|
||||
requiresHtmlEmbedFeature: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHtmlEmbed({ source: "" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
@@ -744,6 +793,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the workspace-level HTML embed master toggle from the persisted
|
||||
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
|
||||
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
|
||||
* `getSuggestionItems` is a plain function (no React/atom context), so we read
|
||||
* the persisted state directly. UI gate only; an anonymous public-share read is
|
||||
* served already-stripped content by the server when the toggle is OFF.
|
||||
*/
|
||||
export function isHtmlEmbedFeatureEnabled(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem("currentUser");
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.workspace?.settings?.htmlEmbed === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const getSuggestionItems = ({
|
||||
query,
|
||||
excludeItems,
|
||||
@@ -753,6 +821,7 @@ export const getSuggestionItems = ({
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
let queryIndex = 0;
|
||||
@@ -767,6 +836,9 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
// Hide the HTML embed item unless the workspace master toggle is ON.
|
||||
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
|
||||
return false;
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
|
||||
@@ -21,6 +21,10 @@ export type SlashMenuItemType = {
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||
// When true, the item is hidden unless the workspace HTML embed master toggle
|
||||
// is ON. UI gate only — for anonymous public-share reads the server serves
|
||||
// already-stripped content when the toggle is OFF.
|
||||
requiresHtmlEmbedFeature?: boolean;
|
||||
};
|
||||
|
||||
export type SlashMenuGroupedItemsType = {
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
}
|
||||
|
||||
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
HtmlEmbed,
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
SearchAndReplace,
|
||||
@@ -60,7 +61,11 @@ import {
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
PageEmbed,
|
||||
TableView,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -87,10 +92,15 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
|
||||
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
|
||||
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
|
||||
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -230,7 +240,7 @@ export const mainExtensions = [
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
@@ -365,6 +375,13 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
// Raw HTML/CSS/JS node (Variant C). The node is registered for ALL users so
|
||||
// documents authored by admins render correctly for everyone; INSERTION is
|
||||
// gated to admins in the slash menu, and the server strips the node from any
|
||||
// non-admin write so a non-admin cannot persist it.
|
||||
HtmlEmbed.configure({
|
||||
view: HtmlEmbedView,
|
||||
}),
|
||||
TiptapPdf.configure({
|
||||
view: PdfView,
|
||||
}),
|
||||
@@ -381,6 +398,22 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
FootnoteReference.configure({
|
||||
view: FootnoteReferenceView,
|
||||
// Skip orphan-cleanup on remote/collaboration steps so collaborating
|
||||
// clients never fight over footnote integrity (deterministic numbering
|
||||
// decorations handle the rest).
|
||||
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
|
||||
}),
|
||||
FootnotesList.configure({
|
||||
view: FootnotesListView,
|
||||
}),
|
||||
FootnoteDefinition.configure({
|
||||
view: FootnoteDefinitionView,
|
||||
}),
|
||||
PageEmbed.configure({
|
||||
view: PageEmbedView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
@@ -420,7 +453,8 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block"
|
||||
"Synced block",
|
||||
"Embed page"
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
|
||||
@@ -73,6 +73,9 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
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";
|
||||
import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed/page-embed-lookup-context";
|
||||
import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
|
||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageEditorProps {
|
||||
@@ -407,6 +410,8 @@ export default function PageEditor({
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
@@ -454,6 +459,7 @@ export default function PageEditor({
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
{editor && editorIsEditable && <PageEmbedPicker />}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
@@ -461,6 +467,8 @@ export default function PageEditor({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedLookupProvider>
|
||||
</TransclusionLookupProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
const filteredExtensions = mainExtensions
|
||||
.filter((ext) => ext.name !== "uniqueID")
|
||||
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
||||
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
||||
// setContent on a doc the viewer can't edit is never rewritten.
|
||||
.map((ext) =>
|
||||
ext.name === "footnoteReference"
|
||||
? ext.configure({ enableSync: false })
|
||||
: ext,
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
ToggleTemplateResponse,
|
||||
Error,
|
||||
{ pageId: string; isTemplate?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemplate(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message || "Failed to update template",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
sourcePageIds: string[];
|
||||
}): Promise<{ items: PageTemplateLookup[] }> {
|
||||
const r = await api.post("/pages/template/lookup", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemplate(params: {
|
||||
pageId: string;
|
||||
isTemplate?: boolean;
|
||||
}): Promise<ToggleTemplateResponse> {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type PageTemplateLookup =
|
||||
| {
|
||||
sourcePageId: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
sourceUpdatedAt: string;
|
||||
}
|
||||
| { sourcePageId: string; status: "not_found" }
|
||||
| { sourcePageId: string; status: "no_access" };
|
||||
|
||||
export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
@@ -360,6 +360,16 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Idempotency guard: the server now self-echoes addTreeNode back to the
|
||||
// author, so this writer can run twice for one create (mutation onSuccess
|
||||
// + socket echo). Skip the append if the page is already in the cache to
|
||||
// avoid a duplicate node / duplicate React key.
|
||||
const exists = old.pages.some((page) =>
|
||||
page.items.some((item) => item.id === newPage.id),
|
||||
);
|
||||
if (exists) return old;
|
||||
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
|
||||
@@ -92,6 +92,14 @@ export async function getAllSidebarPages(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpaceTree(params: {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
}): Promise<IPage[]> {
|
||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||
return req.data.items;
|
||||
}
|
||||
|
||||
export async function getPageBreadcrumbs(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
|
||||
@@ -16,6 +16,11 @@ import { treeModel } from '../model/tree-model';
|
||||
import { DocTreeRow } from './doc-tree-row';
|
||||
import styles from '../styles/tree.module.css';
|
||||
|
||||
// Page-tree row heights. STANDARD is the safe default density; COMPACT is the
|
||||
// denser layout gated behind the COMPACT_PAGE_TREE feature flag.
|
||||
export const ROW_HEIGHT_STANDARD = 32;
|
||||
export const ROW_HEIGHT_COMPACT = 26;
|
||||
|
||||
export type RenderRowProps<T extends object> = {
|
||||
node: TreeNode<T>;
|
||||
level: number;
|
||||
@@ -122,11 +127,11 @@ function DocTreeInner<T extends object>(
|
||||
selectedId,
|
||||
renderRow,
|
||||
indentPerLevel = 8,
|
||||
// Compact vertical density: each virtualized row occupies exactly this
|
||||
// many px (the virtualizer stride). Row content is ~22px (18px icon /
|
||||
// 14px text / 20px action icons), so 26px keeps a small, even gap between
|
||||
// nodes without clipping. Lower => denser tree.
|
||||
rowHeight = 26,
|
||||
// Each virtualized row occupies exactly this many px (the virtualizer
|
||||
// stride). Default is standard density (32px); the denser compact layout
|
||||
// (26px) is opt-in and driven by the COMPACT_PAGE_TREE feature flag in
|
||||
// consumers. Lower => denser tree.
|
||||
rowHeight = ROW_HEIGHT_STANDARD,
|
||||
onMove,
|
||||
onToggle,
|
||||
onSelect,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconLink,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTemplate,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
@@ -63,6 +65,26 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const addFavorite = useAddFavoriteMutation();
|
||||
const removeFavorite = useRemoveFavoriteMutation();
|
||||
const isFavorited = favoriteIds.has(node.id);
|
||||
const toggleTemplate = useToggleTemplateMutation();
|
||||
const isTemplate = !!node.isTemplate;
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
try {
|
||||
await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next });
|
||||
// Reflect the new flag locally so the menu label updates immediately.
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, node.id, { isTemplate: next } as any),
|
||||
);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Page marked as template")
|
||||
: t("Page is no longer a template"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -217,6 +239,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconTemplate size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleTemplate();
|
||||
}}
|
||||
>
|
||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useRef } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, rem } from "@mantine/core";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconTemplate,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
@@ -171,6 +172,25 @@ export function SpaceTreeRow({
|
||||
|
||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||
|
||||
{node.isTemplate === true && (
|
||||
<Tooltip label={t("Template")} withArrow>
|
||||
<IconTemplate
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
// Visual-only indicator: subtle and never shrinks. Pointer events
|
||||
// stay enabled so the Tooltip's hover handlers fire; clicks fall
|
||||
// through to the row link since no stopPropagation is used.
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginLeft: rem(4),
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
}}
|
||||
aria-label={t("Template")}
|
||||
role="img"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
|
||||
// child render components. The expandAll contract is exercised purely through
|
||||
// the imperative ref, so we mock everything that would otherwise need a real
|
||||
// server / router and stub the visual children to empty renders.
|
||||
|
||||
const getSpaceTreeMock = vi.fn();
|
||||
const notificationsShowMock = vi.fn();
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the data-load effect is inert so the
|
||||
// test fully controls the tree through expandAll.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: () => 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,
|
||||
}));
|
||||
|
||||
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
|
||||
// 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.
|
||||
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);
|
||||
},
|
||||
);
|
||||
return { openTreeNodesAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import { treeDataAtom } 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";
|
||||
|
||||
// A flat space-tree response (parentPageId pointers) that buildTree +
|
||||
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
|
||||
// expandAll never fans out into per-branch fetches (no N+1).
|
||||
function spaceTreeItems(): SpaceTreeNode[] {
|
||||
const n = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
});
|
||||
return [
|
||||
n("root", null, "a0"),
|
||||
n("branch", "root", "a1"),
|
||||
n("leaf", "branch", "a1"),
|
||||
];
|
||||
}
|
||||
|
||||
function renderTree(store: ReturnType<typeof createStore>) {
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// 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 {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* ignore — fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree.expandAll (integration via ref)", () => {
|
||||
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
|
||||
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
|
||||
|
||||
// Every branch node (root, branch) is opened; the leaf needs no entry.
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBe(true);
|
||||
expect(openMap["branch"]).toBe(true);
|
||||
expect(openMap["leaf"]).toBeUndefined();
|
||||
|
||||
// The full tree replaced the current-space nodes.
|
||||
const data = store.get(treeDataAtom);
|
||||
expect(data.map((d) => d.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
|
||||
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
|
||||
// isExpanding must be reset in the finally block even on failure.
|
||||
await waitFor(() => {
|
||||
expect(ref.current!.isExpanding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the merge when the space switches mid-flight", async () => {
|
||||
// getSpaceTree resolves only after we flip the tree to a different space,
|
||||
// simulating the user navigating away while the request is in flight.
|
||||
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
|
||||
getSpaceTreeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<SpaceTreeNode[]>((resolve) => {
|
||||
resolveTree = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const promise = ref.current!.expandAll();
|
||||
|
||||
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Now resolve the in-flight request for the OLD space.
|
||||
resolveTree(spaceTreeItems());
|
||||
await promise;
|
||||
|
||||
// The merge must have been aborted: no tree data written, no branches opened.
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBeUndefined();
|
||||
expect(openMap["branch"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
@@ -16,13 +25,25 @@ import {
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
mergeRootTrees,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
getPageBreadcrumbs,
|
||||
getSpaceTree,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { DocTree } from "./doc-tree";
|
||||
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
|
||||
import {
|
||||
DocTree,
|
||||
ROW_HEIGHT_COMPACT,
|
||||
ROW_HEIGHT_STANDARD,
|
||||
} from "./doc-tree";
|
||||
import { SpaceTreeRow } from "./space-tree-row";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
@@ -30,10 +51,21 @@ interface SpaceTreeProps {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
export type SpaceTreeApi = {
|
||||
expandAll: () => Promise<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
{ spaceId, readOnly },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const compactTree = isCompactPageTreeEnabled();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const [isExpanding, setIsExpanding] = useState(false);
|
||||
const { handleMove } = useTreeMutation(spaceId);
|
||||
const {
|
||||
data: pagesData,
|
||||
@@ -186,6 +218,56 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
[data, spaceId],
|
||||
);
|
||||
|
||||
const expandAll = useCallback(async () => {
|
||||
const startSpaceId = spaceIdRef.current;
|
||||
setIsExpanding(true);
|
||||
try {
|
||||
// One request: the entire space tree, permission-filtered server-side.
|
||||
const items = await getSpaceTree({ spaceId: startSpaceId });
|
||||
// Space switched mid-flight — abort merge/expand.
|
||||
if (spaceIdRef.current !== startSpaceId) return;
|
||||
|
||||
const fullTree = buildTreeWithChildren(buildTree(items));
|
||||
|
||||
setData((prev) => {
|
||||
// Replace current-space nodes with the full tree; keep other spaces intact.
|
||||
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
|
||||
return [...others, ...fullTree];
|
||||
});
|
||||
|
||||
// Open every branch node (node with children) of the current space only.
|
||||
const branchIds = collectBranchIds(fullTree);
|
||||
|
||||
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
|
||||
} catch (err: any) {
|
||||
// Never swallow: log full error + surface the real reason.
|
||||
console.error("[tree] expandAll failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Couldn't expand the tree: {{reason}}", {
|
||||
reason:
|
||||
err?.response?.data?.message ?? err?.message ?? String(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
}, [setData, setOpenTreeNodes, t]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
// The open-map is shared across spaces; collapse only current-space ids so
|
||||
// other spaces' expanded state is left intact.
|
||||
const ids = collectAllIds(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => closeIds(prev, ids));
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({ expandAll, collapseAll, isExpanding }),
|
||||
[expandAll, collapseAll, isExpanding],
|
||||
);
|
||||
|
||||
// Stable callbacks for DocTree. Without these, every parent render recreates
|
||||
// the props and tears down every row's draggable/dropTarget subscription,
|
||||
// defeating memo(DocTreeRow).
|
||||
@@ -219,6 +301,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
renderRow={renderRow}
|
||||
onMove={handleMove}
|
||||
onToggle={handleToggle}
|
||||
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
|
||||
readOnly={readOnly}
|
||||
disableDrag={disableDragDrop}
|
||||
disableDrop={disableDragDrop}
|
||||
@@ -228,4 +311,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default SpaceTree;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -41,12 +40,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (sourceId: string, op: DropOp) => {
|
||||
const before = store.get(treeDataAtom);
|
||||
const { tree: after, result } = treeModel.move(before, sourceId, op);
|
||||
const { tree: after } = treeModel.move(before, sourceId, op);
|
||||
if (after === before) return;
|
||||
|
||||
const payload = dropOpToMovePayload(before, sourceId, op);
|
||||
@@ -112,22 +110,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
pageData,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
id: sourceId,
|
||||
parentId: payload.parentPageId,
|
||||
oldParentId,
|
||||
index: result.index,
|
||||
position: payload.position,
|
||||
pageData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `moveTreeNode` to the space room on PAGE_MOVED. The old client relay
|
||||
// (emit + setTimeout(50)) was removed; the optimistic local update above
|
||||
// stays for instant feedback to the author.
|
||||
},
|
||||
[setData, store, movePageMutation, spaceId, emit, t],
|
||||
[setData, store, movePageMutation, spaceId, t],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
@@ -166,20 +154,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
lastIndex = parent?.children?.length ?? 0;
|
||||
}
|
||||
|
||||
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex));
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data: newNode,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
// Idempotent by id: the tree is server-authoritative and the server's
|
||||
// `addTreeNode` broadcast (now ~ms over same-origin) can win the race and
|
||||
// insert this node before this optimistic update runs. Inserting again
|
||||
// un-guarded would duplicate the row in the author's sidebar. Mirror the
|
||||
// `addTreeNode` socket guard: skip when the node already exists. The
|
||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||
// the ids match exactly regardless of which path runs first.
|
||||
setData((prev) => {
|
||||
if (treeModel.find(prev, newNode.id)) return prev;
|
||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||
});
|
||||
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `addTreeNode` to the space room on PAGE_CREATED. The old client relay
|
||||
// (emit + setTimeout(50)) was removed; the optimistic insert above stays
|
||||
// for instant feedback to the author (the server event is idempotent and
|
||||
// a no-op for the author whose node already exists).
|
||||
const pageUrl = buildPageUrl(
|
||||
spaceSlug,
|
||||
createdPage.slugId,
|
||||
@@ -187,7 +178,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
navigate(pageUrl);
|
||||
},
|
||||
[spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug],
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -238,19 +229,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!node) return;
|
||||
emit({
|
||||
operation: "deleteTreeNode",
|
||||
spaceId,
|
||||
payload: { node },
|
||||
});
|
||||
}, 50);
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `deleteTreeNode` to the space room on PAGE_SOFT_DELETED. The old
|
||||
// client relay (emit + setTimeout(50)) was removed; the optimistic
|
||||
// removal above stays for instant feedback to the author.
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page:", error);
|
||||
}
|
||||
},
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId],
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
|
||||
@@ -128,6 +128,271 @@ describe('treeModel.insert', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.insertByPosition', () => {
|
||||
// Server-authoritative broadcasts ship the node's fractional `position`; the
|
||||
// receiver inserts among already-loaded siblings ordered by `position`.
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const roots: P[] = [
|
||||
{ id: 'a', name: 'A', position: 'a0' },
|
||||
{ id: 'b', name: 'B', position: 'a2' },
|
||||
{ id: 'c', name: 'C', position: 'a4' },
|
||||
];
|
||||
|
||||
it('inserts a root node in position order (middle)', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a3' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
|
||||
});
|
||||
|
||||
it('inserts a root node at the front when its position sorts first', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a-' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['x', 'a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('appends a root node when its position sorts last', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a9' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
|
||||
it('produces the same order regardless of which siblings are loaded', () => {
|
||||
// Client 1 loaded all siblings; client 2 only loaded a subset. The inserted
|
||||
// node lands in a consistent relative position for both.
|
||||
const full: P[] = roots;
|
||||
const partial: P[] = [roots[0], roots[2]]; // a, c (b not loaded)
|
||||
const node: P = { id: 'x', name: 'X', position: 'a3' };
|
||||
|
||||
expect(
|
||||
treeModel.insertByPosition(full, null, node).map((n) => n.id),
|
||||
).toEqual(['a', 'b', 'x', 'c']);
|
||||
expect(
|
||||
treeModel.insertByPosition(partial, null, node).map((n) => n.id),
|
||||
).toEqual(['a', 'x', 'c']);
|
||||
});
|
||||
|
||||
it('inserts a child in position order under the parent', () => {
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'p1', name: 'P1', position: 'a0' },
|
||||
{ id: 'p2', name: 'P2', position: 'a2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const node: P = { id: 'p15', name: 'P1.5', position: 'a1' };
|
||||
const t = treeModel.insertByPosition(tree, 'p', node);
|
||||
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
|
||||
'p1', 'p15', 'p2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends when the new node has no position', () => {
|
||||
const node: P = { id: 'x', name: 'X' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
|
||||
it('tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)', () => {
|
||||
// The insertion index is the first sibling whose position sorts STRICTLY
|
||||
// after the new node's. An equal sibling is not strictly after, so it is
|
||||
// skipped — the new node lands immediately AFTER every equal-position
|
||||
// sibling and before the first strictly-greater one. This is deterministic:
|
||||
// a tie always resolves the same way on every client.
|
||||
const node: P = { id: 'x', name: 'X', position: 'a2' }; // equals b's position
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
// addTreeNode idempotency: the receiver early-returns when the node id already
|
||||
// exists, so re-delivery (or the author's optimistic node) is never duplicated.
|
||||
// This guards the find-then-skip contract insertByPosition relies on.
|
||||
describe('addTreeNode idempotency (find-then-skip)', () => {
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const applyAddTreeNode = (tree: P[], node: P): P[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insertByPosition(tree, null, node);
|
||||
};
|
||||
|
||||
it('does not insert a duplicate when the id already exists', () => {
|
||||
const tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
|
||||
const node: P = { id: 'a', name: 'A again', position: 'a5' };
|
||||
const t1 = applyAddTreeNode(tree, node);
|
||||
expect(t1).toBe(tree);
|
||||
expect(t1.map((n) => n.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('inserts once, then is a no-op on repeat delivery', () => {
|
||||
let tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
|
||||
const node: P = { id: 'x', name: 'X', position: 'a5' };
|
||||
tree = applyAddTreeNode(tree, node);
|
||||
expect(tree.map((n) => n.id)).toEqual(['a', 'x']);
|
||||
const again = applyAddTreeNode(tree, node);
|
||||
expect(again).toBe(tree);
|
||||
expect(again.filter((n) => n.id === 'x')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// handleCreate optimistic-insert idempotency: the author's optimistic insert is
|
||||
// now guarded by `treeModel.find` (same contract as the addTreeNode socket
|
||||
// handler) because the server's broadcast can win the race and insert the node
|
||||
// first. Whichever runs first inserts; the second is a no-op. Exactly one row.
|
||||
describe('handleCreate optimistic-insert idempotency (find-then-skip)', () => {
|
||||
// Mirrors the guarded optimistic insert in use-tree-mutation handleCreate.
|
||||
const applyOptimisticInsert = (
|
||||
tree: N[],
|
||||
parentId: string | null,
|
||||
node: N,
|
||||
index: number,
|
||||
): N[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insert(tree, parentId, node, index);
|
||||
};
|
||||
|
||||
// Mirrors the addTreeNode socket handler guard.
|
||||
const applyAddTreeNode = (tree: N[], parentId: string | null, node: N): N[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insert(tree, parentId, node);
|
||||
};
|
||||
|
||||
const created: N = { id: 'new', name: '' };
|
||||
|
||||
it('optimistic insert is a no-op when server addTreeNode already inserted it', () => {
|
||||
// Reverse-of-reverse race: server wins.
|
||||
const afterServer = applyAddTreeNode(fixture, null, created);
|
||||
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
const afterOptimistic = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
expect(afterOptimistic).toBe(afterServer); // skipped
|
||||
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('server addTreeNode is a no-op when optimistic insert already ran (optimistic-first)', () => {
|
||||
const afterOptimistic = applyOptimisticInsert(fixture, null, created, fixture.length);
|
||||
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
const afterServer = applyAddTreeNode(afterOptimistic, null, created);
|
||||
expect(afterServer).toBe(afterOptimistic); // skipped
|
||||
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('inserts exactly once when only the optimistic path runs', () => {
|
||||
const t = applyOptimisticInsert(fixture, 'a', { id: 'a3', name: '' }, 2);
|
||||
expect(treeModel.find(t, 'a')?.children?.filter((n) => n.id === 'a3')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||
// use-tree-socket.ts so the contract is unit-tested without rendering the hook.
|
||||
describe('moveTreeNode handler (place by position + apply pageData)', () => {
|
||||
type P = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
icon?: string;
|
||||
hasChildren?: boolean;
|
||||
parentPageId?: string | null;
|
||||
}>;
|
||||
|
||||
const applyMoveTreeNode = (
|
||||
tree: P[],
|
||||
payload: {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
position: string;
|
||||
pageData?: { title?: string | null; icon?: string | null; hasChildren?: boolean };
|
||||
},
|
||||
): P[] => {
|
||||
if (!treeModel.find(tree, payload.id)) return tree;
|
||||
const placed = treeModel.placeByPosition(tree, payload.id, {
|
||||
parentId: payload.parentId,
|
||||
position: payload.position,
|
||||
});
|
||||
if (placed === tree) return treeModel.remove(tree, payload.id);
|
||||
const patch: Partial<P> = {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentId,
|
||||
} as Partial<P>;
|
||||
const pd = payload.pageData;
|
||||
if (pd) {
|
||||
if (pd.title !== undefined) (patch as { name?: string }).name = pd.title ?? '';
|
||||
if (pd.icon !== undefined) (patch as { icon?: string }).icon = pd.icon ?? undefined;
|
||||
if (pd.hasChildren !== undefined)
|
||||
(patch as { hasChildren?: boolean }).hasChildren = pd.hasChildren;
|
||||
}
|
||||
return treeModel.update(placed, payload.id, patch);
|
||||
};
|
||||
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'dst',
|
||||
name: 'DST',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'c1', name: 'C1', position: 'a1' },
|
||||
{ id: 'c2', name: 'C2', position: 'a3' },
|
||||
{ id: 'c3', name: 'C3', position: 'a5' },
|
||||
],
|
||||
},
|
||||
{ id: 'src', name: 'SRC', position: 'a9' },
|
||||
];
|
||||
|
||||
it('lands the moved node in the correct MIDDLE slot, not at index 0', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'src', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands the moved node at the END when position sorts last', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a8',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'c3', 'src',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies pageData (title/icon/hasChildren) to the moved node', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
pageData: { title: 'Renamed', icon: '🔥', hasChildren: true },
|
||||
});
|
||||
const moved = treeModel.find(t, 'src');
|
||||
expect(moved?.name).toBe('Renamed');
|
||||
expect(moved?.icon).toBe('🔥');
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe('a4');
|
||||
});
|
||||
|
||||
it('falls back to removing the node when the destination parent is not loaded', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'not-loaded',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'src')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.remove', () => {
|
||||
it('removes a leaf', () => {
|
||||
const t = treeModel.remove(fixture, 'a2');
|
||||
@@ -240,6 +505,118 @@ describe('treeModel.place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.placeByPosition', () => {
|
||||
// Server-authoritative `moveTreeNode` ships the moved node's fractional
|
||||
// `position`; the receiver must sort it into the correct slot among the new
|
||||
// siblings — NOT drop it at index 0.
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'dst',
|
||||
name: 'DST',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'c1', name: 'C1', position: 'a1' },
|
||||
{ id: 'c2', name: 'C2', position: 'a3' },
|
||||
{ id: 'c3', name: 'C3', position: 'a5' },
|
||||
],
|
||||
},
|
||||
{ id: 'src', name: 'SRC', position: 'a9' },
|
||||
];
|
||||
|
||||
it('places the moved node in the MIDDLE of new siblings by position', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'src', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the moved node at the END when its position sorts last', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a8',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'c3', 'src',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the moved node at the FRONT only when its position sorts first', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a0',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'src', 'c1', 'c2', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stamps the authoritative position onto the moved node', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'src')?.position).toBe('a4');
|
||||
});
|
||||
|
||||
it('reorders within the same parent by position (not to index 0)', () => {
|
||||
const same: P[] = [
|
||||
{
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'x', name: 'X', position: 'a1' },
|
||||
{ id: 'y', name: 'Y', position: 'a2' },
|
||||
{ id: 'z', name: 'Z', position: 'a3' },
|
||||
],
|
||||
},
|
||||
];
|
||||
// Move x to between y and z.
|
||||
const t = treeModel.placeByPosition(same, 'x', {
|
||||
parentId: 'p',
|
||||
position: 'a25',
|
||||
});
|
||||
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
|
||||
'y', 'x', 'z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns same array reference for unknown source', () => {
|
||||
expect(
|
||||
treeModel.placeByPosition(tree, 'ghost', { parentId: 'dst', position: 'a4' }),
|
||||
).toBe(tree);
|
||||
});
|
||||
|
||||
it('returns same array reference when destination parent is not loaded', () => {
|
||||
expect(
|
||||
treeModel.placeByPosition(tree, 'src', { parentId: 'ghost', position: 'a4' }),
|
||||
).toBe(tree);
|
||||
});
|
||||
|
||||
it('moves a node to root by position', () => {
|
||||
const roots: P[] = [
|
||||
{ id: 'r1', name: 'R1', position: 'a1' },
|
||||
{ id: 'r2', name: 'R2', position: 'a5' },
|
||||
{
|
||||
id: 'rp',
|
||||
name: 'RP',
|
||||
position: 'a7',
|
||||
children: [{ id: 'child', name: 'CHILD', position: 'a1' }],
|
||||
},
|
||||
];
|
||||
const t = treeModel.placeByPosition(roots, 'child', {
|
||||
parentId: null,
|
||||
position: 'a3',
|
||||
});
|
||||
expect(t.map((n) => n.id)).toEqual(['r1', 'child', 'r2', 'rp']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.move', () => {
|
||||
it('reorder-before within same parent: moves source to target index', () => {
|
||||
const { tree: t, result } = treeModel.move(fixture, 'a2', {
|
||||
@@ -326,4 +703,45 @@ describe('treeModel.move', () => {
|
||||
});
|
||||
expect(out.tree).toBe(fixture);
|
||||
});
|
||||
|
||||
it('cross-parent move does NOT apply the same-parent adjust (no off-by-one)', () => {
|
||||
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
|
||||
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
|
||||
// so the `sameParent && source.index < info.index` adjust must be 0 — the
|
||||
// node must land at index 0 in `y`, not at index -1 (which would silently
|
||||
// drop it at a wrong slot / off-by-one).
|
||||
const crossFixture: N[] = [
|
||||
{
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
children: [
|
||||
{ id: 'x1', name: 'X1' },
|
||||
{ id: 'x2', name: 'X2' },
|
||||
{ id: 'x3', name: 'X3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'y',
|
||||
name: 'Y',
|
||||
children: [
|
||||
{ id: 'y1', name: 'Y1' },
|
||||
{ id: 'y2', name: 'Y2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { tree: t, result } = treeModel.move(crossFixture, 'x3', {
|
||||
kind: 'reorder-before',
|
||||
targetId: 'y1',
|
||||
});
|
||||
expect(result).toEqual({ parentId: 'y', index: 0 });
|
||||
expect(treeModel.find(t, 'y')?.children?.map((n) => n.id)).toEqual([
|
||||
'x3',
|
||||
'y1',
|
||||
'y2',
|
||||
]);
|
||||
expect(treeModel.find(t, 'x')?.children?.map((n) => n.id)).toEqual([
|
||||
'x1',
|
||||
'x2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,35 @@ export const treeModel = {
|
||||
return touched ? out : tree;
|
||||
},
|
||||
|
||||
// Position-aware insert for server-authoritative broadcasts. The server does
|
||||
// not know each receiver's local index (clients have different loaded sets and
|
||||
// the root list is paginated), so it sends the node's fractional `position`.
|
||||
// We insert among the already-loaded siblings ordered by `position` so the
|
||||
// order is consistent across clients regardless of which nodes they loaded.
|
||||
// Falls back to appending when `position` is missing.
|
||||
insertByPosition<T extends { position?: string }>(
|
||||
tree: TreeNode<T>[],
|
||||
parentId: string | null,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T>[] {
|
||||
const index = (siblings: TreeNode<T>[]): number => {
|
||||
const pos = node.position;
|
||||
if (pos == null) return siblings.length;
|
||||
// First sibling whose position sorts after the new node's position.
|
||||
const at = siblings.findIndex(
|
||||
(s) => s.position != null && s.position > pos,
|
||||
);
|
||||
return at === -1 ? siblings.length : at;
|
||||
};
|
||||
|
||||
if (parentId === null) {
|
||||
return treeModel.insert(tree, null, node, index(tree));
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
|
||||
return treeModel.insert(tree, parentId, node, index(kids));
|
||||
},
|
||||
|
||||
remove<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] {
|
||||
let touched = false;
|
||||
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] => {
|
||||
@@ -186,6 +215,30 @@ export const treeModel = {
|
||||
return treeModel.insert(removed, to.parentId, source, to.index);
|
||||
},
|
||||
|
||||
// Position-aware move for server-authoritative `moveTreeNode` broadcasts. Like
|
||||
// `place`, but instead of an absolute index (which the sender computed against
|
||||
// its own loaded set), it inserts the moved node among the destination's
|
||||
// already-loaded siblings ordered by the node's fractional `position`. This
|
||||
// keeps the visible order correct for every receiver — `place(..., index: 0)`
|
||||
// would wrongly drop the node at the TOP of its new sibling list.
|
||||
// Returns the same array reference (like `place`) when the source is missing
|
||||
// or the destination parent isn't loaded on this client, so callers can detect
|
||||
// that and fall back to removing the node.
|
||||
placeByPosition<T extends { position?: string }>(
|
||||
tree: TreeNode<T>[],
|
||||
sourceId: string,
|
||||
to: { parentId: string | null; position?: string },
|
||||
): TreeNode<T>[] {
|
||||
const source = treeModel.find(tree, sourceId);
|
||||
if (!source) return tree;
|
||||
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
|
||||
const removed = treeModel.remove(tree, sourceId);
|
||||
// Reuse the same position-ordered insertion as `insertByPosition` by
|
||||
// stamping the authoritative position onto the moved node first.
|
||||
const positioned = { ...source, position: to.position } as TreeNode<T>;
|
||||
return treeModel.insertByPosition(removed, to.parentId, positioned);
|
||||
},
|
||||
|
||||
move<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
sourceId: string,
|
||||
|
||||
@@ -8,5 +8,6 @@ export type SpaceTreeNode = {
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
isTemplate?: boolean;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
275
apps/client/src/features/page/tree/utils/utils.test.ts
Normal file
275
apps/client/src/features/page/tree/utils/utils.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function page(id: string, position: string): IPage {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: id.toUpperCase(),
|
||||
icon: "",
|
||||
position,
|
||||
hasChildren: false,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
// Flat SpaceTreeNode factory for buildTreeWithChildren (it consumes a flat list
|
||||
// with parentPageId pointers and nests them).
|
||||
function flatNode(
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
|
||||
function treeNode(
|
||||
id: string,
|
||||
children: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: children.length > 0,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
expect(tree.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("dedups a duplicate id so the tree has no duplicate node", () => {
|
||||
// A realtime cache write could append a page twice; buildTree must not emit
|
||||
// two references to the same node (which would crash the sidebar render with
|
||||
// a duplicate React key).
|
||||
const tree = buildTree([
|
||||
page("a", "a1"),
|
||||
page("b", "a2"),
|
||||
page("a", "a1"), // duplicate id
|
||||
]);
|
||||
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree.map((n) => n.id).sort()).toEqual(["a", "b"]);
|
||||
// No id appears more than once.
|
||||
const ids = tree.map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectBranchIds", () => {
|
||||
it("returns every node-with-children id in a multi-level tree", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf3")]),
|
||||
];
|
||||
expect(collectBranchIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a leaf-only tree", () => {
|
||||
const tree = [treeNode("a"), treeNode("b"), treeNode("c")];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT include a node whose children is an empty array", () => {
|
||||
// hasChildren-less / empty-children nodes are leaves for expansion purposes.
|
||||
const tree = [treeNode("a", [])];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns every ancestor id in a deep single chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
// a, b, c are branches; d is the leaf.
|
||||
expect(collectBranchIds([chain])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectBranchIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectAllIds", () => {
|
||||
it("returns every id (roots, branches, leaves)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2"),
|
||||
];
|
||||
expect(collectAllIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"leaf1",
|
||||
"leaf2",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns every id in a deep chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
expect(collectAllIds([chain])).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectAllIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a superset of collectBranchIds for the same tree (property)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1"), treeNode("leaf2")]),
|
||||
treeNode("branch2", [treeNode("leaf3")]),
|
||||
treeNode("leaf4"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf5")]),
|
||||
];
|
||||
const all = new Set(collectAllIds(tree));
|
||||
const branches = collectBranchIds(tree);
|
||||
for (const id of branches) {
|
||||
expect(all.has(id)).toBe(true);
|
||||
}
|
||||
// And the superset is strictly larger (it also has the leaves).
|
||||
expect(all.size).toBeGreaterThan(branches.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTreeWithChildren", () => {
|
||||
it("nests a flat list and sorts siblings by position", () => {
|
||||
// Provided out of position order to prove the sort.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("c2", "root", "a4"),
|
||||
flatNode("c1", "root", "a1"),
|
||||
];
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree.map((n) => n.id)).toEqual(["root"]);
|
||||
expect(tree[0].children.map((n) => n.id)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("recomputes hasChildren to true for nodes that gain children", () => {
|
||||
// Parent ships with hasChildren=false; building must flip it true.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("child", "root", "a1"),
|
||||
];
|
||||
expect(flat[0].hasChildren).toBe(false);
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree[0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a node whose parentPageId is ABSENT from the list as a root (no crash)", () => {
|
||||
// Permission-trimmed response: `orphan`'s parent `missing` was filtered out
|
||||
// server-side. The function must not throw and must surface the orphan as a
|
||||
// root rather than dropping or crashing on it.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("orphan", "missing", "a2"),
|
||||
];
|
||||
let tree: SpaceTreeNode[] = [];
|
||||
expect(() => {
|
||||
tree = buildTreeWithChildren(flat);
|
||||
}).not.toThrow();
|
||||
expect(tree.map((n) => n.id).sort()).toEqual(["orphan", "root"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBranches", () => {
|
||||
it("sets all given ids to true", () => {
|
||||
const next = openBranches({}, ["a", "b", "c"]);
|
||||
expect(next).toEqual({ a: true, b: true, c: true });
|
||||
});
|
||||
|
||||
it("preserves pre-existing open ids and other-space ids", () => {
|
||||
const prev = { existing: true, "other-space": true, closed: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(next).toEqual({
|
||||
existing: true,
|
||||
"other-space": true,
|
||||
closed: false,
|
||||
a: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: false });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = openBranches({ z: true }, ["a", "b"]);
|
||||
const twice = openBranches(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeIds", () => {
|
||||
it("flips current-space ids to false while leaving OTHER-space ids untouched", () => {
|
||||
const prev = {
|
||||
"current-1": true,
|
||||
"current-2": true,
|
||||
"other-space": true,
|
||||
};
|
||||
const next = closeIds(prev, ["current-1", "current-2"]);
|
||||
expect(next).toEqual({
|
||||
"current-1": false,
|
||||
"current-2": false,
|
||||
"other-space": true, // untouched
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: true };
|
||||
const next = closeIds(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: true });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = closeIds({ keep: true }, ["a", "b"]);
|
||||
const twice = closeIds(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
expect(twice).toEqual({ keep: true, a: false, b: false });
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,19 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||
// same node, producing a duplicate React key that crashes the sidebar render.
|
||||
// Track ids we've already pushed and skip repeats so a stray duplicate from a
|
||||
// realtime cache write can never break the tree.
|
||||
const seen = new Set<string>();
|
||||
pages.forEach((page) => {
|
||||
if (seen.has(page.id)) return;
|
||||
seen.add(page.id);
|
||||
tree.push(pageMap[page.id]);
|
||||
});
|
||||
|
||||
@@ -134,11 +142,17 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
// Build the tree array
|
||||
items.forEach((item) => {
|
||||
const node = nodeMap[item.id];
|
||||
if (item.parentPageId !== null) {
|
||||
// A permission-trimmed response can include a node whose `parentPageId` is
|
||||
// not in the list (the parent was filtered out server-side). Treat such an
|
||||
// orphan as a root instead of dereferencing an absent parent and throwing
|
||||
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
|
||||
// a node whose parent IS present still nests under it.
|
||||
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
|
||||
// Find the parent node and add the current node to its children
|
||||
nodeMap[item.parentPageId].children.push(node);
|
||||
} else {
|
||||
// If the item has no parent, it's a root node, so add it to the result array
|
||||
// If the item has no parent (or its parent isn't loaded), it's a root
|
||||
// node, so add it to the result array.
|
||||
result.push(node);
|
||||
}
|
||||
});
|
||||
@@ -216,3 +230,60 @@ export function mergeRootTrees(
|
||||
|
||||
return sortPositionKeys(merged);
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: SpaceTreeNode[]) => {
|
||||
for (const n of list) {
|
||||
ids.push(n.id);
|
||||
if (n.children?.length) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
|
||||
// open every branch in the open-state map; leaves need no entry.
|
||||
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: SpaceTreeNode[]) => {
|
||||
for (const n of list) {
|
||||
if (n.children?.length) {
|
||||
ids.push(n.id);
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
|
||||
// next-map helpers for expand/collapse so the merge logic can be unit-tested
|
||||
// without rendering SpaceTree. Both return a fresh map and never mutate the
|
||||
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
|
||||
|
||||
// Set each id in `ids` to true (open). Pre-existing entries (including other
|
||||
// spaces' open state) are preserved.
|
||||
export function openBranches(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
|
||||
// spaces' ids) are left exactly as they were.
|
||||
export function closeIds(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IPage {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isTemplate?: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SearchSuggestionParams {
|
||||
includeUsers?: boolean;
|
||||
includeGroups?: boolean;
|
||||
includePages?: boolean;
|
||||
onlyTemplates?: boolean;
|
||||
spaceId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
226
apps/client/src/features/share/components/share-ai-widget.tsx
Normal file
226
apps/client/src/features/share/components/share-ai-widget.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import {
|
||||
ActionIcon,
|
||||
Affix,
|
||||
Alert,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowUp,
|
||||
IconSparkles,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
|
||||
interface ShareAiWidgetProps {
|
||||
/** The share id (or key) the assistant is scoped to. */
|
||||
shareId: string;
|
||||
/** The page the reader currently has open (context for "this page"). */
|
||||
pageId: string;
|
||||
/** Display name of the configured assistant identity; falls back to 'AI agent' when absent. */
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
|
||||
*
|
||||
* A stripped version of the authenticated chat: text input only, no chat list,
|
||||
* no history, no persistence, no voice input. The transcript lives only in
|
||||
* memory (this component's `useChat` store) and is sent with `credentials:
|
||||
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
|
||||
* nothing.
|
||||
*
|
||||
* Presentation is now shared with the internal chat: the same `MessageList`
|
||||
* renders the streamed transcript, so the public share gets the SAME
|
||||
* incremental markdown render, animated typing indicator, and tool-call cards
|
||||
* as the internal chat. Only the anonymous specifics differ — no auth, no
|
||||
* history, `credentials: "omit"`, suppressed page citations (an anonymous
|
||||
* reader cannot open the linked internal pages), neutralized internal markdown
|
||||
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
|
||||
* clickable links), and a documentation-focused empty state.
|
||||
*/
|
||||
export default function ShareAiWidget({
|
||||
shareId,
|
||||
pageId,
|
||||
assistantName,
|
||||
}: ShareAiWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// Stable per-mount store key (see ai-chat ChatThread for the rationale on why
|
||||
// useChat needs a stable, non-undefined id to avoid re-creating its store).
|
||||
const storeIdRef = useRef<string>(`share-ai-${generateId()}`);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/shares/ai/stream",
|
||||
// Anonymous endpoint: never send cookies/credentials.
|
||||
credentials: "omit",
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
shareId,
|
||||
pageId,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[shareId, pageId],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
id: storeIdRef.current,
|
||||
transport,
|
||||
});
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInput("");
|
||||
void sendMessage({ text });
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
|
||||
// "Powered by Gitmost" branding button (share-branding.tsx) without
|
||||
// overlapping it.
|
||||
<Affix position={{ bottom: 80, right: 20 }}>
|
||||
<Tooltip label={t("Ask AI")} position="left">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
aria-label={t("Ask AI")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<IconSparkles size={22} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Affix position={{ bottom: 80, right: 20 }}>
|
||||
<Paper
|
||||
shadow="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
width: 360,
|
||||
maxWidth: "calc(100vw - 40px)",
|
||||
height: 480,
|
||||
maxHeight: "calc(100vh - 100px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="xs"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("Ask AI")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Close")}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Shared transcript: same incremental streaming render, animated typing
|
||||
indicator, markdown, and tool-call cards as the internal chat. The
|
||||
share is anonymous, so page citation links are suppressed (an
|
||||
anonymous reader cannot open the linked internal pages). */}
|
||||
<Box style={{ flex: 1, minHeight: 0, display: "flex", padding: "var(--mantine-spacing-sm)" }}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
assistantName={assistantName}
|
||||
showCitations={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
neutralizeInternalLinks={true}
|
||||
emptyState={
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask a question about this documentation.")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mx="sm"
|
||||
mb="xs"
|
||||
title={t("Something went wrong")}
|
||||
>
|
||||
{/* Surface the real cause (provider/gating message) instead of a
|
||||
generic line — same helper the internal chat uses. */}
|
||||
{describeChatError(error.message ?? "", t)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group
|
||||
gap="xs"
|
||||
p="xs"
|
||||
align="flex-end"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Textarea
|
||||
style={{ flex: 1 }}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
placeholder={t("Ask a question…")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
aria-label={isStreaming ? t("Stop") : t("Send")}
|
||||
onClick={isStreaming ? () => stop() : handleSend}
|
||||
disabled={!isStreaming && input.trim().length === 0}
|
||||
>
|
||||
{isStreaming ? <IconX size={18} /> : <IconArrowUp size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import { Affix, Button } from "@mantine/core";
|
||||
|
||||
export default function ShareBranding() {
|
||||
return (
|
||||
// Pinned to the bottom-RIGHT corner. The AI assistant FAB
|
||||
// (share-ai-widget.tsx) is stacked ABOVE this with a higher `bottom`
|
||||
// offset, so the two Affix elements never overlap.
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
href="https://github.com/vvzvlad/gitmost?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
Powered by Gitmost
|
||||
</Button>
|
||||
</Affix>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
DocTree,
|
||||
type DocTreeApi,
|
||||
type RenderRowProps,
|
||||
ROW_HEIGHT_COMPACT,
|
||||
ROW_HEIGHT_STANDARD,
|
||||
} from "@/features/page/tree/components/doc-tree";
|
||||
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
|
||||
import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom";
|
||||
|
||||
interface SharedTreeProps {
|
||||
@@ -36,6 +39,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
const treeRef = useRef<DocTreeApi | null>(null);
|
||||
const { pageSlug } = useParams();
|
||||
const compactTree = isCompactPageTreeEnabled();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
|
||||
|
||||
const currentNodeId = extractPageSlugId(pageSlug);
|
||||
@@ -100,6 +104,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
||||
renderRow={SharedTreeRow}
|
||||
onMove={noopMove}
|
||||
onToggle={handleToggle}
|
||||
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
|
||||
getDragLabel={getDragLabel}
|
||||
aria-label={t("Pages")}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface ISharedPage extends IShare {
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
features?: string[];
|
||||
// Whether the anonymous public-share AI assistant is enabled for the
|
||||
// workspace (server-resolved). Gates the "Ask AI" widget.
|
||||
aiAssistant?: boolean;
|
||||
// Display name of the configured assistant identity (agent role name), used
|
||||
// to label the public-share chat. Null/absent when no identity is set →
|
||||
// the widget falls back to the generic "AI agent" label.
|
||||
aiAssistantName?: string | null;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -23,14 +25,16 @@ import {
|
||||
useUnwatchSpaceMutation,
|
||||
} from "@/features/space/queries/space-watcher-query.ts";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
import SpaceTree, {
|
||||
SpaceTreeApi,
|
||||
} from "@/features/page/tree/components/space-tree.tsx";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -57,6 +61,7 @@ export function SpaceSidebar() {
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||
const treeRef = useRef<SpaceTreeApi | null>(null);
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
@@ -100,6 +105,7 @@ export function SpaceSidebar() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
onSpaceSettings={openSettings}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
|
||||
{spaceAbility.can(
|
||||
@@ -122,6 +128,7 @@ export function SpaceSidebar() {
|
||||
|
||||
<div className={classes.pages}>
|
||||
<SpaceTree
|
||||
ref={treeRef}
|
||||
spaceId={space.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
@@ -145,13 +152,25 @@ interface SpaceMenuProps {
|
||||
spaceId: string;
|
||||
canManagePages: boolean;
|
||||
onSpaceSettings: () => void;
|
||||
treeRef: React.RefObject<SpaceTreeApi | null>;
|
||||
}
|
||||
function SpaceMenu({
|
||||
spaceId,
|
||||
canManagePages,
|
||||
onSpaceSettings,
|
||||
treeRef,
|
||||
}: SpaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleExpandAll = () => {
|
||||
// Fire-and-forget: expandAll already surfaces its own error notification.
|
||||
// The menu closes on click (consistent with Collapse all), so there is no
|
||||
// in-menu loading state to track here.
|
||||
treeRef.current?.expandAll();
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
treeRef.current?.collapseAll();
|
||||
};
|
||||
const { spaceSlug } = useParams();
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -201,6 +220,22 @@ function SpaceMenu({
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleExpandAll}
|
||||
leftSection={<IconChevronsDown size={16} />}
|
||||
>
|
||||
{t("Expand all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleCollapseAll}
|
||||
leftSection={<IconChevronsUp size={16} />}
|
||||
>
|
||||
{t("Collapse all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleToggleFavorite}
|
||||
leftSection={
|
||||
|
||||
74
apps/client/src/features/user/connect-resync.test.ts
Normal file
74
apps/client/src/features/user/connect-resync.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
makeConnectHandler,
|
||||
shouldResyncOnConnect,
|
||||
ROOT_SIDEBAR_PAGES_KEY,
|
||||
SIDEBAR_PAGES_KEY,
|
||||
} from "./connect-resync";
|
||||
|
||||
describe("shouldResyncOnConnect", () => {
|
||||
it("does not resync on the first connect", () => {
|
||||
expect(shouldResyncOnConnect(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("resyncs on a reconnect (not the first connect)", () => {
|
||||
expect(shouldResyncOnConnect(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeConnectHandler", () => {
|
||||
it("does NOT invalidate on the first connect", () => {
|
||||
const invalidateQueries = vi.fn();
|
||||
const handler = makeConnectHandler({ invalidateQueries });
|
||||
|
||||
handler();
|
||||
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates BOTH sidebar keys on the reconnect (second connect)", () => {
|
||||
const invalidateQueries = vi.fn();
|
||||
const handler = makeConnectHandler({ invalidateQueries });
|
||||
|
||||
// First connect: the initial connection, no resync.
|
||||
handler();
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
|
||||
// Second connect: a reconnect after a gap, resync both tree levels.
|
||||
handler();
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(2);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: [...ROOT_SIDEBAR_PAGES_KEY],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: [...SIDEBAR_PAGES_KEY],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps invalidating on every subsequent reconnect", () => {
|
||||
const invalidateQueries = vi.fn();
|
||||
const handler = makeConnectHandler({ invalidateQueries });
|
||||
|
||||
handler(); // first connect -> nothing
|
||||
handler(); // reconnect #1 -> 2 calls
|
||||
handler(); // reconnect #2 -> 2 more calls
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("isolates state per handler instance (each factory call gets its own flag)", () => {
|
||||
const invalidateA = vi.fn();
|
||||
const invalidateB = vi.fn();
|
||||
const handlerA = makeConnectHandler({ invalidateQueries: invalidateA });
|
||||
const handlerB = makeConnectHandler({ invalidateQueries: invalidateB });
|
||||
|
||||
// Exhausting handlerA's first connect must not affect handlerB.
|
||||
handlerA();
|
||||
handlerA(); // reconnect on A
|
||||
handlerB(); // still A's-independent first connect on B
|
||||
|
||||
expect(invalidateA).toHaveBeenCalledTimes(2);
|
||||
expect(invalidateB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
41
apps/client/src/features/user/connect-resync.ts
Normal file
41
apps/client/src/features/user/connect-resync.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Sidebar tree query keys that must be refetched (through the authorized API)
|
||||
// after a socket reconnect so the view re-converges after a gap where ws events
|
||||
// were missed (wifi blip, laptop sleep). Both the root level and the
|
||||
// nested-page levels of every space tree are invalidated.
|
||||
export const ROOT_SIDEBAR_PAGES_KEY = ["root-sidebar-pages"] as const;
|
||||
export const SIDEBAR_PAGES_KEY = ["sidebar-pages"] as const;
|
||||
|
||||
/**
|
||||
* Pure decision for the reconnect-resync branch.
|
||||
*
|
||||
* The first `connect` event is the initial connection and must NOT trigger a
|
||||
* resync (the data was just fetched). Every subsequent `connect` event is a
|
||||
* RECONNECT after a gap and should trigger a resync.
|
||||
*/
|
||||
export function shouldResyncOnConnect(isFirstConnect: boolean): boolean {
|
||||
return !isFirstConnect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the socket `connect` handler that owns the first-connect-vs-reconnect
|
||||
* logic via a private closure flag. The returned handler is what the component
|
||||
* registers with `socket.on("connect", ...)`.
|
||||
*
|
||||
* - 1st invocation -> first connect, no invalidation.
|
||||
* - 2nd+ invocation -> reconnect, invalidate both sidebar tree key levels.
|
||||
*/
|
||||
export function makeConnectHandler(
|
||||
queryClient: Pick<QueryClient, "invalidateQueries">,
|
||||
): () => void {
|
||||
let firstConnect = true;
|
||||
|
||||
return () => {
|
||||
if (shouldResyncOnConnect(firstConnect)) {
|
||||
queryClient.invalidateQueries({ queryKey: [...ROOT_SIDEBAR_PAGES_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [...SIDEBAR_PAGES_KEY] });
|
||||
}
|
||||
firstConnect = false;
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
|
||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
@@ -33,8 +35,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
// @ts-ignore
|
||||
setSocket(newSocket);
|
||||
|
||||
// Distinguish the first connect from a reconnect so we only resync after a
|
||||
// gap. The handler owns the first-connect-vs-reconnect decision through a
|
||||
// private closure flag (see makeConnectHandler): on RECONNECT it refetches
|
||||
// the sidebar tree through the authorized API so the view re-converges after
|
||||
// a gap where ws events were missed (wifi blip, laptop sleep), invalidating
|
||||
// both the root level and the nested-page levels of every space tree.
|
||||
const handleConnect = makeConnectHandler(queryClient);
|
||||
newSocket.on("connect", () => {
|
||||
console.log("ws connected");
|
||||
handleConnect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "./tree-socket-reducers";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// Minimal node factory — fills the SpaceTreeNode shape required fields while
|
||||
// letting tests override the bits that matter (position, parentPageId, etc).
|
||||
function node(
|
||||
id: string,
|
||||
overrides: Partial<SpaceTreeNode> = {},
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyMoveTreeNode", () => {
|
||||
// Destination parent `dst` is loaded with three positioned children; the moved
|
||||
// node `src` is a sibling at root with a later position.
|
||||
const buildTree = (): SpaceTreeNode[] => [
|
||||
node("dst", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "dst" }),
|
||||
node("c2", { position: "a3", parentPageId: "dst" }),
|
||||
node("c3", { position: "a5", parentPageId: "dst" }),
|
||||
],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
|
||||
it("places the node by position in the MIDDLE slot of the destination", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"src",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "not-loaded",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
// The source must not linger at its old place — it is removed entirely.
|
||||
expect(treeModel.find(next, "src")).toBeNull();
|
||||
// Destination children are untouched.
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
|
||||
// src is the only child of `old`; moving it to `dst` empties `old`.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("old", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("src", { position: "a1", parentPageId: "old" })],
|
||||
}),
|
||||
node("dst", { position: "a2", hasChildren: false }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: "old",
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("flips the NEW parent's hasChildren to true", () => {
|
||||
// dst starts as a childless leaf; moving src into it must flip the chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dst", { position: "a0", hasChildren: false }),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"src",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the source node is not found", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "ghost",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
|
||||
});
|
||||
const moved = treeModel.find(next, "src");
|
||||
expect(moved?.name).toBe("Renamed");
|
||||
expect(moved?.icon).toBe("fire");
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe("a4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeleteTreeNode", () => {
|
||||
it("removes the node together with its descendants", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("child", {
|
||||
position: "a1",
|
||||
parentPageId: "p",
|
||||
hasChildren: true,
|
||||
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("child", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "child")).toBeNull();
|
||||
expect(treeModel.find(next, "grandchild")).toBeNull();
|
||||
expect(treeModel.find(next, "p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the node is already gone (idempotent)", () => {
|
||||
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("ghost"),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("flips the parent's hasChildren to false when it is left childless", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("only", { position: "a1", parentPageId: "p" })],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("only", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
|
||||
expect(treeModel.find(next, "p")?.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the parent's hasChildren true when other children remain", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "p" }),
|
||||
node("c2", { position: "a2", parentPageId: "p" }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("c1", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAddTreeNode", () => {
|
||||
const roots = (): SpaceTreeNode[] => [
|
||||
node("a", { position: "a0" }),
|
||||
node("b", { position: "a2" }),
|
||||
node("c", { position: "a4" }),
|
||||
];
|
||||
|
||||
it("inserts the new node by position among siblings", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("x", { position: "a3" }),
|
||||
});
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the id is already present (idempotent)", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("b", { position: "a9" }),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("flips the new parent's hasChildren to true", () => {
|
||||
// Parent `p` is a childless leaf; adding a child must flip its chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", { position: "a0", hasChildren: false }),
|
||||
];
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: "p",
|
||||
index: 0,
|
||||
data: node("child", { position: "a1", parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
});
|
||||
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type {
|
||||
AddTreeNodeEvent,
|
||||
MoveTreeNodeEvent,
|
||||
DeleteTreeNodeEvent,
|
||||
UpdateEvent,
|
||||
} from "@/features/websocket/types";
|
||||
|
||||
// Pure tree transforms for the `useTreeSocket` reducer arms. Extracted from the
|
||||
// hook so the realtime tree behaviour can be unit-tested without rendering the
|
||||
// hook, the socket, or jotai. The hook calls these inside its `setData`.
|
||||
//
|
||||
// IMPORTANT: these are PURE — no `queryClient`, no notifications, no atoms. The
|
||||
// delete arm's `queryClient.invalidateQueries` side effect stays in the hook;
|
||||
// `applyDeleteTreeNode` is a pure tree transform only.
|
||||
|
||||
// `updateOne` for a page: patch the in-tree node's name/icon from the payload.
|
||||
// No-op (returns the same reference) when the node isn't loaded on this client.
|
||||
export function applyUpdateOne(
|
||||
prev: SpaceTreeNode[],
|
||||
event: UpdateEvent,
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `addTreeNode`: insert the new node by its fractional `position` among the
|
||||
// already-loaded siblings (not the sender's absolute index). Idempotent — if the
|
||||
// id already exists (optimistic author insert or re-delivery) returns prev
|
||||
// unchanged. Flips the new parent's `hasChildren` to true so the chevron renders.
|
||||
export function applyAddTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: AddTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
// Idempotent: the author already inserted the node optimistically, and a node
|
||||
// may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, payload.data.id)) return prev;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
// Insert by `position` among already-loaded siblings (not the sender's
|
||||
// absolute index) so order is consistent across clients with different loaded
|
||||
// sets.
|
||||
let next = treeModel.insertByPosition(prev, newParentId, payload.data);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so the chevron
|
||||
// renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `moveTreeNode`: place the moved node by its fractional `position` among the new
|
||||
// siblings (NOT the sender's absolute index). If the destination parent isn't
|
||||
// loaded on this client, fall back to removing the source so the UI stays
|
||||
// consistent. Applies authoritative `pageData` fields and mirrors the
|
||||
// `hasChildren` bookkeeping for both the old and the new parent.
|
||||
export function applyMoveTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: MoveTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
const sourceBefore = treeModel.find(prev, payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
|
||||
// Place the node by its fractional `position` among the new siblings — NOT by
|
||||
// the sender's absolute `index` (the sender computed that against its own
|
||||
// loaded set, which differs from this receiver's). Using the position keeps
|
||||
// the visible order correct on every client; placing at `index: 0` would
|
||||
// wrongly drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, payload.id, {
|
||||
parentId: newParentId,
|
||||
position: payload.position,
|
||||
});
|
||||
// `placeByPosition` silently returns the same reference if the destination
|
||||
// parent isn't loaded on this client. Falling back to removing the source
|
||||
// keeps the UI consistent (the source reappears when the user expands the new
|
||||
// parent and lazy-load fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, payload.id);
|
||||
}
|
||||
|
||||
// Apply the authoritative node fields the move payload carries (`pageData`) so
|
||||
// receivers don't keep a stale title/icon/chevron on the moved node.
|
||||
// `placeByPosition` already set `position`.
|
||||
const pageData = payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: payload.position,
|
||||
// Honest type: a root move has a null parent, so this is `string | null`,
|
||||
// not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined) patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// `deleteTreeNode`: remove the node (and its descendants) from the tree.
|
||||
// Idempotent — if the node is already gone returns prev unchanged. Mirrors the
|
||||
// `hasChildren` bookkeeping: a parent left childless flips `hasChildren` false.
|
||||
//
|
||||
// PURE: the `queryClient.invalidateQueries` side effect lives in the hook, not
|
||||
// here.
|
||||
export function applyDeleteTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: DeleteTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, payload.node.id)) return prev;
|
||||
let next = treeModel.remove(prev, payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state when the last child is deleted.
|
||||
const parentPageId = payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import {
|
||||
applyUpdateOne,
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "@/features/websocket/tree-socket-reducers.ts";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
@@ -35,106 +41,26 @@ export const useTreeSocket = () => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyUpdateOne(prev, event));
|
||||
}
|
||||
break;
|
||||
case "addTreeNode":
|
||||
setTreeData((prev) => {
|
||||
if (treeModel.find(prev, event.payload.data.id)) return prev;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
let next = treeModel.insert(
|
||||
prev,
|
||||
newParentId,
|
||||
event.payload.data,
|
||||
event.payload.index,
|
||||
);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so
|
||||
// the chevron renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
setTreeData((prev) => {
|
||||
const sourceBefore = treeModel.find(prev, event.payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId =
|
||||
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
|
||||
const placed = treeModel.place(prev, event.payload.id, {
|
||||
parentId: newParentId,
|
||||
index: event.payload.index,
|
||||
});
|
||||
// `place` silently returns the same reference if the destination
|
||||
// parent isn't loaded on this client. Falling back to removing the
|
||||
// source keeps the UI consistent (the source will reappear when
|
||||
// the user expands the new parent and lazy-load fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, event.payload.id);
|
||||
}
|
||||
|
||||
let next = treeModel.update(placed, event.payload.id, {
|
||||
position: event.payload.position,
|
||||
parentPageId: newParentId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
// The `invalidateQueries` side effect stays in the hook; the tree
|
||||
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
|
||||
// node is actually in the tree (mirrors the pure reducer's early-out).
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.payload.node.id)) return prev;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
let next = treeModel.remove(prev, event.payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state when the last child is deleted.
|
||||
const parentPageId = event.payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (treeModel.find(prev, event.payload.node.id)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
return applyDeleteTreeNode(prev, event.payload);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AI_DRIVER_VALUES,
|
||||
DRIVER_OPTIONS,
|
||||
} from "./ai-agent-role-form";
|
||||
|
||||
/**
|
||||
* Drift guard: the client's hardcoded driver list must stay in sync with the
|
||||
* server `AI_DRIVERS`. Client and server are separate build targets and Vite
|
||||
* refuses to import a module from outside the client root, so instead of an
|
||||
* `import` we read the server `ai.types.ts` source and parse out the AI_DRIVERS
|
||||
* literal. This contract test fails loudly if the two lists ever diverge
|
||||
* (order-independent).
|
||||
*/
|
||||
function readServerAiDrivers(): string[] {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
// apps/client/src/.../components -> repo apps/server/src/integrations/ai
|
||||
const serverTypesPath = path.resolve(
|
||||
here,
|
||||
"../../../../../../../server/src/integrations/ai/ai.types.ts",
|
||||
);
|
||||
const source = readFileSync(serverTypesPath, "utf8");
|
||||
const match = source.match(/AI_DRIVERS\s*:\s*AiDriver\[\]\s*=\s*\[([^\]]*)\]/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Could not locate the AI_DRIVERS literal in ${serverTypesPath}`,
|
||||
);
|
||||
}
|
||||
return match[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
describe("ai-agent-role-form driver drift guard", () => {
|
||||
it("mirrors the server AI_DRIVERS list exactly", () => {
|
||||
const serverDrivers = readServerAiDrivers();
|
||||
expect([...AI_DRIVER_VALUES].sort()).toEqual([...serverDrivers].sort());
|
||||
});
|
||||
|
||||
it("exposes one Select option per server driver plus a workspace-default", () => {
|
||||
const serverDrivers = readServerAiDrivers();
|
||||
const driverOptionValues = DRIVER_OPTIONS.map((o) => o.value).filter(
|
||||
(v) => v !== "",
|
||||
);
|
||||
expect(driverOptionValues.sort()).toEqual([...serverDrivers].sort());
|
||||
// Exactly one empty-value option for the "Workspace default" choice.
|
||||
expect(DRIVER_OPTIONS.filter((o) => o.value === "")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useCreateAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Source of truth: the server `AI_DRIVERS` list in
|
||||
// apps/server/src/integrations/ai/ai.types.ts. The client cannot import that
|
||||
// constant at build time (separate build target), so it is mirrored here and a
|
||||
// drift contract test (ai-agent-role-form.drivers.test.ts) fails if the two
|
||||
// lists diverge. Keep this in sync when adding/removing a server driver.
|
||||
export const AI_DRIVER_VALUES = ["openai", "gemini", "ollama"] as const;
|
||||
export type AiDriverValue = (typeof AI_DRIVER_VALUES)[number];
|
||||
|
||||
const DRIVER_LABELS: Record<AiDriverValue, string> = {
|
||||
openai: "OpenAI",
|
||||
gemini: "Gemini",
|
||||
ollama: "Ollama",
|
||||
};
|
||||
|
||||
// Select options for the optional model override. "" => use the workspace
|
||||
// default driver/model.
|
||||
export const DRIVER_OPTIONS = [
|
||||
{ value: "", label: "Workspace default" },
|
||||
...AI_DRIVER_VALUES.map((value) => ({ value, label: DRIVER_LABELS[value] })),
|
||||
];
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
emoji: z.string(),
|
||||
description: z.string(),
|
||||
instructions: z.string().min(1),
|
||||
// "" => no driver override (use the workspace driver).
|
||||
driver: z.enum(["", ...AI_DRIVER_VALUES]),
|
||||
chatModel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface AiAgentRoleFormProps {
|
||||
// When provided, edits an existing role; otherwise creates one.
|
||||
role?: IAiRole;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AiAgentRoleForm({
|
||||
role,
|
||||
onClose,
|
||||
}: AiAgentRoleFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(role);
|
||||
|
||||
const createMutation = useCreateAiRoleMutation();
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-hydrate when the target role changes (reusing the modal).
|
||||
useEffect(() => {
|
||||
form.setValues({
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
});
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [role?.id]);
|
||||
|
||||
// Build the model override payload: null when neither a driver nor a model id
|
||||
// is set (use the workspace default).
|
||||
function resolveModelConfig(values: FormValues) {
|
||||
const driver = values.driver || undefined;
|
||||
const chatModel = values.chatModel.trim() || undefined;
|
||||
if (!driver && !chatModel) return null;
|
||||
return { driver, chatModel };
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const modelConfig = resolveModelConfig(values);
|
||||
|
||||
if (isEdit && role) {
|
||||
const payload: IAiRoleUpdate = {
|
||||
id: role.id,
|
||||
name: values.name,
|
||||
emoji: values.emoji,
|
||||
description: values.description,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
const payload: IAiRoleCreate = {
|
||||
name: values.name,
|
||||
emoji: values.emoji || undefined,
|
||||
description: values.description || undefined,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("Role name")}
|
||||
placeholder={t("e.g. Proofreader")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Emoji")}
|
||||
description={t("Optional. Shown as the chat badge.")}
|
||||
maxLength={8}
|
||||
{...form.getInputProps("emoji")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Description")}
|
||||
description={t("Optional. A short note about what this role does.")}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t("Instructions")}
|
||||
description={t(
|
||||
"The built-in safety framework is always added automatically.",
|
||||
)}
|
||||
autosize
|
||||
minRows={4}
|
||||
maxRows={14}
|
||||
{...form.getInputProps("instructions")}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t("Model provider override")}
|
||||
description={t("Optional. Defaults to the workspace provider.")}
|
||||
data={DRIVER_OPTIONS}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("driver")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("Model override")}
|
||||
description={t("Optional. Defaults to the workspace model.")}
|
||||
placeholder={t("e.g. gpt-4o-mini")}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
{t(
|
||||
"If you choose a different provider, it must already be configured in AI settings.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={form.values.enabled}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("enabled", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button type="button" variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(form.values)}
|
||||
disabled={isSaving || !form.isValid()}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiRolesQuery,
|
||||
useDeleteAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
||||
|
||||
/**
|
||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||
* replaces the agent's persona (instructions) and may optionally override the
|
||||
* model; the safety framework is always still applied. The add/edit form lives
|
||||
* in `AiAgentRoleForm`, opened in a modal.
|
||||
*/
|
||||
export default function AiAgentRoles() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const { data: roles, isLoading } = useAiRolesQuery(isAdmin);
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
const deleteMutation = useDeleteAiRoleMutation();
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
// The role being edited; undefined => the modal is in "create" mode.
|
||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Only workspace admins can manage AI provider settings.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(undefined);
|
||||
open();
|
||||
}
|
||||
|
||||
function openEdit(role: IAiRole) {
|
||||
setEditing(role);
|
||||
open();
|
||||
}
|
||||
|
||||
function confirmDelete(role: IAiRole) {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete role"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete this role?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteMutation.mutate(role.id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
bg="green.6"
|
||||
style={{ borderRadius: "50%", flex: "none" }}
|
||||
/>
|
||||
<Text fw={600}>{t("Agent roles")}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!roles || roles.length === 0) && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
{roles?.map((role) => (
|
||||
<Group key={role.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{role.emoji ? `${role.emoji} ` : ""}
|
||||
{role.name}
|
||||
</Text>
|
||||
{role.modelConfig?.chatModel && (
|
||||
<Badge size="xs" variant="light">
|
||||
{role.modelConfig.chatModel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{role.description && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{role.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={role.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: role.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(role)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(role)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editing ? t("Edit role") : t("Add role")}
|
||||
size="lg"
|
||||
>
|
||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,21 @@ interface AiMcpServerFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Build the form's field values from a (possibly undefined) server. Used both
|
||||
// for the initial mount and for re-hydration when the modal is reused for a
|
||||
// different server, so the two stay in sync. authHeader is always empty: it is
|
||||
// a write-only secret buffer never echoed back from the server.
|
||||
function buildInitialValues(server?: IAiMcpServer): FormValues {
|
||||
return {
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
|
||||
const TAVILY_PRESET = {
|
||||
name: "Tavily",
|
||||
@@ -72,26 +87,12 @@ export default function AiMcpServerForm({
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
},
|
||||
initialValues: buildInitialValues(server),
|
||||
});
|
||||
|
||||
// Re-hydrate when the target server changes (e.g. reusing the modal).
|
||||
useEffect(() => {
|
||||
form.setValues({
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
});
|
||||
form.setValues(buildInitialValues(server));
|
||||
form.resetDirty();
|
||||
setHasHeaders(server?.hasHeaders ?? false);
|
||||
setHeadersCleared(false);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveCardStatus,
|
||||
isEndpointConfigured,
|
||||
resolveKeyField,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
it('returns "off" when not configured and not enabled', () => {
|
||||
expect(resolveCardStatus(false, false)).toBe('off');
|
||||
});
|
||||
|
||||
it('returns "warning" when enabled but not configured (misconfig, not silent "off")', () => {
|
||||
expect(resolveCardStatus(false, true)).toBe('warning');
|
||||
});
|
||||
|
||||
it('returns "configured" when configured but disabled', () => {
|
||||
expect(resolveCardStatus(true, false)).toBe('configured');
|
||||
});
|
||||
|
||||
it('returns "ready" when configured and enabled', () => {
|
||||
expect(resolveCardStatus(true, true)).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEndpointConfigured', () => {
|
||||
it('configured when model and the endpoint own base URL are set', () => {
|
||||
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('configured by inheriting the chat base URL when own base is empty', () => {
|
||||
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('not configured when model is set but both base URLs are empty', () => {
|
||||
expect(isEndpointConfigured('m', '', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when both base URLs are whitespace-only', () => {
|
||||
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when the model is whitespace-only', () => {
|
||||
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeyField (write-only key payload)', () => {
|
||||
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
|
||||
it('typed a value -> set the new key', () => {
|
||||
expect(resolveKeyField('sk-new', false)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('typed a value wins even if cleared was also flagged', () => {
|
||||
expect(resolveKeyField('sk-new', true)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('cleared (empty buffer) -> set the key to empty string', () => {
|
||||
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
|
||||
});
|
||||
|
||||
it('untouched (empty buffer, not cleared) -> omit the key', () => {
|
||||
expect(resolveKeyField('', false)).toEqual({ set: false });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Anchor,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
IAiSettingsUpdate,
|
||||
SttApiStyle,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
|
||||
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
|
||||
@@ -44,6 +47,11 @@ import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
const formSchema = z.object({
|
||||
chatModel: z.string(),
|
||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||
publicShareChatModel: z.string(),
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona.
|
||||
publicShareAssistantRoleId: z.string(),
|
||||
embeddingModel: z.string(),
|
||||
baseUrl: z.string(),
|
||||
// Embedding-specific base URL. Empty means "use the chat base URL".
|
||||
@@ -60,8 +68,15 @@ const formSchema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
// Status of an endpoint card, drives the little status dot color.
|
||||
type CardStatus = "ok" | "error" | "idle";
|
||||
// Four-state endpoint health shown by the header dot. Derived synchronously
|
||||
// from the form values + feature toggle — never from a network probe (the
|
||||
// "Test endpoint" button still surfaces the live probe result as text).
|
||||
// "ready" (green) — required fields filled AND the feature is ON
|
||||
// "configured"(yellow) — required fields filled but the feature is OFF
|
||||
// "off" (gray) — required fields missing (nothing to enable)
|
||||
// "warning" (orange) — feature is ON but required fields are missing
|
||||
// (a real misconfiguration: it won't work as-is)
|
||||
type CardStatus = "ready" | "configured" | "off" | "warning";
|
||||
|
||||
// Resolve a "Base URL + path" hint defensively: trim a single trailing slash
|
||||
// off the base, then append the path. Empty base falls back to `fallback`
|
||||
@@ -71,21 +86,80 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
|
||||
return `${trimmed}${path}`;
|
||||
}
|
||||
|
||||
// Small colored dot used in each card header.
|
||||
function StatusDot({ status }: { status: CardStatus }) {
|
||||
// Pure + unit-testable. `configured` = the endpoint has the fields it needs
|
||||
// to work; `enabled` = the workspace feature toggle for this endpoint is ON.
|
||||
// The "enabled && !configured" case is surfaced as "warning" instead of "off"
|
||||
// so a misconfiguration (feature on, endpoint not filled) is not hidden.
|
||||
export function resolveCardStatus(
|
||||
configured: boolean,
|
||||
enabled: boolean,
|
||||
): CardStatus {
|
||||
if (configured) return enabled ? "ready" : "configured";
|
||||
return enabled ? "warning" : "off";
|
||||
}
|
||||
|
||||
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
|
||||
// when its model is set AND it has a usable base URL: either its own base URL is
|
||||
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
|
||||
// All inputs are trimmed so whitespace-only values do not count as filled.
|
||||
export function isEndpointConfigured(
|
||||
model: string,
|
||||
ownBase: string,
|
||||
chatBase: string,
|
||||
): boolean {
|
||||
return (
|
||||
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
|
||||
);
|
||||
}
|
||||
|
||||
// Pure + unit-testable. Write-only API-key payload semantics:
|
||||
// - typed a value (buffer non-empty) -> set it
|
||||
// - explicitly cleared -> send '' to clear the stored key
|
||||
// - untouched (empty buffer, not cleared) -> omit the key entirely
|
||||
export function resolveKeyField(
|
||||
buffer: string,
|
||||
cleared: boolean,
|
||||
): { set: true; value: string } | { set: false } {
|
||||
if (buffer.length > 0) return { set: true, value: buffer };
|
||||
if (cleared) return { set: true, value: "" };
|
||||
return { set: false };
|
||||
}
|
||||
|
||||
// Translate the dot's tooltip label. Kept in one place so all three endpoint
|
||||
// cards share identical wording.
|
||||
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return t("Configured and enabled");
|
||||
case "configured":
|
||||
return t("Configured but disabled");
|
||||
case "warning":
|
||||
return t("Enabled but not configured");
|
||||
default:
|
||||
return t("Not configured");
|
||||
}
|
||||
}
|
||||
|
||||
// Small colored dot used in each card header, with a tooltip label so the
|
||||
// state is readable without relying on color alone (colorblind access).
|
||||
function StatusDot({ status, label }: { status: CardStatus; label: string }) {
|
||||
const theme = useMantineTheme();
|
||||
const color =
|
||||
status === "ok"
|
||||
status === "ready"
|
||||
? theme.colors.green[6]
|
||||
: status === "error"
|
||||
? theme.colors.red[6]
|
||||
: theme.colors.gray[5];
|
||||
: status === "configured"
|
||||
? theme.colors.yellow[6]
|
||||
: status === "warning"
|
||||
? theme.colors.orange[6]
|
||||
: theme.colors.gray[5];
|
||||
return (
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
<Tooltip label={label} position="top" withArrow>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +177,10 @@ export default function AiProviderSettings() {
|
||||
const embedTest = useTestAiConnectionMutation();
|
||||
const sttTest = useTestAiConnectionMutation();
|
||||
|
||||
// Agent roles drive the public-share assistant identity picker. Admin-gated
|
||||
// (the component returns early for non-admins), same as the AI settings query.
|
||||
const { data: roles } = useAiRolesQuery(isAdmin);
|
||||
|
||||
// Workspace-level feature toggles live in the card headers.
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [chatEnabled, setChatEnabled] = useState<boolean>(
|
||||
@@ -114,9 +192,17 @@ export default function AiProviderSettings() {
|
||||
const [dictationEnabled, setDictationEnabled] = useState<boolean>(
|
||||
workspace?.settings?.ai?.dictation ?? false,
|
||||
);
|
||||
const [publicShareAssistantEnabled, setPublicShareAssistantEnabled] =
|
||||
useState<boolean>(
|
||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||
);
|
||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||
const [
|
||||
publicShareAssistantToggleLoading,
|
||||
setPublicShareAssistantToggleLoading,
|
||||
] = useState(false);
|
||||
|
||||
// Whether a key is currently stored server-side (drives the placeholder).
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
@@ -136,6 +222,8 @@ export default function AiProviderSettings() {
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
chatModel: "",
|
||||
publicShareChatModel: "",
|
||||
publicShareAssistantRoleId: "",
|
||||
embeddingModel: "",
|
||||
baseUrl: "",
|
||||
embeddingBaseUrl: "",
|
||||
@@ -155,6 +243,8 @@ export default function AiProviderSettings() {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
chatModel: settings.chatModel ?? "",
|
||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||
embeddingModel: settings.embeddingModel ?? "",
|
||||
baseUrl: settings.baseUrl ?? "",
|
||||
embeddingBaseUrl: settings.embeddingBaseUrl ?? "",
|
||||
@@ -181,6 +271,12 @@ export default function AiProviderSettings() {
|
||||
// Everything is OpenAI-compatible.
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||
// back to chatModel server-side.
|
||||
publicShareChatModel: values.publicShareChatModel,
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona server-side.
|
||||
publicShareAssistantRoleId: values.publicShareAssistantRoleId,
|
||||
embeddingModel: values.embeddingModel,
|
||||
// The embedding base URL is optional; empty falls back to the chat base
|
||||
// URL server-side.
|
||||
@@ -194,29 +290,23 @@ export default function AiProviderSettings() {
|
||||
sttApiStyle: values.sttApiStyle,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back):
|
||||
// Key semantics (never send the stored key back) — see resolveKeyField:
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit the key entirely (leave unchanged)
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
|
||||
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
|
||||
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
const embeddingKeyField = resolveKeyField(
|
||||
values.embeddingApiKey,
|
||||
embeddingKeyCleared,
|
||||
);
|
||||
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
|
||||
|
||||
// Same write-only semantics for the STT-specific key.
|
||||
if (values.sttApiKey.length > 0) {
|
||||
payload.sttApiKey = values.sttApiKey;
|
||||
} else if (sttKeyCleared) {
|
||||
payload.sttApiKey = "";
|
||||
}
|
||||
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
|
||||
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -344,6 +434,37 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic toggle for the anonymous public-share AI assistant
|
||||
// (settings.ai.publicShareAssistant). When off, the public endpoint 404s.
|
||||
async function handleTogglePublicShareAssistant(value: boolean) {
|
||||
setPublicShareAssistantToggleLoading(true);
|
||||
const previous = publicShareAssistantEnabled;
|
||||
setPublicShareAssistantEnabled(value);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
aiPublicShareAssistant: value,
|
||||
});
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, publicShareAssistant: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
setPublicShareAssistantEnabled(previous);
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setPublicShareAssistantToggleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Admins only — match the previous behavior.
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
@@ -353,21 +474,23 @@ export default function AiProviderSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const chatStatus: CardStatus = chatTest.data
|
||||
? chatTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
const embedStatus: CardStatus = embedTest.data
|
||||
? embedTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
const sttStatus: CardStatus = sttTest.data
|
||||
? sttTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
// Per-endpoint "configured" predicate, derived from the LIVE form values
|
||||
// (the dot reacts as the admin types). A key is NOT required — local
|
||||
// servers (Ollama, speaches) work without one. Embeddings and Voice
|
||||
// inherit the chat base URL when their own is empty (see resolveUrl).
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
const embedConfigured = isEndpointConfigured(
|
||||
v.embeddingModel,
|
||||
v.embeddingBaseUrl,
|
||||
v.baseUrl,
|
||||
);
|
||||
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
|
||||
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
|
||||
|
||||
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
|
||||
const embedResolved = resolveUrl(
|
||||
@@ -383,6 +506,34 @@ export default function AiProviderSettings() {
|
||||
|
||||
const monoFont = "ui-monospace, Menlo, monospace";
|
||||
|
||||
// Public-share assistant identity options: a leading "built-in persona" entry
|
||||
// (empty value, the server default) plus every enabled agent role. If the saved
|
||||
// role was since disabled it is filtered out of the enabled list, so surface it
|
||||
// explicitly (labeled "disabled") instead of letting the Select render a blank
|
||||
// field for a still-stored id.
|
||||
const selectedRoleId = form.values.publicShareAssistantRoleId;
|
||||
const enabledRoles = (roles ?? []).filter((r: IAiRole) => r.enabled);
|
||||
const selectedDisabledRole =
|
||||
selectedRoleId.length > 0 &&
|
||||
!enabledRoles.some((r: IAiRole) => r.id === selectedRoleId)
|
||||
? (roles ?? []).find((r: IAiRole) => r.id === selectedRoleId)
|
||||
: undefined;
|
||||
const roleOptions = [
|
||||
{ value: "", label: t("Built-in assistant persona") },
|
||||
...enabledRoles.map((r: IAiRole) => ({
|
||||
value: r.id,
|
||||
label: r.emoji ? `${r.emoji} ${r.name}` : r.name,
|
||||
})),
|
||||
...(selectedDisabledRole
|
||||
? [
|
||||
{
|
||||
value: selectedDisabledRole.id,
|
||||
label: `${selectedDisabledRole.emoji ? `${selectedDisabledRole.emoji} ` : ""}${selectedDisabledRole.name} (${t("disabled")})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
{/* Section header */}
|
||||
@@ -404,7 +555,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={chatStatus} />
|
||||
<StatusDot status={chatStatus} label={cardStatusLabel(chatStatus, t)} />
|
||||
<Text fw={600}>{t("Chat / LLM")}</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("root")}
|
||||
@@ -430,19 +581,34 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasApiKey && form.values.apiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -455,6 +621,50 @@ export default function AiProviderSettings() {
|
||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||
</Text>
|
||||
|
||||
{/* Anonymous public-share assistant: a single master toggle + an
|
||||
optional cheaper model id. Reuses this card's driver/URL/key. */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||
<Text fw={600} size="sm">
|
||||
{t("Public share assistant")}
|
||||
</Text>
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
labelPosition="left"
|
||||
checked={publicShareAssistantEnabled}
|
||||
disabled={publicShareAssistantToggleLoading}
|
||||
onChange={(e) =>
|
||||
handleTogglePublicShareAssistant(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} mb="xs">
|
||||
{t(
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
)}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={t("Public assistant model")}
|
||||
placeholder={t("Defaults to the chat model")}
|
||||
disabled={isLoading || !publicShareAssistantEnabled}
|
||||
{...form.getInputProps("publicShareChatModel")}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.",
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
mt="sm"
|
||||
label={t("Assistant identity")}
|
||||
description={t(
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
)}
|
||||
data={roleOptions}
|
||||
allowDeselect={false}
|
||||
disabled={isLoading || !publicShareAssistantEnabled}
|
||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||
/>
|
||||
|
||||
<Group mt="md" align="center">
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -514,7 +724,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={embedStatus} />
|
||||
<StatusDot status={embedStatus} label={cardStatusLabel(embedStatus, t)} />
|
||||
<Text fw={600}>{t("Embeddings")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
@@ -535,29 +745,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
{hasEmbeddingApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasEmbeddingApiKey && form.values.embeddingApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -631,7 +850,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={sttStatus} />
|
||||
<StatusDot status={sttStatus} label={cardStatusLabel(sttStatus, t)} />
|
||||
<Text fw={600}>{t("Voice / STT")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
@@ -654,29 +873,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("sttModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
{hasSttApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasSttApiKey && form.values.sttApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Workspace master toggle that enables/disables the HTML embed block type.
|
||||
*
|
||||
* The block renders inside a SANDBOXED iframe (no same-origin access), so it
|
||||
* cannot touch the viewer's session/cookies/API — it is a feature switch, not a
|
||||
* security gate. When ON, ANY member can insert the block. OFF by default; for
|
||||
* anonymous public-share reads the server serves already-stripped content when
|
||||
* the toggle is OFF. The toggle itself is managed by workspace admins.
|
||||
*/
|
||||
export default function HtmlEmbedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [checked, setChecked] = useState<boolean>(
|
||||
workspace?.settings?.htmlEmbed ?? false,
|
||||
);
|
||||
|
||||
async function handleToggle(value: boolean) {
|
||||
const previous = checked;
|
||||
setChecked(value); // optimistic update
|
||||
const ok = await save(value);
|
||||
if (!ok) setChecked(previous); // revert on failure
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("HTML embed")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("advanced")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Switch
|
||||
label={t("Enable HTML embed")}
|
||||
description={t(
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
onChange={(event) => handleToggle(event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<List size="xs" c="dimmed" mt="md" spacing={4}>
|
||||
<List.Item>
|
||||
{t(
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
)}
|
||||
</List.Item>
|
||||
</List>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Admin-only analytics/tracker snippet for public share pages.
|
||||
*
|
||||
* The value is injected VERBATIM into the <head> of PUBLIC SHARE pages only,
|
||||
* in the page's own (same-origin) context. It is the deliberate same-origin
|
||||
* surface for analytics snippets (Google Analytics, Yandex.Metrika, etc.).
|
||||
* Admin only — the workspace settings write is admin-gated server-side, and the
|
||||
* Save button is disabled for non-admins.
|
||||
*/
|
||||
export default function TrackerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [value, setValue] = useState<string>(
|
||||
workspace?.settings?.trackerHead ?? "",
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
await save(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Analytics / tracker")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("advanced")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
aria-label={t("Analytics / tracker")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={t("<script>...</script>")}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
disabled={!isAdmin || isLoading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={isLoading}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user