Compare commits

..

1 Commits

Author SHA1 Message Date
glm5.2 agent 180
0c7d67fe2a feat(tree): add Expand all / Collapse all to the space page tree
The space sidebar tree loaded children one level at a time, so there was
no way to expand the whole tree at once - doing it client-side would mean
recursively paging /pages/sidebar-pages hundreds of times. Add a server
endpoint that returns the whole space tree in one permission-filtered
request, and two menu items in the Space menu that drive it.

Server:
- POST /pages/tree (reuses SidebarPageDto, CASL Read gate) returns
  { items: [...] } - a flat list in the same shape as /pages/sidebar-pages
  (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren,
  canEdit), sorted by position (collate 'C') then id.
- pageRepo.getSpaceDescendants(spaceId, opts): recursive CTE seeded from
  space roots (parentPageId IS NULL). getSpaceDescendantsExcludingRestricted
  is also added (space-rooted variant of getPageAnd*ExcludingRestricted)
  but not used by the read endpoint - see below.
- pageService.getSidebarPagesTree: fetches the whole space tree WITHOUT
  SQL restricted-pruning and lets filterAccessibleTreePages drop
  inaccessible pages + their subtrees. This mirrors the stepwise
  /pages/sidebar-pages behavior, so a restricted page the user HAS
  access to stays visible in expand-all (an earlier draft pruned in SQL
  and hid them - a behavioural regression). canEdit is per-page in
  restricted spaces (filterAccessiblePageIdsWithPermissions) and
  space-wide in open spaces. filterAccessibleTreePages is now public
  and accepts rootPageId=null to treat every top-level page as a root.
- hasChildren is derived in JS (a page hasChildren iff some returned row
  has it as parentPageId) - O(n), no N subqueries.

Client:
- SpaceTree is now a forwardRef exposing { expandAll, collapseAll,
  isExpanding } via useImperativeHandle. expandAll fires one
  getSpaceTree request, merges the full nested tree into treeDataAtom
  (replace-and-merge so the authoritative server tree wins over stale
  partially-loaded roots), opens every branch id of the current space,
  and guards against space switches via spaceIdRef. Errors are logged
  AND surfaced in a notification with the real reason - never a generic
  string. collapseAll clears ONLY current-space ids from the shared
  open-map (does not disturb other spaces).
- isExpanding is lifted to reactive state in SpaceSidebar and passed as
  a prop to SpaceMenu so the Expand item's disabled state actually
  updates (a ref mutation alone would not re-render).
- Two Menu.Items inside SpaceMenu (Expand all / Collapse all), gated
  only by read access (not canManage) - these are view operations.
2026-06-20 14:44:25 +03:00
312 changed files with 4677 additions and 28844 deletions

View File

@@ -2,38 +2,6 @@
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
@@ -101,55 +69,15 @@ DEBUG_DB=false
# Log http requests
LOG_HTTP=false
# 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 server (community): service account the embedded MCP uses to talk to this Docmost instance
MCP_DOCMOST_EMAIL=
MCP_DOCMOST_PASSWORD=
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
# 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).
# 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).
# 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

View File

@@ -3,7 +3,7 @@ name: Develop
on:
push:
branches:
- develop
- main
workflow_dispatch:
concurrency:
@@ -18,12 +18,7 @@ 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

View File

@@ -19,12 +19,7 @@ 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:

View File

@@ -1,40 +0,0 @@
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
View File

@@ -42,6 +42,3 @@ lerna-debug.log*
.nx/installation
.nx/cache
.claude/worktrees/
# TypeScript incremental build artifacts
*.tsbuildinfo

View File

@@ -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`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — 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`) and nullable columns — 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. 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.
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`.
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 `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
- `.github/workflows/develop.yml` — on push to `main`, 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 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.
`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.

View File

@@ -10,109 +10,6 @@ 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
@@ -195,6 +92,5 @@ 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.93.0...HEAD
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0

View File

@@ -101,9 +101,6 @@ 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
@@ -111,11 +108,14 @@ 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,11 +158,6 @@ 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

View File

@@ -102,9 +102,6 @@ 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.
### В процессе
@@ -112,11 +109,14 @@ 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,12 +159,6 @@ dump/restore, существующий каталог данных переис
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT
> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров
> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить
> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите
> неизменным и бэкапьте вместе с базой данных.
## Возможности

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.93.0",
"version": "0.91.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",

View File

@@ -29,9 +29,11 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Collapse all": "Collapse all",
"Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Create": "Create",
"Create group": "Create group",
"Create page": "Create page",
@@ -68,6 +70,7 @@
"Enter your password": "Enter your password",
"Error fetching page data.": "Error fetching page data.",
"Error loading page history.": "Error loading page history.",
"Expand all": "Expand all",
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
@@ -183,7 +186,6 @@
"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.",
@@ -474,7 +476,6 @@
"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",
@@ -531,7 +532,6 @@
"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",
@@ -980,9 +980,6 @@
"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",
@@ -1128,24 +1125,10 @@
"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",
@@ -1182,10 +1165,6 @@
"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.",
@@ -1219,41 +1198,5 @@
"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)",
"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."
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)"
}

View File

@@ -183,7 +183,6 @@
"Successfully imported": "Успешно импортировано",
"Successfully restored": "Успешно восстановлено",
"System settings": "Системные настройки",
"Template": "Шаблон",
"Templates": "Шаблоны",
"Theme": "Тема",
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
@@ -392,13 +391,6 @@
"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": "Блок формулы",
@@ -479,7 +471,6 @@
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
"Open source page": "Открыть исходную страницу",
"Page": "Страница",
"Delete public share link": "Удалить публичную ссылку",
"Delete share": "Удалить общий доступ",
@@ -668,9 +659,6 @@
"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...": "Задайте вопрос...",

View File

@@ -13,15 +13,6 @@ 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

View File

@@ -57,12 +57,6 @@
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;

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Group, Loader, Select, Tooltip } from "@mantine/core";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
@@ -18,14 +18,13 @@ import {
IconX,
} from "@tabler/icons-react";
import { useAtom, useSetAtom } from "jotai";
import { useMatch } from "react-router-dom";
import { useParams } 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";
@@ -33,15 +32,10 @@ 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";
@@ -108,16 +102,10 @@ 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
@@ -135,29 +123,16 @@ 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. 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;
// 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();
const { data: openPageData } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
@@ -169,9 +144,7 @@ export default function AiChatWindow() {
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
}, [setActiveChatId, setDraft]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -265,31 +238,8 @@ 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.
@@ -337,21 +287,10 @@ export default function AiChatWindow() {
el.style.top = `${nt}px`;
};
const up = (ev: MouseEvent): void => {
const up = (): 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.
@@ -395,49 +334,14 @@ export default function AiChatWindow() {
height: minimized ? undefined : geom.height,
}}
>
{/* 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). */}
{/* drag bar / header */}
<div className={classes.dragBar} onMouseDown={startDrag}>
<IconGripVertical
size={14}
color="var(--mantine-color-gray-4)"
style={{ flex: "none" }}
/>
{/* 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>
)}
<span className={classes.title}>{t("AI chat")}</span>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
@@ -496,16 +400,7 @@ 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}
@@ -537,29 +432,6 @@ 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 ? (
@@ -572,8 +444,6 @@ 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}
/>
)}

View File

@@ -25,10 +25,6 @@ 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;
@@ -65,7 +61,6 @@ export default function ChatThread({
chatId,
initialRows,
openPage,
roleId,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -89,12 +84,6 @@ 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
@@ -130,9 +119,6 @@ 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,
},
}),
@@ -148,13 +134,6 @@ 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";

View File

@@ -115,28 +115,11 @@ 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);
}
}}
>
<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>
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}
</Text>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon

View File

@@ -3,31 +3,18 @@ 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, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { ToolUiPart } 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;
/**
* 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;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/**
@@ -42,12 +29,7 @@ interface MessageItemProps {
* `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,
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
export default function MessageItem({ message }: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -68,7 +50,7 @@ export default function MessageItem({
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{resolveAssistantName(assistantName) ?? t("AI agent")}
{t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "text") {
@@ -76,9 +58,7 @@ export default function MessageItem({
// 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, {
neutralizeInternalLinks,
});
const html = renderChatMarkdown(part.text);
if (html) {
return (
<div
@@ -98,13 +78,7 @@ export default function MessageItem({
}
if (isToolPart(part.type)) {
return (
<ToolCallCard
key={index}
part={part as unknown as ToolUiPart}
showCitations={showCitations}
/>
);
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
}
return null;

View File

@@ -1,41 +1,19 @@
import { ReactNode, useEffect, useRef } from "react";
import { 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;
/**
* 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;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -50,7 +28,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.
*/
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
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.
@@ -67,14 +45,7 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
* 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,
emptyState,
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageListProps) {
export default function MessageList({ messages, isStreaming }: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
// Whether the viewport is currently pinned to the bottom. Starts true so the
@@ -137,11 +108,9 @@ export default function MessageList({
if (messages.length === 0 && !typing) {
return (
<Center className={classes.messages}>
{emptyState ?? (
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
)}
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
</Center>
);
}
@@ -150,15 +119,9 @@ export default function MessageList({
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
<MessageItem
key={message.id}
message={message}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}
/>
<MessageItem key={message.id} message={message} />
))}
{typing && <TypingIndicator assistantName={assistantName} />}
{typing && <TypingIndicator />}
</Stack>
</ScrollArea>
);

View File

@@ -1,55 +0,0 @@
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);
});
});

View File

@@ -13,14 +13,6 @@ 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;
}
/**
@@ -28,15 +20,12 @@ 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,
showCitations = true,
}: ToolCallCardProps) {
export default function ToolCallCard({ part }: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = showCitations ? toolCitations(part) : [];
const citations = toolCitations(part);
return (
<div className={classes.toolCard}>

View File

@@ -1,35 +1,23 @@
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 " 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 "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.
*
* 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".
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
* so it reads as the assistant's bubble taking shape.
*/
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
export default function TypingIndicator() {
const { t } = useTranslation();
const name = resolveAssistantName(assistantName);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
{t("AI agent")}
</Text>
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">
@@ -38,7 +26,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
<span />
</span>
<Text size="sm" c="dimmed">
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
{t("AI agent is typing…")}
</Text>
</Group>
</Box>

View File

@@ -8,26 +8,18 @@ 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,
@@ -122,79 +114,3 @@ 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",
});
},
});
}

View File

@@ -5,9 +5,6 @@ import {
IAiChatListParams,
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCreate,
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -49,33 +46,3 @@ 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;
}

View File

@@ -13,63 +13,6 @@ 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;
}
/**

View File

@@ -1,24 +0,0 @@
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();
});
});

View File

@@ -1,16 +0,0 @@
// 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;
}

View File

@@ -1,79 +0,0 @@
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);
});
});

View File

@@ -1,41 +0,0 @@
// 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;
}

View File

@@ -1,53 +0,0 @@
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.",
);
});
});

View File

@@ -1,117 +0,0 @@
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");
});
});

View File

@@ -1,66 +1,6 @@
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
@@ -72,31 +12,9 @@ function neutralizeInternalLinksHook(node: Element): void {
* 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,
options: RenderChatMarkdownOptions = {},
): string {
export function renderChatMarkdown(markdown: string): string {
if (!markdown) return "";
const html = markdownToHtml(markdown);
if (typeof html !== "string") return "";
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,
);
}
return DOMPurify.sanitize(html);
}

View File

@@ -1,100 +0,0 @@
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");
});
});

View File

@@ -5,11 +5,9 @@
*
* 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 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.
* 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.
*/
/** A tool UI part as it arrives from `useChat` / persisted history. */
@@ -40,11 +38,6 @@ 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 ?? "";

View File

@@ -116,8 +116,8 @@ function CommentListItem({
}
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
<Box ref={ref} pb="xs">
<Group>
<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="xs" fw={500} lineClamp={1}>
<Text size="sm" 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="xs">{comment?.selection}</Text>
<Text size="sm">{comment?.selection}</Text>
</Box>
)}

View File

@@ -121,8 +121,8 @@ function CommentListWithTabs() {
<Paper
shadow="sm"
radius="md"
p="xs"
mb="xs"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
@@ -145,7 +145,7 @@ function CommentListWithTabs() {
{!comment.resolvedAt && canComment && (
<>
<Divider my={2} />
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}

View File

@@ -1,11 +1,15 @@
.wrapper {
padding: var(--mantine-spacing-md);
}
.focused-thread {
border: 2px solid #8d7249;
}
.textSelection {
margin-top: 2px;
margin-top: 4px;
border-left: 2px solid var(--mantine-color-gray-6);
padding: 6px;
padding: 8px;
background: var(--mantine-color-gray-light);
cursor: pointer;
overflow-wrap: break-word;
@@ -28,9 +32,6 @@
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%;
@@ -38,9 +39,7 @@
word-break: break-word;
padding-left: 6px;
padding-right: 6px;
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
margin-top: 4px;
margin-top: 10px;
margin-bottom: 2px;
}

View File

@@ -1,48 +0,0 @@
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>
);
}

View File

@@ -1,146 +0,0 @@
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>
);
}

View File

@@ -1,111 +0,0 @@
/* 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;
}

View File

@@ -1,20 +0,0 @@
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>
);
}

View File

@@ -1,170 +0,0 @@
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);
});
});

View File

@@ -1,142 +0,0 @@
/**
* 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;
}

View File

@@ -1,50 +0,0 @@
.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;
}

View File

@@ -1,207 +0,0 @@
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>
);
}

View File

@@ -1,141 +0,0 @@
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");
});
});

View File

@@ -1,58 +0,0 @@
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";
}

View File

@@ -1,91 +0,0 @@
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");
});
});

View File

@@ -1,53 +0,0 @@
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);
}

View File

@@ -1,49 +0,0 @@
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>
);
}

View File

@@ -1,162 +0,0 @@
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();
});
});

View File

@@ -1,184 +0,0 @@
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) };
}

View File

@@ -1,110 +0,0 @@
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>
);
}

View File

@@ -1,43 +0,0 @@
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);
});
});

View File

@@ -1,27 +0,0 @@
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,
};
}

View File

@@ -1,255 +0,0 @@
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}
</>
);
}

View File

@@ -1,79 +0,0 @@
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);
});
});

View File

@@ -28,10 +28,7 @@ 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,
@@ -369,14 +366,6 @@ 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.",
@@ -546,29 +535,6 @@ 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.",
@@ -621,21 +587,6 @@ 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",
@@ -793,25 +744,6 @@ 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,
@@ -821,7 +753,6 @@ export const getSuggestionItems = ({
}): SlashMenuGroupedItemsType => {
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
const fuzzyMatch = (query: string, target: string) => {
let queryIndex = 0;
@@ -836,9 +767,6 @@ 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) ||

View File

@@ -21,10 +21,6 @@ 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 = {

View File

@@ -183,8 +183,7 @@
}
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
outline: none;
}

View File

@@ -41,7 +41,6 @@ import {
Drawio,
Excalidraw,
Embed,
HtmlEmbed,
TiptapPdf,
PageBreak,
SearchAndReplace,
@@ -61,11 +60,7 @@ import {
Status,
TransclusionSource,
TransclusionReference,
PageEmbed,
TableView,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -92,15 +87,10 @@ 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";
@@ -240,7 +230,7 @@ export const mainExtensions = [
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
customNodes: ["transclusionSource", "transclusionReference"],
}),
TextStyle,
Color,
@@ -375,13 +365,6 @@ 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,
}),
@@ -398,22 +381,6 @@ 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,
}),
@@ -453,8 +420,7 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
"Draw.io (diagrams.net)",
"Excalidraw (Whiteboard)",
"Audio",
"Synced block",
"Embed page"
"Synced block"
]);
const TemplateSlashCommand = Command.configure({

View File

@@ -73,9 +73,6 @@ 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 {
@@ -410,8 +407,6 @@ export default function PageEditor({
return (
<TransclusionLookupProvider>
<PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}>
{showStatic ? (
<EditorProvider
editable={false}
@@ -459,7 +454,6 @@ export default function PageEditor({
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
{editor && editorIsEditable && <PageEmbedPicker />}
</div>
<div
onClick={() => editor.commands.focus("end")}
@@ -467,8 +461,6 @@ export default function PageEditor({
></div>
</div>
)}
</PageEmbedAncestryProvider>
</PageEmbedLookupProvider>
</TransclusionLookupProvider>
);
}

View File

@@ -48,16 +48,9 @@ export default function ReadonlyPageEditor({
}, []);
const extensions = useMemo(() => {
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,
);
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,

View File

@@ -1,20 +0,0 @@
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",
});
},
});
}

View File

@@ -1,20 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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;
};

View File

@@ -360,16 +360,6 @@ 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) => {

View File

@@ -69,6 +69,17 @@ export async function getSidebarPages(
return req.data;
}
// Fetch the whole space tree (or a single page's subtree) in one shot. Used by
// the "Expand all" command. Returns a flat list that buildTreeWithChildren turns
// into the nested tree. Permission-filtered server-side.
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 getAllSidebarPages(
params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
@@ -92,14 +103,6 @@ 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[]>> {

View File

@@ -16,11 +16,6 @@ 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;
@@ -127,11 +122,11 @@ function DocTreeInner<T extends object>(
selectedId,
renderRow,
indentPerLevel = 8,
// 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,
// 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,
onMove,
onToggle,
onSelect,

View File

@@ -12,7 +12,6 @@ import {
IconLink,
IconStar,
IconStarFilled,
IconTemplate,
IconTrash,
} from "@tabler/icons-react";
@@ -31,7 +30,6 @@ 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";
@@ -65,26 +63,6 @@ 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 =
@@ -239,17 +217,6 @@ 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"

View File

@@ -2,14 +2,13 @@ import { useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ActionIcon, rem } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPlus,
IconPointFilled,
IconTemplate,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
@@ -172,25 +171,6 @@ 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} />

View File

@@ -1,228 +0,0 @@
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();
});
});

View File

@@ -24,11 +24,9 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
import {
buildTree,
buildTreeWithChildren,
mergeRootTrees,
collectAllIds,
collectBranchIds,
openBranches,
closeIds,
mergeRootTrees,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -38,17 +36,18 @@ import {
} from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
import {
DocTree,
ROW_HEIGHT_COMPACT,
ROW_HEIGHT_STANDARD,
} from "./doc-tree";
import { DocTree } from "./doc-tree";
import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
spaceId: string;
readOnly: boolean;
// Lifted-state notifier: invoked with true at the start of expandAll and
// false in its finally. Lets the parent (SpaceSidebar) re-render and feed
// a reactive `isExpanding` prop to SpaceMenu, so the Expand-all item's
// disabled state updates while the menu is open. treeRef.current?.isExpanding
// alone does NOT trigger a re-render of the menu.
onExpandingChange?: (v: boolean) => void;
}
export type SpaceTreeApi = {
@@ -58,14 +57,12 @@ export type SpaceTreeApi = {
};
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
{ spaceId, readOnly },
{ spaceId, readOnly, onExpandingChange },
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,
@@ -75,6 +72,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
} = useGetRootSidebarPagesQuery({ spaceId });
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [isExpanding, setIsExpanding] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({
@@ -221,45 +219,55 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
onExpandingChange?.(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.
const items: IPage[] = await getSpaceTree({ spaceId: startSpaceId });
// Space switched mid-request — drop the result, don't mutate another space.
if (spaceIdRef.current !== startSpaceId) return;
// IPage[] -> flat SpaceTreeNode[] -> nested tree.
const fullTree = buildTreeWithChildren(buildTree(items));
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
// fullTree is the server-authoritative complete tree; pass it first so
// its fully-nested children win, and any current root absent from the
// response is preserved defensively. Other spaces are untouched.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...fullTree];
const current = prev.filter((n) => n?.spaceId === startSpaceId);
return [...others, ...mergeRootTrees(fullTree, current)];
});
// Open every branch node (node with children) of the current space only.
// Open every branch node of the returned tree (leaves need no entry).
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
// Never swallow: log the full error AND 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),
reason: err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
setIsExpanding(false);
onExpandingChange?.(false);
}
}, [setData, setOpenTreeNodes, t]);
}, [setData, setOpenTreeNodes, t, onExpandingChange]);
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; collapse only current-space ids so
// other spaces' expanded state is left intact.
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => closeIds(prev, ids));
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
@@ -301,7 +309,6 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
renderRow={renderRow}
onMove={handleMove}
onToggle={handleToggle}
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
readOnly={readOnly}
disableDrag={disableDragDrop}
disableDrop={disableDragDrop}

View File

@@ -19,6 +19,7 @@ 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>;
@@ -40,11 +41,12 @@ 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 } = treeModel.move(before, sourceId, op);
const { tree: after, result } = treeModel.move(before, sourceId, op);
if (after === before) return;
const payload = dropOpToMovePayload(before, sourceId, op);
@@ -110,12 +112,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
pageData,
);
// 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.
setTimeout(() => {
emit({
operation: "moveTreeNode",
spaceId: spaceId,
payload: {
id: sourceId,
parentId: payload.parentPageId,
oldParentId,
index: result.index,
position: payload.position,
pageData,
},
});
}, 50);
},
[setData, store, movePageMutation, spaceId, t],
[setData, store, movePageMutation, spaceId, emit, t],
);
const handleCreate = useCallback(
@@ -154,23 +166,20 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
lastIndex = parent?.children?.length ?? 0;
}
// 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);
});
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex));
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
// 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,
@@ -178,7 +187,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
);
navigate(pageUrl);
},
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
[spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug],
);
const handleRename = useCallback(
@@ -229,15 +238,19 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
navigate(getSpaceUrl(spaceSlug));
}
// 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.
setTimeout(() => {
if (!node) return;
emit({
operation: "deleteTreeNode",
spaceId,
payload: { node },
});
}, 50);
} catch (error) {
console.error("Failed to delete page:", error);
}
},
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId],
);
return { handleMove, handleCreate, handleRename, handleDelete };

View File

@@ -128,271 +128,6 @@ 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');
@@ -505,118 +240,6 @@ 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', {
@@ -703,45 +326,4 @@ 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',
]);
});
});

View File

@@ -98,35 +98,6 @@ 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>[] => {
@@ -215,30 +186,6 @@ 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,

View File

@@ -8,6 +8,5 @@ export type SpaceTreeNode = {
parentPageId: string;
hasChildren: boolean;
canEdit?: boolean;
isTemplate?: boolean;
children: SpaceTreeNode[];
};

View File

@@ -1,275 +0,0 @@
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 });
});
});

View File

@@ -25,19 +25,11 @@ 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]);
});
@@ -142,17 +134,11 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
// Build the tree array
items.forEach((item) => {
const node = nodeMap[item.id];
// 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]) {
if (item.parentPageId !== null) {
// 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 (or its parent isn't loaded), it's a root
// node, so add it to the result array.
// If the item has no parent, it's a root node, so add it to the result array
result.push(node);
}
});
@@ -260,30 +246,3 @@ export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
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;
}

View File

@@ -12,7 +12,6 @@ export interface IPage {
spaceId: string;
workspaceId: string;
isLocked: boolean;
isTemplate?: boolean;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;

View File

@@ -22,7 +22,6 @@ export interface SearchSuggestionParams {
includeUsers?: boolean;
includeGroups?: boolean;
includePages?: boolean;
onlyTemplates?: boolean;
spaceId?: string;
limit?: number;
}

View File

@@ -1,226 +0,0 @@
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>
);
}

View File

@@ -2,17 +2,14 @@ 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://github.com/vvzvlad/gitmost?ref=public-share"
href="https://docmost.com?ref=public-share"
>
Powered by Gitmost
Powered by Docmost
</Button>
</Affix>
);

View File

@@ -25,10 +25,7 @@ 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 {
@@ -39,7 +36,6 @@ 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);
@@ -104,7 +100,6 @@ 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")}
/>

View File

@@ -42,13 +42,6 @@ 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 {

View File

@@ -6,9 +6,9 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArrowsMaximize,
IconArrowsMinimize,
IconArrowDown,
IconChevronsDown,
IconChevronsUp,
IconDots,
IconEye,
IconEyeOff,
@@ -25,7 +25,7 @@ import {
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom";
import clsx from "clsx";
@@ -62,6 +62,10 @@ export function SpaceSidebar() {
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
// Lifted mirror of SpaceTree's internal isExpanding, so SpaceSidebar (and
// SpaceMenu) re-render when expand-all starts/finishes. treeRef.current
// mutations do not trigger re-renders; this state does.
const [isTreeExpanding, setIsTreeExpanding] = useState(false);
if (!space) {
return <></>;
@@ -100,12 +104,13 @@ export function SpaceSidebar() {
<Group gap="xs">
<SpaceMenu
spaceId={space.id}
treeRef={treeRef}
isExpanding={isTreeExpanding}
canManagePages={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
onSpaceSettings={openSettings}
treeRef={treeRef}
/>
{spaceAbility.can(
@@ -130,6 +135,7 @@ export function SpaceSidebar() {
<SpaceTree
ref={treeRef}
spaceId={space.id}
onExpandingChange={setIsTreeExpanding}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
@@ -150,27 +156,22 @@ export function SpaceSidebar() {
interface SpaceMenuProps {
spaceId: string;
treeRef: React.RefObject<SpaceTreeApi | null>;
// Reactive (lifted state), NOT treeRef.current?.isExpanding. The latter would
// never update the disabled state while the menu is open because ref mutations
// do not trigger re-renders.
isExpanding: boolean;
canManagePages: boolean;
onSpaceSettings: () => void;
treeRef: React.RefObject<SpaceTreeApi | null>;
}
function SpaceMenu({
spaceId,
treeRef,
isExpanding,
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);
@@ -220,22 +221,6 @@ 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={
@@ -261,6 +246,21 @@ function SpaceMenu({
{isWatching ? t("Stop watching space") : t("Watch space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={() => treeRef.current?.expandAll()}
leftSection={<IconArrowsMaximize size={16} />}
disabled={isExpanding}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={() => treeRef.current?.collapseAll()}
leftSection={<IconArrowsMinimize size={16} />}
>
{t("Collapse all")}
</Menu.Item>
{canManagePages && (
<>
<Menu.Divider />

View File

@@ -1,74 +0,0 @@
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();
});
});

View File

@@ -1,41 +0,0 @@
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;
};
}

View File

@@ -11,8 +11,6 @@ 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);
@@ -35,16 +33,8 @@ 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 () => {

View File

@@ -1,264 +0,0 @@
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",
]);
});
});

View File

@@ -1,164 +0,0 @@
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;
}

View File

@@ -6,12 +6,6 @@ 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 = () => {
@@ -41,26 +35,106 @@ export const useTreeSocket = () => {
switch (event.operation) {
case "updateOne":
if (event.entity[0] === "pages") {
setTreeData((prev) => applyUpdateOne(prev, event));
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;
});
}
break;
case "addTreeNode":
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
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;
});
break;
case "moveTreeNode":
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
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;
});
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)) {
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
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>);
}
}
return applyDeleteTreeNode(prev, event.payload);
return next;
});
break;
}

View File

@@ -1,53 +0,0 @@
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);
});
});

View File

@@ -1,221 +0,0 @@
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>
);
}

View File

@@ -1,175 +0,0 @@
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>
);
}

View File

@@ -47,21 +47,6 @@ 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",
@@ -87,12 +72,26 @@ export default function AiMcpServerForm({
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: buildInitialValues(server),
initialValues: {
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
},
});
// Re-hydrate when the target server changes (e.g. reusing the modal).
useEffect(() => {
form.setValues(buildInitialValues(server));
form.setValues({
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
});
form.resetDirty();
setHasHeaders(server?.hasHeaders ?? false);
setHeadersCleared(false);

View File

@@ -1,73 +0,0 @@
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 });
});
});

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { z } from "zod/v4";
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
@@ -15,13 +15,12 @@ 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, IconX } from "@tabler/icons-react";
import { IconPencil } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
@@ -38,8 +37,6 @@ 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
@@ -47,11 +44,6 @@ 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".
@@ -68,15 +60,8 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
// 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";
// Status of an endpoint card, drives the little status dot color.
type CardStatus = "ok" | "error" | "idle";
// 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`
@@ -86,80 +71,21 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
return `${trimmed}${path}`;
}
// 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 }) {
// Small colored dot used in each card header.
function StatusDot({ status }: { status: CardStatus }) {
const theme = useMantineTheme();
const color =
status === "ready"
status === "ok"
? theme.colors.green[6]
: status === "configured"
? theme.colors.yellow[6]
: status === "warning"
? theme.colors.orange[6]
: theme.colors.gray[5];
: status === "error"
? theme.colors.red[6]
: theme.colors.gray[5];
return (
<Tooltip label={label} position="top" withArrow>
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
</Tooltip>
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
);
}
@@ -177,10 +103,6 @@ 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>(
@@ -192,17 +114,9 @@ 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);
@@ -222,8 +136,6 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema),
initialValues: {
chatModel: "",
publicShareChatModel: "",
publicShareAssistantRoleId: "",
embeddingModel: "",
baseUrl: "",
embeddingBaseUrl: "",
@@ -243,8 +155,6 @@ 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 ?? "",
@@ -271,12 +181,6 @@ 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.
@@ -290,23 +194,29 @@ export default function AiProviderSettings() {
sttApiStyle: values.sttApiStyle,
};
// Key semantics (never send the stored key back) — see resolveKeyField:
// Key semantics (never send the stored key back):
// - typed a value -> set it
// - explicitly cleared -> send '' to clear
// - untouched -> omit the key entirely (leave unchanged)
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
if (values.apiKey.length > 0) {
payload.apiKey = values.apiKey;
} else if (keyCleared) {
payload.apiKey = "";
}
// Same write-only semantics for the embedding-specific key.
const embeddingKeyField = resolveKeyField(
values.embeddingApiKey,
embeddingKeyCleared,
);
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
if (values.embeddingApiKey.length > 0) {
payload.embeddingApiKey = values.embeddingApiKey;
} else if (embeddingKeyCleared) {
payload.embeddingApiKey = "";
}
// Same write-only semantics for the STT-specific key.
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
if (values.sttApiKey.length > 0) {
payload.sttApiKey = values.sttApiKey;
} else if (sttKeyCleared) {
payload.sttApiKey = "";
}
return payload;
}
@@ -434,37 +344,6 @@ 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 (
@@ -474,23 +353,21 @@ export default function AiProviderSettings() {
);
}
// 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 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";
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
const embedResolved = resolveUrl(
@@ -506,34 +383,6 @@ 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 */}
@@ -555,7 +404,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} label={cardStatusLabel(chatStatus, t)} />
<StatusDot status={chatStatus} />
<Text fw={600}>{t("Chat / LLM")}</Text>
<Badge size="sm" variant="light" color="gray">
{t("root")}
@@ -581,34 +430,19 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("chatModel")}
/>
{/* 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")}
/>
<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>
</Group>
<TextInput
@@ -621,50 +455,6 @@ 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"
@@ -724,7 +514,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} label={cardStatusLabel(embedStatus, t)} />
<StatusDot status={embedStatus} />
<Text fw={600}>{t("Embeddings")}</Text>
</Group>
<Switch
@@ -745,38 +535,29 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("embeddingModel")}
/>
{/* 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")}
/>
<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>
</Group>
<TextInput
@@ -850,7 +631,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} label={cardStatusLabel(sttStatus, t)} />
<StatusDot status={sttStatus} />
<Text fw={600}>{t("Voice / STT")}</Text>
</Group>
<Switch
@@ -873,38 +654,29 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("sttModel")}
/>
{/* 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")}
/>
<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>
</Group>
<Select

View File

@@ -1,74 +0,0 @@
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>
);
}

View File

@@ -1,76 +0,0 @@
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