Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
3a04e09f13 docs: remove implemented ai-chat-review-followups backlog plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 05:51:52 +03:00
claude code agent 227
e2d180ab0b test(ai-chat): cover crypto/SSRF/assistant-parts; a11y + refactors
Closes the ai-chat code-review follow-ups.

Tests (security-critical paths previously uncovered):
- secret-box.spec: AES-256-GCM round-trip, random-salt uniqueness, tampered
  blob / wrong APP_SECRET throw the expected message.
- ssrf-guard.spec: isIpAllowed blocks loopback/link-local/private/CGNAT/ULA/
  unspecified/IPv4-mapped, allows public; isUrlAllowed blocks bad scheme,
  invalid URL, IP literals, and DNS-rebinding (mocked dns.lookup).
- ai-chat.service.spec: assistantParts emits output-error for an UNPAIRED
  tool call (guards the MissingToolResultsError fix), output-available when
  paired, skips malformed calls; serializeSteps/rowToUiMessage.
- ai-chat-tools.service.spec: JSON-string node/content coercion + invalid-JSON
  throws; updatePageJson title-only vs object.
- page-embedding.repo.spec: empty spaceIds early-returns [] with no DB call.

a11y:
- Chat history toggle and conversation rows are now keyboard-operable
  (role=button, tabIndex, Enter/Space), matching history-item.tsx.

Refactors:
- onError on useChat adopts the server chat id when the first turn errors
  (AI SDK v6 onFinish doesn't fire on error).
- isToolPart exported once from tool-parts and shared (was duplicated).
- buildInitialValues() dedups the ai-mcp-server-form initial values.
- describeProviderError replaces two inline statusCode/message snippets.
- tool-parts stale tool-list comment refreshed.

Implements docs/backlog/ai-chat-review-followups.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 05:51:52 +03:00
442 changed files with 6271 additions and 44148 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: 100 calls per workspace per rolling hour).
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
#
# 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

6
.gitignore vendored
View File

@@ -42,9 +42,3 @@ lerna-debug.log*
.nx/installation
.nx/cache
.claude/worktrees/
# TypeScript incremental build artifacts
*.tsbuildinfo
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/

188
AGENTS.md
View File

@@ -5,48 +5,45 @@ repository. It has two layers: **how to run a task end-to-end** (the
sections below), and **how the codebase is built** (the technical sections
further down, formerly in `CLAUDE.md`).
## Task lifecycle
## Жизненный цикл задачи
### 1. Start: sync with develop
### 1. Старт: синхронизация с develop
Before starting **any** work, update your local `develop` and branch off it:
Перед началом **любой** работы обнови локальный `develop` и ветвись от него:
```bash
git checkout develop
git fetch gitea
git pull --ff-only gitea develop
git checkout -b <short-feature-name>
git checkout -b <короткое-имя-фичи>
```
Never build a feature directly on `develop`, and never branch off a stale
`develop`otherwise the PR will carry extra commits or conflict.
Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего
`develop`иначе PR будет содержать лишние коммиты или конфликтовать.
### 2. Implementation
### 2. Реализация
Run the task through the workflow from the system prompt (Phase 1 analysis →
Phase 3 implementation → Phase 4 review → Phase 5 verification → Phase 6
report). Delegate large changes to a general subagent; review via the review
subagent.
Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3
реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие
изменения делегируй в general subagent, ревьюй через review subagent.
**Create worktrees only inside the `.claude` folder** (e.g.
`.claude/worktrees/<name>`). Creating a git worktree anywhere else — the repo
root, sibling directories, or temp folders — is forbidden.
### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code`
### 3. Commit — ONLY to Gitea and ONLY as `claude_code`
Это правило без исключений:
This rule has no exceptions:
- **Where:** the only remote for commits/pushes is **`gitea`**
(`gitea.vvzvlad.xyz`). **Never** push to `origin` (the GitHub mirror), and
especially not to `upstream` (the original Docmost). The GitHub mirror is
updated by the owner's CI process, not by the agent.
- **Who:** commit **only** as the agent identity. Any commit whose author or
committer is `vvzvlad` is an error and must be rewritten.
- **Куда:** единственный remote для коммитов/пушей — **`gitea`**
(`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и
тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется
CI-процессом владельца, не агентом.
- **От кого:** коммить **только** от агентского identity. Любой коммит,
у которого author или committer — `vvzvlad`, считается ошибкой и должен
быть переписан.
- **name:** `claude_code`
- **email:** `claude_code@vvzvlad.xyz`
Use `--reset-author` when amending, otherwise git keeps the original author
(the default config on this machine is `vvzvlad`, so check after every commit):
Используй `--reset-author` при amend, иначе git оставит оригинального
автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй
после каждого коммита):
```bash
GIT_AUTHOR_NAME="claude_code" \
@@ -56,33 +53,34 @@ GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \
git commit --amend --no-edit --reset-author
```
For a regular new commit, set the branch-local config once and commit normally:
Для обычного нового коммита достаточно один раз выставить локальный
config ветки и коммитить штатно:
```bash
git config user.name "claude_code"
git config user.email "claude_code@vvzvlad.xyz"
```
Check before push:
Проверка перед push:
```bash
git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
# both lines must show claude_code <claude_code@vvzvlad.xyz>
# обе строки должны показать claude_code <claude_code@vvzvlad.xyz>
```
### 4. Push and PR to develop
### 4. Push и PR в develop
PRs always target `develop`. The `claude_code` password lives in the macOS
keychain as a **generic password** under service `gitea-claude-code` (do not
duplicate it as an internet-password for `gitea.vvzvlad.xyz`that creates a
conflict with the owner's account in the git credential helper):
PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как
**generic password** под service `gitea-claude-code` (не дублируй его как
internet-password для `gitea.vvzvlad.xyz`это создаст конфликт с учёткой
владельца в git credential helper):
```bash
AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w)
```
Push by temporarily injecting the credentials into the remote URL, then always
restore the URL to its clean form (the password must not linger in git
Push — через временную подстановку кредов в remote URL, после чего URL
обязательно возвращается в чистый вид (пароль не должен оседать в git
config / reflog):
```bash
@@ -94,7 +92,7 @@ git remote set-url gitea "$ORIG_URL"
unset AGENT_PASS SAFE_PASS
```
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
PR создаётся через Gitea REST API (Basic Auth от `claude_code`):
```bash
curl -s -X POST \
@@ -104,62 +102,63 @@ curl -s -X POST \
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
```
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
of scope, verification results (tsc/lint/tests).
`base: develop`, `head: <branch>`. В теле PR — что сделано, что вне scope,
результаты верификации (tsc/lint/tests).
> If push fails with `User permission denied for writing`, then `claude_code`
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
> the Gitea UI or `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code`
> with `{"permission":"write"}` from their account).
> Если push падает с `User permission denied for writing` — значит у
> `claude_code` нет коллабораторских прав на репо. Попроси владельца
> добавить (один раз, через Gitea UI или
> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с
> `{"permission":"write"}` от его учётки).
### 5. Merge and cleanup
### 5. Мерж и cleanup
- **The user merges the PR into develop** (not the agent). The agent does not
press the merge button.
- **After implementing a task, delete its plan from `docs/backlog/<task>.md`** —
this is part of closing the task, not the user's work. Files in
`docs/backlog/` are the work queue; completed items get cleaned out of it.
Do this in a separate commit from the same `claude_code` on the same branch
(or ask the user to delete it if the PR is already open and you don't want to
repush it).
- Any junk left uncommitted in the working tree? Check `git status` before the
final report.
- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт
кнопку merge.
- **После реализации задачи удали её план из `docs/backlog/<task>.md`** —
это часть закрытия задачи, не пользовательская работа. Файлы в
`docs/backlog/` — это очередь работы, выполненное из неё вычищается.
Сделай это в отдельном коммите от того же `claude_code` в той же ветке
(или попроси пользователя удалить, если PR уже открыт и ты не хочешь
его перепушивать).
- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед
финальным отчётом.
## Release cycle: staging a new version
## Релизный цикл: набор на новую версию
When enough changes have accumulated on `develop` for a release, a **final
review by three orchestrator skills** runs before the merge/tag:
Когда в `develop` накопилось достаточно изменений для релиза, запускается
**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом:
1. **test-orchestrator** (the `code-review-orchestrator` skill focused on test
coverage) — verifies new code is covered by tests and there are no
regressions in existing ones.
2. **review-orchestrator** (the `code-review-orchestrator` skill) —
multi-aspect code review: security, stability, convention conformance,
regressions, over-complexity.
3. **red-team-orchestrator** (the red-team skill) — adversarial analysis of
attack scenarios against the affected components.
1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на
тестовом покрытии) — проверяет, что новый код покрыт тестами и нет
регрессий в существующих.
2. **review-orchestrator** (skill `code-review-orchestrator`) —
мульти-аспектный код-ревью: безопасность, стабильность, соответствие
конвенциям, регрессии, перегруженность.
3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ
атакующих сценариев на затронутые компоненты.
Order: the orchestrators return finding lists → the agent fixes everything they
found (via a subagent or itself, per the delegation rules) → re-runs the review
on the affected areas → cuts the tag per the "Cutting a release" procedure
below.
Порядок: оркестраторы возвращают списки находок → агент правит всё, что
они нашли (через subagent или сам, по правилам делегирования) → повторно
прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a
release» ниже.
## Accounts & endpoints cheat sheet
## Шпаргалка по учёткам и endpoint'ам
| Item | Value |
| Что | Значение |
| --- | --- |
| Only remote for commits | `gitea``https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
| Agent user (Gitea/git) | `claude_code` |
| Agent email | `claude_code@vvzvlad.xyz` |
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
| Base branch | `develop` |
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | The original Docmost — **never push** |
| Единственный remote для коммитов | `gitea``https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
| Агентский user (Gitea/git) | `claude_code` |
| Агентский email | `claude_code@vvzvlad.xyz` |
| Пароль в keychain | `security find-generic-password -s gitea-claude-code -w` |
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (тут `gitmost` — реальный slug репо на сервере) |
| Базовая ветка | `develop` |
| `origin` | GitHub-зеркало `vvzvlad/gitmost`**не пушить**, обновляется CI владельца |
| `upstream` | Оригинальный Docmost — **не пушить никогда** |
---
# Architecture and codebase
# Архитектура и кодовая база
## What this is
@@ -217,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.
@@ -241,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.
@@ -264,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.
@@ -278,30 +277,7 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
#### Why develop keeps showing the *previous* version (and why step 7 matters)
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
Fix / checklist when develop still shows the old version after a back-merge:
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
## 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,118 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Public share AI: default per-workspace hourly assistant cap lowered
300 → 100.** The limiter falls back to this default whenever
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
never set the env var has its anonymous public-share assistant hourly cap
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
keep the previous limit. (#62)
## [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
@@ -204,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,10 +1,10 @@
{
"name": "client",
"private": true,
"version": "0.93.0",
"version": "0.91.0",
"scripts": {
"dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
@@ -28,7 +28,6 @@
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@ricky0123/vad-web": "^0.0.30",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
@@ -54,7 +53,6 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"onnxruntime-web": "^1.27.0",
"posthog-js": "1.372.2",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",

View File

@@ -119,8 +119,6 @@
"Name": "Name",
"New email": "New email",
"New page": "New page",
"New note": "New note",
"Create in space": "Create in space",
"New password": "New password",
"No group found": "No group found",
"No page history saved yet.": "No page history saved yet.",
@@ -185,7 +183,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.",
@@ -476,7 +473,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",
@@ -533,7 +529,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",
@@ -708,6 +703,7 @@
"Authorization header": "Authorization header",
"Tool allowlist": "Tool allowlist",
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Use Tavily preset": "Use Tavily preset",
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
@@ -952,7 +948,6 @@
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"No document": "No document",
"You": "You",
"What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
@@ -982,9 +977,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",
@@ -1130,29 +1122,11 @@
"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",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…",
"Send": "Send",
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Stop": "Stop",
"Chat menu": "Chat menu",
"No chats yet.": "No chats yet.",
@@ -1188,10 +1162,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.",
@@ -1225,44 +1195,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)",
"Dictation language": "Dictation language",
"Auto-detect": "Auto-detect",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
"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

@@ -119,8 +119,6 @@
"Name": "Имя",
"New email": "Новый электронный адрес",
"New page": "Новая страница",
"New note": "Новая заметка",
"Create in space": "Создать в пространстве",
"New password": "Новый пароль",
"No group found": "Группа не найдена",
"No page history saved yet.": "История страниц ещё не сохранена.",
@@ -185,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.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
@@ -394,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": "Блок формулы",
@@ -481,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": "Удалить общий доступ",
@@ -670,38 +659,6 @@
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
"Ask a question…": "Задайте вопрос…",
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
"Universal assistant": "Универсальный ассистент",
"You": "Вы",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Thinking": "Думаю",
"Ask a question...": "Задайте вопрос...",
@@ -957,7 +914,6 @@
"Try a different search term.": "Попробуйте другой поисковый запрос.",
"Try again": "Попробовать снова",
"Untitled chat": "Чат без названия",
"No document": "Без документа",
"What can I help you with?": "Чем я могу вам помочь?",
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
@@ -1129,8 +1085,5 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
}

View File

@@ -1,70 +0,0 @@
// Self-host the @ricky0123/vad-web + onnxruntime-web runtime assets under
// apps/client/public/vad/.
//
// WHY THIS EXISTS:
// Both vad-web and onnxruntime-web resolve their assets by URL *at runtime* (the
// VAD audio worklet + Silero model, and ORT's wasm/mjs backend). In vad-web
// 0.0.30 the default baseAssetPath / onnxWASMBasePath is "./" — i.e. relative to
// the current page URL — NOT a CDN. In this SPA that "./" request hits the
// client-side catch-all route and gets served index.html (text/html), so the
// onnxruntime ESM/wasm backend fails to initialize ("'text/html' is not a valid
// JavaScript MIME type"). We fix that by copying the needed runtime files into
// public/vad/ and pointing both path constants at the fixed absolute "/vad/".
//
// These copies are NOT committed (the ORT wasm is ~26 MB); this script runs
// before `dev` and `build` (see package.json) to repopulate them from
// node_modules. It is idempotent: it (re)creates the dir and overwrites.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(here, "..", "public", "vad");
// vad-web exposes ./package.json, so derive its dist dir from there.
const vadDist = path.join(
path.dirname(require.resolve("@ricky0123/vad-web/package.json")),
"dist",
);
// onnxruntime-web's "exports" map does NOT expose ./package.json, so resolving
// it would throw ERR_PACKAGE_PATH_NOT_EXPORTED. It DOES export the exact asset
// subpaths we need, so resolve those files directly.
//
// ORT ships several wasm backends and which one the app bundle references depends
// on the resolver: Vite dev resolves the JSEP build (ort-wasm-simd-threaded.jsep.*)
// while the production rolldown build resolves the plain build
// (ort-wasm-simd-threaded.*). Ship BOTH variants so the runtime fetch hits a real
// file under /vad/ regardless of which the bundle picked (each .mjs proxy fetches
// its matching .wasm at init).
const ortJsepMjs = require.resolve(
"onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs",
);
const ortJsepWasm = require.resolve(
"onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm",
);
const ortMjs = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.mjs");
const ortWasm = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.wasm");
// [absolute source path, output filename]
const files = [
[path.join(vadDist, "vad.worklet.bundle.min.js"), "vad.worklet.bundle.min.js"],
[path.join(vadDist, "silero_vad_v5.onnx"), "silero_vad_v5.onnx"],
[ortJsepMjs, "ort-wasm-simd-threaded.jsep.mjs"],
[ortJsepWasm, "ort-wasm-simd-threaded.jsep.wasm"],
[ortMjs, "ort-wasm-simd-threaded.mjs"],
[ortWasm, "ort-wasm-simd-threaded.wasm"],
];
fs.mkdirSync(outDir, { recursive: true });
for (const [src, name] of files) {
if (!fs.existsSync(src)) {
console.error(`[copy-vad-assets] missing source: ${src}`);
process.exit(1);
}
fs.copyFileSync(src, path.join(outDir, name));
console.log(`[copy-vad-assets] ${name}`);
}

View File

@@ -13,7 +13,6 @@
text-decoration: none;
color: inherit;
cursor: pointer;
user-select: none;
}
.brandIcon {
@@ -34,3 +33,21 @@
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
margin-bottom: rem(7px);
}
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}

View File

@@ -10,6 +10,7 @@ import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom, useSetAtom } from "jotai";
import {
desktopSidebarAtom,
@@ -29,6 +30,10 @@ import {
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [
{ link: APP_ROUTE.HOME, label: "Home" },
];
export function AppHeader() {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
@@ -42,6 +47,12 @@ export function AppHeader() {
// AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{t(link.label)}
</Link>
));
return (
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
@@ -86,6 +97,10 @@ export function AppHeader() {
</Text>
</Tooltip>
</Group>
<Group ml="xl" gap={5} className={classes.links} visibleFrom="sm">
{items}
</Group>
</Group>
<div>

View File

@@ -27,7 +27,7 @@ export default function Aside() {
switch (tab) {
case "comments":
component = <CommentListWithTabs onClose={closeAside} />;
component = <CommentListWithTabs />;
title = "Comments";
break;
case "toc":
@@ -44,27 +44,26 @@ export default function Aside() {
}
return (
<Box p={0} style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component &&
(tab === "comments" ? (
component
) : (
<>
<Group justify="space-between" wrap="nowrap" mb="sm">
<Title order={2} size="h6" fw={500}>
{t(title)}
</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
<Group justify="space-between" wrap="nowrap" mb="md">
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
{tab === "comments" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
@@ -72,8 +71,9 @@ export default function Aside() {
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
</>
))}
)}
</>
)}
</Box>
);
}

View File

@@ -94,12 +94,12 @@ export default function GlobalAppShell({
}}
aside={
isPageRoute && {
width: 420,
width: 350,
breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}
}
padding={{ base: "xs", sm: "md" }}
padding="md"
>
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
@@ -138,7 +138,7 @@ export default function GlobalAppShell({
id={ASIDE_PANEL_ID}
tabIndex={-1}
className={classes.aside}
p="sm"
p="md"
withBorder={false}
aria-label={
asideTab === "comments"

View File

@@ -20,29 +20,18 @@ import {
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link, useMatch } from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
export default function TopMenu() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
const { colorScheme, setColorScheme } = useMantineColorScheme();
// Detect the currently viewed space so the "Space settings" item is only
// offered while the user is inside a space. The "/*" splat also matches the
// bare "/s/:spaceSlug" route (the splat matches an empty segment).
const spaceMatch = useMatch("/s/:spaceSlug/*");
const spaceSlug = spaceMatch?.params?.spaceSlug;
const [
spaceSettingsOpened,
{ open: openSpaceSettings, close: closeSpaceSettings },
] = useDisclosure(false);
const user = currentUser?.user;
const workspace = currentUser?.workspace;
@@ -52,143 +41,124 @@ export default function TopMenu() {
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
<Group gap={7} wrap={"nowrap"}>
<CustomAvatar
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
<Group gap={7} wrap={"nowrap"}>
<CustomAvatar
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}
</Text>
<IconChevronDown size={16} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<IconChevronDown size={16} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Text size="xs" c="dimmed" truncate="end">
{user.email}
</Text>
</div>
</Group>
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
{t("My profile")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
{t("My preferences")}
</Menu.Item>
{spaceSlug && (
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={openSpaceSettings}
leftSection={<IconSettings size={16} />}
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Space settings")}
{t("Light")}
</Menu.Item>
)}
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Divider />
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text size="xs" c="dimmed" truncate="end">
{user.email}
</Text>
</div>
</Group>
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
{t("My profile")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
{t("My preferences")}
</Menu.Item>
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Light")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{spaceSlug && (
<SpaceSettingsModal
spaceId={spaceSlug}
opened={spaceSettingsOpened}
onClose={closeSpaceSettings}
/>
)}
</>
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -20,6 +20,7 @@ import {
prefetchSpaces,
prefetchWorkspaceMembers,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
@@ -140,6 +141,8 @@ export default function SettingsSidebar() {
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>
<AppVersion />
</div>
);
}

View File

@@ -27,7 +27,6 @@ export function BrandLogo({
src={src}
alt="Gitmost"
className={className}
draggable={false}
style={{ height, width: "auto", display: "block", userSelect: "none" }}
/>
);

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,6 @@ import {
useRef,
useState,
} from "react";
import { generateId } from "ai";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
@@ -19,31 +18,24 @@ 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";
import {
AI_CHATS_RQ_KEY,
AI_CHAT_MESSAGES_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";
@@ -110,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
@@ -136,51 +122,17 @@ export default function AiChatWindow() {
// can adopt it once the chat list refreshes after the first turn finishes.
const adoptNewChat = useRef(false);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// Mount key for ChatThread + the chat the currently-mounted thread represents.
// `threadKey` normally tracks the active chat, so selecting a different chat
// (incl. from page history) remounts and re-seeds. The ONE exception is
// in-place adoption of a brand-new chat's server id: the adopt effect moves
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
// check below does not fire and the SAME thread stays mounted (its useChat
// already holds the just-finished turn) instead of being re-seeded from
// not-yet-persisted history.
const [threadKey, setThreadKey] = useState<string>(
() => activeChatId ?? `new-${generateId()}`,
);
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
activeChatId,
);
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),
});
@@ -189,28 +141,18 @@ export default function AiChatWindow() {
: null;
const startNewChat = useCallback((): void => {
// Cancel any pending adoption so a just-finished new chat can't yank the user
// back here after they explicitly started a fresh one.
adoptNewChat.current = false;
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 => {
// Cancel any pending adoption so it can't override an explicit selection.
adoptNewChat.current = false;
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
// Reset the card-picked role so a stale pick can't leak into the existing
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft, setSelectedRoleId],
[setActiveChatId, setDraft],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
@@ -220,18 +162,6 @@ export default function AiChatWindow() {
const onTurnFinished = useCallback(() => {
if (activeChatId === null) adoptNewChat.current = true;
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
// Re-sync the persisted message rows for the active chat so the Markdown
// export and the token counters reflect the turn that just finished. The
// live thread renders from its own useChat store (stable threadKey / store
// id), so refetching these rows never re-seeds or tears down the open
// thread. For a brand-new chat activeChatId is still null here; that chat's
// first row load happens right after id adoption, and every later turn hits
// this invalidation with the adopted id.
if (activeChatId) {
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
});
}
}, [activeChatId, queryClient]);
// The active chat object (for its title) and an export gate: only enable the
@@ -242,18 +172,6 @@ export default function AiChatWindow() {
);
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
// The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
}
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
return picked ? { name: picked.name, emoji: picked.emoji } : null;
}, [activeChat, enabledRoles, selectedRoleId]);
// Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the
// feedback.
@@ -276,54 +194,15 @@ export default function AiChatWindow() {
const newest = chats?.items?.[0];
if (newest) {
adoptNewChat.current = false;
// In-place adoption: move the active chat AND the live-thread marker to the
// new id together, so the threadKey derivation below sees no "switch" and
// keeps the SAME mounted thread (its useChat already holds the finished
// turn) instead of remounting and re-seeding from not-yet-persisted history.
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
// render so the render-phase guard never observes the new activeChatId with
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
// batching inside this effect callback guarantees that; if the store/atom
// mechanism ever changes, gate adoption on an explicit flag instead.
setLiveThreadChatId(newest.id);
setActiveChatId(newest.id);
}
}, [chats, setActiveChatId]);
// Adjust the derived thread state during render when the active chat genuinely
// changes — the React-sanctioned alternative to an effect (it re-renders before
// paint, no extra commit, and converges since the next render finds them equal).
// In-place adoption of a new chat's id never reaches here because the adopt
// effect moves liveThreadChatId in lockstep with activeChatId.
if (activeChatId !== liveThreadChatId) {
setLiveThreadChatId(activeChatId);
setThreadKey(activeChatId ?? `new-${generateId()}`);
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages
// invalidation, which can transiently flip hasNextPage for a chat whose
// message count is an exact multiple of the server page size) does not tear
// the live thread down to a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
threadKey === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so we keep showing the live thread instead of unmounting it behind a
// loader; and once a chat's history has loaded, a later background refetch no
// longer tears the thread back down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
threadKey === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
// The thread is remounted when the active chat changes so initial messages
// re-seed. For a new chat we key on "new"; adopting the id remounts the
// thread with the persisted history loaded.
const threadKey = activeChatId ?? "new";
const waitingForHistory = activeChatId !== null && messagesLoading;
// Current context size for the active chat: how much the conversation now
// occupies in the model's context window — NOT the cumulative tokens spent.
@@ -359,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.
@@ -431,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.
@@ -489,50 +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 (emoji + name). Shows the persisted role of an existing
chat, or the role picked via a card for a brand-new chat. Hidden for
a universal (no-role) chat. */}
{currentRole && (
<span className={classes.badge} title={t("Agent role")}>
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
{currentRole.name}
</span>
)}
<span className={classes.title}>{t("AI chat")}</span>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
@@ -632,11 +441,6 @@ export default function AiChatWindow() {
)}
</div>
{/* The role picker for a NEW chat is rendered as the chat's empty-state
(colored role cards centered in the empty window) by ChatThread
itself — clicking a card starts the chat with that role. Once the
chat exists, its role is fixed and shown as a header badge instead. */}
{/* body: active chat thread */}
<div className={classes.body}>
{waitingForHistory ? (
@@ -649,13 +453,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}
// Role cards are the new-chat empty-state; offered only when this
// is a brand-new chat. Clicking a card starts the chat with it.
roles={activeChatId === null ? enabledRoles : undefined}
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
/>
)}

View File

@@ -88,18 +88,16 @@
opacity: 0.4;
}
40% {
/* Bounce height is driven by --bounce so reduced-motion can dampen it
(below) without disabling the animation outright. */
transform: translateY(var(--bounce, -6px));
transform: translateY(-3px);
opacity: 1;
}
}
/* Respect reduced-motion preferences: keep a smaller bounce instead of a full
stop, so the "thinking" indicator still reads as active rather than frozen. */
/* Respect reduced-motion preferences: fall back to a static dimmed state. */
@media (prefers-reduced-motion: reduce) {
.typingDots span {
--bounce: -3px;
animation: none;
opacity: 0.6;
}
}
@@ -128,29 +126,3 @@
.conversationItemActive {
background: var(--mantine-color-gray-light);
}
/* Pending messages queued by the user while a turn is still streaming. They
are sent automatically, FIFO, once the current turn finishes. */
.queuedList {
padding-bottom: var(--mantine-spacing-xs);
}
.queuedItem {
background: var(--mantine-color-gray-light);
border-radius: var(--mantine-radius-sm);
padding: 4px 8px;
}
.queuedIcon {
flex: none;
color: var(--mantine-color-dimmed);
}
.queuedText {
flex: 1;
min-width: 0;
color: var(--mantine-color-dimmed);
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}

View File

@@ -9,24 +9,18 @@ import { MicButton } from "@/features/dictation/components/mic-button";
interface ChatInputProps {
onSend: (text: string) => void;
/** Called instead of `onSend` while a turn is streaming: the text is queued
* and sent automatically once the current turn finishes. */
onQueue: (text: string) => void;
onStop: () => void;
isStreaming: boolean;
disabled?: boolean;
}
/**
* Message composer. Enter submits, Shift+Enter inserts a newline. While the
* agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
* dropping it — it is sent automatically once the current turn finishes; the
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
* user can draft / queue the next turn while the agent is busy.
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
* is streaming, the send button becomes a Stop button (calls `stop()`); the
* textarea stays usable so the user can draft the next turn.
*/
export default function ChatInput({
onSend,
onQueue,
onStop,
isStreaming,
disabled,
@@ -36,18 +30,17 @@ export default function ChatInput({
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const submit = (): void => {
const send = (): void => {
const text = value.trim();
if (!text || disabled) return;
if (isStreaming) onQueue(text);
else onSend(text);
if (!text || isStreaming || disabled) return;
onSend(text);
setValue("");
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
send();
}
};
@@ -71,43 +64,28 @@ export default function ChatInput({
{isDictationEnabled && (
<MicButton
size="lg"
streaming
disabled={isStreaming || disabled}
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
/>
)}
{isStreaming ? (
<Group gap="xs" wrap="nowrap">
{value.trim().length > 0 && (
<Tooltip label={t("Send when the agent finishes")} withArrow>
<ActionIcon
size="lg"
variant="filled"
onClick={submit}
aria-label={t("Queue message")}
>
<IconSend size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Stop")} withArrow>
<ActionIcon
size="lg"
color="red"
variant="light"
onClick={onStop}
aria-label={t("Stop")}
>
<IconPlayerStopFilled size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Tooltip label={t("Stop")} withArrow>
<ActionIcon
size="lg"
color="red"
variant="light"
onClick={onStop}
aria-label={t("Stop")}
>
<IconPlayerStopFilled size={18} />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label={t("Send")} withArrow>
<ActionIcon
size="lg"
variant="filled"
onClick={submit}
onClick={send}
disabled={disabled || value.trim().length === 0}
aria-label={t("Send")}
>

View File

@@ -1,24 +1,14 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useMemo, useRef } from "react";
import { generateId } from "ai";
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
import { Alert, Box, Stack } from "@mantine/core";
import { IconAlertTriangle } 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 ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import {
dequeue,
enqueueMessage,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
/** The page the user is currently viewing, sent as chat context. */
@@ -35,19 +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;
/** Enabled roles for the new-chat empty state (only meaningful when
* `chatId === null`). Rendered as the colored role cards. */
roles?: IAiRole[];
/** Notify the parent which role was picked via a card, so it can update the
* header badge / assistant name for the brand-new chat. */
onRolePicked?: (role: IAiRole) => void;
/** Display name for the assistant label / typing line (the role name);
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
@@ -84,10 +61,6 @@ export default function ChatThread({
chatId,
initialRows,
openPage,
roleId,
roles,
onRolePicked,
assistantName,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -111,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
@@ -135,55 +102,7 @@ export default function ChatThread({
// The id only needs to be stable per mount — the parent remounts this via
// `key` on chat switch, which re-seeds cleanly.
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
// Stable for the LIFETIME of this mount. When a brand-new chat adopts its
// server id, the parent now updates the `chatId` prop WITHOUT remounting this
// thread, so the store id must NOT follow `chatId`: recreating the useChat
// store would wipe the live (just-finished) turn. The server still resolves
// the real chat from `chatId` in the request body (see chatIdRef /
// prepareSendMessagesRequest), so this purely-client store key can stay fixed.
const chatStoreId = stableIdRef.current;
// Pending messages the user composed WHILE a turn was streaming. They are sent
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
// LOCAL state so it is scoped to this conversation: it is cleared when the user
// deliberately switches chat / starts a new chat (the parent remounts this via
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
// message queued during a brand-new chat's first turn is not lost. On Stop or
// error the queue is intentionally preserved (onFinish does not fire then) so
// the user decides what to do with the pending messages.
const [queued, setQueued] = useState<QueuedMessage[]>([]);
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
const queuedRef = useRef<QueuedMessage[]>([]);
const setQueue = useCallback((next: QueuedMessage[]) => {
queuedRef.current = next;
setQueued(next);
}, []);
// Capture the latest `sendMessage` (returned by useChat below) so the flush
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
}, [setQueue]);
const enqueue = useCallback(
(text: string) => {
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
},
[setQueue],
);
const removeQueued = useCallback(
(id: string) => {
setQueue(removeQueuedById(queuedRef.current, id));
},
[setQueue],
);
const chatStoreId = chatId ?? stableIdRef.current;
const transport = useMemo(
() =>
@@ -200,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,
},
}),
@@ -217,107 +133,37 @@ export default function ChatThread({
id: chatStoreId,
messages: initialMessages,
transport,
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
// (chat-list refresh + new-chat id adoption must happen even on a failed
// first turn), but flush the pending queue ONLY on a clean finish: auto-
// sending after the user hit Stop — or blindly retrying after a failure —
// would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
if (isAbort || isDisconnect || isError) return;
flushNext();
},
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
// Log the raw failure here for devtools; the UI shows a friendly classified
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
// the onFinish call) so a brand-new chat that fails its first turn is adopted
// and the chat list refreshes immediately rather than after a manual refresh.
onError: (streamError) => {
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
console.error("AI chat stream error:", streamError);
onTurnFinished();
},
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(),
});
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
const isStreaming = status === "submitted" || status === "streaming";
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
return (
<Box className={classes.panel}>
<MessageList
messages={messages}
isStreaming={isStreaming}
emptyState={roleCardsEmptyState}
assistantName={assistantName}
/>
<MessageList messages={messages} isStreaming={isStreaming} />
{errorView && (
{error && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mb="xs"
title={errorView.title}
title={t("Something went wrong")}
>
{errorView.detail}
{describeChatError(error.message ?? "", t)}
</Alert>
)}
<Stack gap={0} className={classes.inputWrapper}>
{queued.length > 0 && (
<Stack gap={4} className={classes.queuedList}>
{queued.map((m) => (
<Group
key={m.id}
gap={6}
wrap="nowrap"
className={classes.queuedItem}
>
<IconClockHour4 size={14} className={classes.queuedIcon} />
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => removeQueued(m.id)}
aria-label={t("Remove queued message")}
>
<IconX size={12} />
</ActionIcon>
</Group>
))}
</Stack>
)}
<ChatInput
onSend={(text) => sendMessage({ text })}
onQueue={enqueue}
onStop={stop}
isStreaming={isStreaming}
/>

View File

@@ -18,31 +18,8 @@ import {
useRenameAiChatMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
/**
* The dimmed second line of a chat row: how long ago the chat was created and
* the document it was created in. Its own component so the self-updating
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
*/
function ChatMetaLine({
createdAt,
pageTitle,
}: {
createdAt: string;
pageTitle?: string | null;
}) {
const { t } = useTranslation();
const ago = useTimeAgo(createdAt);
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
return (
<Text size="xs" c="dimmed" lineClamp={1}>
{ago} · {pageTitle || t("No document")}
</Text>
);
}
interface ConversationListProps {
activeChatId: string | null;
onSelect: (chatId: string) => void;
@@ -150,24 +127,9 @@ export default function ConversationList({
}
}}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={4} wrap="nowrap" style={{ 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>
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
</Box>
<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

@@ -5,29 +5,11 @@ 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 { 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;
}
/**
@@ -42,12 +24,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 +45,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 +53,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 +73,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;
@@ -114,18 +83,14 @@ export default function MessageItem({
{(() => {
const errorText = (message.metadata as { error?: string } | undefined)?.error;
if (!errorText) return null;
// Same classified-error banner as the live chat: a heading naming the
// cause plus a one-line detail.
const errorView = describeChatError(errorText, t);
return (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mt={4}
title={errorView.title}
>
{errorView.detail}
{describeChatError(errorText, t)}
</Alert>
);
})()}

View File

@@ -1,41 +1,15 @@
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, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.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;
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -43,38 +17,23 @@ interface MessageListProps {
const BOTTOM_THRESHOLD = 40;
/**
* Whether to show the standalone "Thinking…" indicator. It bridges every
* gap in a turn where the assistant is working but nothing visible is actively
* being produced yet — so it shows while a turn is in flight AND the latest
* assistant message's LAST part is not live output:
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
* gap between sending and the first streamed content, so it shows only while a
* turn is in flight AND the latest assistant message has nothing visible yet:
* - the last message is still the user's (assistant hasn't started a row), or
* - the assistant row has no parts yet, or
* - its last part is an empty/whitespace text part, or
* - its last part is a finished/errored tool (the model is thinking about the
* next step between tool calls).
* It hides only while output is actively rendering: a non-empty streaming text
* part, or a tool that is still running (ToolCallCard shows its own Loader).
* - 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.
if (last.role !== "assistant") return true; // assistant row not started.
const lastPart = last.parts[last.parts.length - 1];
if (!lastPart) return true; // assistant row exists but has no parts yet.
// The answer text is actively streaming in -> MessageItem renders it; no dots.
if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false;
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
if (
isToolPart(lastPart.type) &&
toolRunState((lastPart as unknown as ToolUiPart).state) === "running"
) {
return false;
}
// Otherwise the turn is in flight but nothing is actively producing visible
// output yet: a finished/errored tool with no follow-up content, or an empty
// trailing text part. The model is thinking between steps -> show the dots.
return true;
const hasVisible = last.parts.some(
(p) =>
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type),
);
return !hasVisible;
}
/**
@@ -82,14 +41,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
@@ -152,11 +104,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>
);
}
@@ -165,15 +115,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,65 +0,0 @@
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* flex-start keeps the first row reachable when the wrapped cards overflow and
the container scrolls. With align-content: center, an overflowing top row is
pushed out of the scrollable area and becomes unreachable. The parent Mantine
Center still vertically centers the whole block when it fits. */
align-content: flex-start;
gap: 10px;
/* Cap the height so a large number of roles scrolls instead of blowing out
the empty chat area. */
max-height: 100%;
overflow-y: auto;
padding: 8px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
/* Grow to fill the row so cards use the available window width instead of
leaving large side gaps; the flex-basis sets how many fit per row before
wrapping (≈2 columns at the default window width, more as it widens). */
flex: 1 1 240px;
min-width: 200px;
max-width: 360px;
min-height: 90px;
padding: 12px 10px;
border-radius: var(--mantine-radius-md);
border: 2px solid transparent;
cursor: pointer;
text-align: center;
transition:
transform 120ms ease,
box-shadow 120ms ease,
border-color 120ms ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
}
.emoji {
font-size: 22px;
line-height: 1;
}
/* The description: small and slightly muted, inheriting the card's color. We
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
with the card's inline color. */
.description {
opacity: 0.8;
line-height: 1.3;
/* Break long unbreakable tokens (URLs, long foreign words) in the
admin-configured description so they wrap instead of overflowing the card
width now that the line clamp no longer caps the text. */
overflow-wrap: anywhere;
}

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RoleCards from "./role-cards";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
// does not implement. Provide a minimal stub so the provider can render.
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
});
const roles: IAiRole[] = [
{
id: "r1",
name: "Pirate",
emoji: "🏴‍☠️",
description: "Talks like a pirate",
enabled: true,
},
{
id: "r2",
name: "Grandpa",
emoji: null,
description: null,
enabled: true,
},
];
function renderCards(onPick = vi.fn()) {
render(
<MantineProvider>
<RoleCards roles={roles} onPick={onPick} />
</MantineProvider>,
);
return onPick;
}
describe("RoleCards", () => {
it("renders one card per role with name, emoji, and description", () => {
renderCards();
expect(screen.getByText("Pirate")).toBeDefined();
expect(screen.getByText("Talks like a pirate")).toBeDefined();
expect(screen.getByText("Grandpa")).toBeDefined();
// The emoji is shown for the role that has one.
expect(screen.getByText("🏴‍☠️")).toBeDefined();
});
it("does NOT render a Universal assistant card", () => {
renderCards();
expect(screen.queryByText("Universal assistant")).toBeNull();
});
it("calls onPick with the role object when a card is clicked", () => {
const onPick = renderCards();
fireEvent.click(screen.getByText("Pirate"));
expect(onPick).toHaveBeenCalledWith(roles[0]);
});
});

View File

@@ -1,78 +0,0 @@
import { UnstyledButton, Text } from "@mantine/core";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
import classes from "@/features/ai-chat/components/role-cards.module.css";
interface RoleCardsProps {
/** The enabled roles to render (one card each). */
roles: IAiRole[];
/** Called with the picked role when a card is clicked. The parent starts the
* chat with this role (binds it and sends the opening message). */
onPick: (role: IAiRole) => void;
}
/**
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
* they render correctly in both light and dark themes; the CSS module owns only
* the layout. The card shows the emoji (if any), the role name, and a small
* dimmed description line (if any).
*/
function RoleCard({
color,
name,
emoji,
description,
onClick,
}: {
color: string;
name: string;
emoji?: string | null;
description?: string | null;
onClick: () => void;
}) {
return (
<UnstyledButton
className={classes.card}
style={{
backgroundColor: `var(--mantine-color-${color}-light)`,
color: `var(--mantine-color-${color}-light-color)`,
}}
title={description ?? name}
onClick={onClick}
>
{emoji && <span className={classes.emoji}>{emoji}</span>}
<Text size="sm" fw={600} lineClamp={2}>
{name}
</Text>
{description && (
<Text size="xs" className={classes.description}>
{description}
</Text>
)}
</UnstyledButton>
);
}
/**
* Colored role cards rendered as the empty-state of a brand-new chat. There is
* no Universal assistant card — the universal assistant is the implicit default
* the user gets by simply typing into the composer without picking a card.
* Clicking a card immediately STARTS the chat with that role (the parent binds
* the role to the new chat and sends the opening message).
*/
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
return (
<div className={classes.container}>
{roles.map((role, index) => (
<RoleCard
key={role.id}
color={roleCardColor(index)}
name={role.name}
emoji={role.emoji}
description={role.description}
onClick={() => onPick(role)}
/>
))}
</div>
);
}

View File

@@ -1,85 +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 "Thinking…" 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);
});
it("shows while streaming after a tool has finished (thinking between steps)", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [doneTool])], true),
).toBe(true);
});
it("shows while streaming when a finished tool is the last part after some text", () => {
const text = { type: "text", text: "Let me check" } as unknown as UIMessage["parts"][number];
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [text, doneTool])], true),
).toBe(true);
});
it("hides while a tool is still running", () => {
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [runningTool])], true),
).toBe(false);
});
it("hides once the assistant streams non-empty text after a finished tool", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
const text = { type: "text", text: "The answer is 42" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [doneTool, text])], 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,37 +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 dimmed label uses the configured
* identity name when provided (otherwise the generic "AI agent"), while the
* typing line is always the generic "Thinking…" (it never includes the
* role/identity name).
* 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">
@@ -40,7 +26,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
<span />
</span>
<Text size="sm" c="dimmed">
{t("Thinking…")}
{t("AI agent is typing…")}
</Text>
</Group>
</Box>

View File

@@ -4,30 +4,22 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
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,
@@ -75,31 +67,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
enabled: !!chatId,
});
// useInfiniteQuery only fetches the first page on its own. The hook's contract
// (and both the Markdown export and the model-history seed) require the
// COMPLETE thread, so keep pulling subsequent pages until the server reports
// none remain. The isFetchingNextPage guard issues one request at a time;
// when chatId is undefined the query is disabled and hasNextPage is false, so
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
// and isFetchingNextPage false — without this guard the effect would re-fire
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
// latches the last next-page failure and clears once a fetch succeeds.
useEffect(() => {
if (
query.hasNextPage &&
!query.isFetchingNextPage &&
!query.isFetchNextPageError
) {
void query.fetchNextPage();
}
}, [
query.hasNextPage,
query.isFetchingNextPage,
query.isFetchNextPageError,
query.fetchNextPage,
]);
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
if (!query.data) return undefined;
return query.data.pages.flatMap((p) => p.items);
@@ -147,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,69 +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;
// The document the chat was created in (ai_chats.page_id). Null when started
// outside any document.
pageId?: string | null;
// Denormalized via a JOIN in the chat list response: the origin page's title.
// Null when there is no origin page (or it was hard-deleted).
pageTitle?: 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,317 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Tests for the client-only Markdown export builder. The output embeds a live
* `new Date().toISOString()` export timestamp; we never assert that value, only
* the deterministic structure (headings, numbering, fenced blocks, totals).
*
* A pass-through translator keeps role/tool labels predictable so the
* structural assertions are stable without an i18n runtime.
*/
const t = (key: string, values?: Record<string, unknown>): string => {
if (values && typeof values.name === "string") {
return key.replace("{{name}}", values.name);
}
return key;
};
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
return {
id: partial.id ?? "id",
role: partial.role ?? "user",
content: partial.content ?? null,
metadata: partial.metadata ?? null,
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
};
}
describe("buildChatMarkdown — structure", () => {
it("emits the title heading, chat id and message count", () => {
const md = buildChatMarkdown({
title: "My chat",
chatId: "chat-123",
rows: [],
t,
});
expect(md).toContain("# My chat");
expect(md).toContain("- Chat ID: `chat-123`");
expect(md).toContain("- Messages: 0");
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
});
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
expect(
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
expect(
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
});
it("numbers rows sequentially with role headings", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({ role: "assistant", content: "hello" }),
row({ role: "user", content: "again" }),
],
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("## 3. You");
// Heading numbering is strictly index+1, not e.g. role-relative.
expect(md).not.toContain("## 0.");
});
it("renders the per-row text content from `content` when no metadata.parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "plain body" })],
t,
});
expect(md).toContain("plain body");
});
});
describe("buildChatMarkdown — text parts", () => {
it("skips empty / whitespace-only text parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "ignored-content",
metadata: {
parts: [
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "kept line" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
},
}),
],
t,
});
expect(md).toContain("kept line");
// Whitespace-only part contributed no block of its own.
expect(md).not.toContain(" \n\n");
// When metadata.parts exists, the plain `content` fallback is NOT used.
expect(md).not.toContain("ignored-content");
});
});
describe("buildChatMarkdown — tool parts", () => {
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: { pageId: "p1" },
output: { id: "p1", title: "Home" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// Known tool name maps to its label key; raw name in backticks; done state.
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
expect(md).toContain("Input:");
expect(md).toContain("Output:");
// Fenced JSON blocks contain the stringified payloads.
expect(md).toContain('"pageId": "p1"');
expect(md).toContain('"title": "Home"');
expect(md).toContain("```json");
});
it("renders the generic label for an unknown tool and surfaces errorText", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-mysteryTool",
state: "output-error",
input: { a: 1 },
errorText: "boom",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
expect(md).toContain("**Error:** boom");
});
it("does not throw on a circular tool input (falls back to String)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circular: any = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "input-available",
input: circular,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
}),
).not.toThrow();
});
});
describe("buildChatMarkdown — fence anti-breakout", () => {
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
// Tool input whose stringified string form contains a literal ``` run.
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
// A bare string passes through stringify() verbatim.
input: "before ``` after",
output: "x",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// The fence around the 3-backtick content must use at least 4 backticks so
// the embedded ``` run cannot terminate the block.
expect(md).toContain("````json\nbefore ``` after\n````");
// Robust anti-breakout check: the opening fence delimiter is strictly
// longer than the longest backtick run inside the wrapped content. (A naive
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
// textually contains the 3-backtick substring.)
const open = md.match(/(`{3,})json\nbefore/);
expect(open).not.toBeNull();
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
});
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: "a ```` b",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("`````json\na ```` b\n`````");
});
});
describe("buildChatMarkdown — token totals", () => {
it("prints the total-tokens line only when the summed usage is > 0", () => {
const withTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(withTokens).toContain("- Total tokens: 15");
// Per-row usage footer too.
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
});
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
const noTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
}),
],
t,
});
expect(noTokens).not.toContain("- Total tokens:");
});
it("uses totalTokens when present rather than summing in/out", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
}),
],
t,
});
expect(md).toContain("- Total tokens: 99");
});
});

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,168 +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('maps a {"statusCode":403} body to the disabled heading', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toEqual({
title: "AI chat is disabled",
detail: "AI chat is disabled for this workspace.",
});
});
it('maps a {"statusCode":503} body to the not-configured heading', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toEqual({
title: "AI provider not configured",
detail:
"The AI provider is not configured. Ask an administrator to set it up.",
});
});
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
expect(
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
).toBe("Lost connection to the AI provider");
});
it('classifies "fetch failed" as a lost-connection error', () => {
expect(describeChatError("fetch failed", t).title).toBe(
"Lost connection to the AI provider",
);
});
it("classifies ETIMEDOUT as a timeout", () => {
expect(describeChatError("ETIMEDOUT", t).title).toBe(
"The AI provider timed out",
);
});
it('classifies "504: Gateway Timeout" as a timeout', () => {
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
"The AI provider timed out",
);
});
it('classifies "429: Too Many Requests" as rate limited', () => {
expect(describeChatError("429: Too Many Requests", t).title).toBe(
"Rate limited by the AI provider",
);
});
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
// Regression intent: a provider message mentioning the number 403 must never
// be folded into the "AI chat is disabled" gating heading. Here the 429
// signature wins (checked before any bare-403 logic exists), so it maps to
// the rate-limit category instead.
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it("classifies a context-window overflow as too-large", () => {
expect(
describeChatError(
"This model's maximum context length is 128000 tokens",
t,
).title,
).toBe("The conversation is too large");
});
it('classifies "402: Insufficient credits" as quota exceeded', () => {
expect(describeChatError("402: Insufficient credits", t).title).toBe(
"AI provider quota exceeded",
);
});
it('classifies "401: Unauthorized" as an auth failure', () => {
expect(describeChatError("401: Unauthorized", t).title).toBe(
"AI provider authentication failed",
);
});
it("falls back to the generic heading + detail for empty input", () => {
expect(describeChatError("", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
expect(describeChatError("418: I'm a teapot", t)).toEqual({
title: "Something went wrong",
detail: "418: I'm a teapot",
});
});
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
// The real status (500) leads the string; the "401" lives in the snippet and
// must not trigger the auth category. The verbatim provider text is surfaced.
const body =
"500: Server error | response body: model gpt-4o-401-preview not found";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it("does NOT treat a passing mention of billing as a quota error", () => {
// "billing" is no longer a quota signature; the verbatim text is surfaced.
const body = "502: Bad Gateway | response body: see our billing page";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
// The textual rate-limit phrase lives only in the response-body snippet, and
// the leading 500 is not a classified numeric code, so it must not leak into
// the rate-limit category. (The detail itself falls back to the generic line
// here because the leading message contains "Internal Server Error", which
// providerDetail suppresses — the title is what this case pins.)
const body =
"500: Internal Server Error | response body: rate limit info: see our docs";
expect(describeChatError(body, t).title).toBe("Something went wrong");
expect(describeChatError(body, t).title).not.toBe(
"Rate limited by the AI provider",
);
});
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
// The 503 leads the string but is not a classified numeric code, and the
// ETIMEDOUT signature appears only in the body, so it must not leak into the
// timeout category; the verbatim text is surfaced under the generic heading.
const body = "503: x | response body: ETIMEDOUT appears in this log line";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
expect(describeChatError(body, t).title).not.toBe(
"The AI provider timed out",
);
});
});

View File

@@ -1,174 +1,24 @@
/**
* A classified AI chat error: a short bold heading naming the cause category and
* a one-line human-readable detail / next step. Both strings are already passed
* through `t`, so callers render them directly.
*/
export interface ChatErrorView {
title: string;
detail: string;
}
/**
* Turn an AI chat error message into a friendly heading + detail. Used for BOTH
* the live `useChat().error` (its `.message`) and a persisted assistant error in
* `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
* body carrying a numeric "statusCode" (matched precisely, not by bare substring,
* so a provider message that merely contains "403"/"503" is never misclassified).
* Known provider/network failures (connection reset, timeout, rate limit, context
* overflow, quota, auth) are mapped to a clear category; anything else falls back
* to the raw provider detail (or a generic line) under the original heading.
* Turn an AI chat error message into a friendly inline string. Used for BOTH the
* live `useChat().error` (its `.message`) and a persisted assistant error stored
* in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
* body carrying a numeric "statusCode" field (matched precisely, not by bare
* substring, so a provider message that merely contains "403"/"503"/"disabled" is
* never misclassified). Everything else — provider stream failures forwarded as
* "<status>: <message>" (402 credits, 429 rate limit, ...) — is surfaced verbatim.
*/
export function describeChatError(
message: string,
t: (key: string) => string,
): ChatErrorView {
): string {
const msg = message ?? "";
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
return {
title: t("AI chat is disabled"),
detail: t("AI chat is disabled for this workspace."),
};
return t("AI chat is disabled for this workspace.");
}
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
return {
title: t("AI provider not configured"),
detail: t(
"The AI provider is not configured. Ask an administrator to set it up.",
),
};
return t("The AI provider is not configured. Ask an administrator to set it up.");
}
const category = classifyProviderError(msg);
if (category) {
return { title: t(category.title), detail: t(category.detail) };
}
// Unknown error: surface the raw provider detail when it is informative,
// otherwise a generic line. The heading stays the original generic one.
return {
title: t("Something went wrong"),
detail:
providerDetail(msg) ??
t("The AI agent could not respond. Please try again."),
};
}
interface ErrorCategory {
/** English key for the bold heading. */
title: string;
/** English key for the one-line explanation. */
detail: string;
}
/**
* Map a provider/network error string to a friendly category. Order matters: the
* most specific signatures are tested first. Returns null when nothing matches,
* so the caller can fall back to the raw provider text. The English keys returned
* here are passed through `t` by the caller.
*
* The server formats provider errors as "<statusCode>: <message> | response body:
* <snippet>" (see server-side describeProviderError), so the HTTP status is always
* the LEADING token. We match a numeric code only when it leads the string, so a
* number inside the response-body snippet never triggers a category; textual
* signatures are matched only against the leading message (before the response
* body), so a phrase inside the snippet never triggers a category either.
*/
function classifyProviderError(msg: string): ErrorCategory | null {
const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
// The server appends "| response body: <snippet>" to provider errors; match
// textual signatures only against the leading provider message so a phrase
// inside the response-body snippet never triggers a wrong category. The numeric
// status code is read from the start of the full string above.
const head = msg.split(/\|\s*response body:/i)[0];
// The browser's OWN fetch-failure messages — WebKit/Safari "Load failed",
// Chrome "Failed to fetch", Firefox "NetworkError when attempting to fetch
// resource". These mean the streaming connection between the browser and THIS
// server (/api/ai-chat/stream) dropped mid-answer: the browser<->server link,
// NOT the server<->AI-provider link, so do NOT blame the provider. A failed
// fetch carries no status/body, so the browser has no further detail — the real
// cause is in the server logs (the stream controller logs the disconnect) and
// the reverse proxy (often buffering or timing out the long-lived SSE).
if (/failed to fetch|load failed|networkerror/i.test(head)) {
return {
title: "Lost connection to the server",
detail:
"The streaming connection to the server dropped before the answer finished. The browser reports no further detail — the cause is in the server logs and the reverse proxy (often buffering or timing out the stream). Reload and try again.",
};
}
// Connection dropped / provider unreachable. ECONNRESET is the production case:
// the LLM socket was reset mid-stream (surfaced by the server's error
// formatter). "terminated" is scoped to a connection/stream context so it does
// not match benign "... was terminated" messages.
if (
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
head,
)
) {
return {
title: "Lost connection to the AI provider",
detail:
"The connection to the AI provider dropped before the answer finished. Please try again.",
};
}
// Timeout.
if (
code === "504" ||
code === "408" ||
/ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
) {
return {
title: "The AI provider timed out",
detail: "The AI provider took too long to respond. Please try again.",
};
}
// Rate limited.
if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
return {
title: "Rate limited by the AI provider",
detail:
"The AI provider is rate-limiting requests. Wait a moment and try again.",
};
}
// Context window / token budget exceeded.
if (
code === "413" ||
/context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
head,
)
) {
return {
title: "The conversation is too large",
detail:
"The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
};
}
// Out of credits / quota / payment required.
if (
code === "402" ||
/payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
head,
)
) {
return {
title: "AI provider quota exceeded",
detail:
"The AI provider rejected the request because of credits or quota. Check the provider account.",
};
}
// Authentication / bad API key.
if (
code === "401" ||
/\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
) {
return {
title: "AI provider authentication failed",
detail:
"The AI provider rejected the credentials. Ask an administrator to check the API key.",
};
}
return null;
return providerDetail(msg) ?? t("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,107 +0,0 @@
import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
describe("enqueueMessage", () => {
it("appends a message to the end of the queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
const next = enqueueMessage(queue, { id: "b", text: "second" });
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
enqueueMessage(queue, { id: "b", text: "second" });
expect(queue).toEqual([{ id: "a", text: "first" }]);
});
});
describe("dequeue", () => {
it("returns {head:null, rest:[]} for an empty queue", () => {
expect(dequeue([])).toEqual({ head: null, rest: [] });
});
it("returns the first item as head and the remainder as rest", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const { head, rest } = dequeue(queue);
expect(head).toEqual({ id: "a", text: "first" });
expect(rest).toEqual([
{ id: "b", text: "second" },
{ id: "c", text: "third" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
dequeue(queue);
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("removeQueuedById", () => {
it("removes the matching id and leaves the others", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const next = removeQueuedById(queue, "b");
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "c", text: "third" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
expect(removeQueuedById(queue, "missing")).toEqual([
{ id: "a", text: "first" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
removeQueuedById(queue, "a");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];
queue = enqueueMessage(queue, { id: "1", text: "one" });
queue = enqueueMessage(queue, { id: "2", text: "two" });
queue = enqueueMessage(queue, { id: "3", text: "three" });
const order: string[] = [];
while (queue.length > 0) {
const { head, rest } = dequeue(queue);
if (head) order.push(head.text);
queue = rest;
}
expect(order).toEqual(["one", "two", "three"]);
});
});

View File

@@ -1,34 +0,0 @@
// Pure FIFO helpers for the AI-chat "send while the agent is busy" queue.
// Kept side-effect free so they can be unit-tested without React.
export interface QueuedMessage {
id: string;
text: string;
}
/** Append a message to the end of the queue (returns a new array). */
export function enqueueMessage(
queue: QueuedMessage[],
message: QueuedMessage,
): QueuedMessage[] {
return [...queue, message];
}
/** Split the queue into its first item (`head`) and the remainder (`rest`).
* `head` is null when the queue is empty. Does not mutate the input. */
export function dequeue(queue: QueuedMessage[]): {
head: QueuedMessage | null;
rest: QueuedMessage[];
} {
if (queue.length === 0) return { head: null, rest: [] };
const [head, ...rest] = queue;
return { head, rest };
}
/** Remove the queued message with the given id (returns a new array). */
export function removeQueuedById(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}

View File

@@ -1,23 +0,0 @@
import { describe, it, expect } from "vitest";
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
describe("roleCardColor", () => {
it("has a 10-color palette", () => {
expect(ROLE_CARD_PALETTE).toHaveLength(10);
});
it("maps index 0 to the first palette color (blue)", () => {
expect(roleCardColor(0)).toBe("blue");
expect(roleCardColor(1)).toBe("grape");
});
it("wraps around at the end of the palette", () => {
expect(roleCardColor(10)).toBe("blue");
expect(roleCardColor(11)).toBe("grape");
});
it("is safe for negative indices", () => {
expect(roleCardColor(-1)).toBe("violet");
expect(roleCardColor(-10)).toBe("blue");
});
});

View File

@@ -1,25 +0,0 @@
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
// these names by index; the colors are applied via theme-aware Mantine CSS vars
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
// Universal assistant uses neutral `gray` separately (not part of this palette).
export const ROLE_CARD_PALETTE = [
"blue",
"grape",
"teal",
"orange",
"pink",
"cyan",
"lime",
"indigo",
"red",
"violet",
] as const;
/**
* Pick a palette color name for a role card by its index. Cycles through the
* palette and is safe for negative indices.
*/
export function roleCardColor(index: number): string {
const len = ROLE_CARD_PALETTE.length;
return ROLE_CARD_PALETTE[((index % len) + len) % len];
}

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

@@ -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} lh={1.2}>
<Text size="sm" fw={500} lineClamp={1}>
{comment.creator.name}
</Text>
@@ -155,7 +155,7 @@ function CommentListItem({
</Group>
<Group gap="xs">
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
<Text size="xs" fw={500} c="dimmed">
{createdAtAgo}
</Text>
</Group>
@@ -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

@@ -11,7 +11,6 @@ import {
Badge,
Text,
ScrollArea,
Tooltip,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
@@ -27,16 +26,12 @@ import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff, IconX } from "@tabler/icons-react";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
interface CommentListWithTabsProps {
onClose?: () => void;
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
function CommentListWithTabs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
@@ -126,8 +121,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
<Paper
shadow="sm"
radius="md"
p="xs"
mb="xs"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
@@ -150,7 +145,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
{!comment.resolvedAt && canComment && (
<>
<Divider my={2} />
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
@@ -199,50 +194,28 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
overflow: "hidden",
}}
>
{/* Header row: full-width centered tab list with the close button overlaid on the right. */}
<div style={{ position: "relative" }}>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
leftSection={
<Badge size="sm" variant="light" color="blue">
{activeComments.length}
</Badge>
}
>
{t("Open")}
</Tabs.Tab>
<Tabs.Tab
value="resolved"
leftSection={
<Badge size="sm" variant="light" color="green">
{resolvedComments.length}
</Badge>
}
>
{t("Resolved")}
</Tabs.Tab>
</Tabs.List>
{onClose && (
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("Close")}
style={{
position: "absolute",
right: 0,
top: "50%",
// Nudge the close button slightly up to align with the tab labels.
transform: "translateY(calc(-50% - 4px))",
}}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
)}
</div>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
leftSection={
<Badge size="sm" variant="light" color="blue">
{activeComments.length}
</Badge>
}
>
{t("Open")}
</Tabs.Tab>
<Tabs.Tab
value="resolved"
leftSection={
<Badge size="sm" variant="light" color="green">
{resolvedComments.length}
</Badge>
}
>
{t("Resolved")}
</Tabs.Tab>
</Tabs.List>
<ScrollArea
style={{ flex: "1 1 auto" }}
@@ -392,7 +365,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
flex: "0 0 auto",
borderTop: "1px solid var(--mantine-color-default-border)",
paddingTop: "var(--mantine-spacing-sm)",
paddingBottom: 10,
paddingBottom: 25,
position: "relative",
}}
>
@@ -401,7 +374,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
size="sm"
avatarUrl={currentUser?.user?.avatarUrl}
name={currentUser?.user?.name}
style={{ flexShrink: 0, marginTop: 2 }}
style={{ flexShrink: 0, marginTop: 10 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
@@ -423,7 +396,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 15 }}
style={{ position: "absolute", right: 8, bottom: 30 }}
>
<IconArrowUp size={16} />
</ActionIcon>

View File

@@ -1,16 +1,15 @@
.wrapper {
padding: var(--mantine-spacing-md);
}
.focused-thread {
border: 2px solid #8d7249;
}
.textSelection {
/* Breathing room below the comment header (author + timestamp) so the
quote does not stick to the timestamp when it is the first block. */
margin-top: 8px;
/* Align the quote's left bar with the comment body text left edge
(the comment editor insets its text by 6px). */
margin-left: 6px;
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;
@@ -33,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%;
@@ -43,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,24 +0,0 @@
.recordingWrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Translucent red halo that sits behind the stop button and scales with the
live microphone level (scale set inline from audioLevel). Radius follows the
ActionIcon's own radius so the halo matches the button's rounded-square
outline instead of being a circle. */
.pulse {
position: absolute;
inset: 0;
border-radius: var(--mantine-radius-default);
background-color: var(--mantine-color-red-5);
opacity: 0.35;
transform-origin: center;
transform: scale(1);
transition: transform 90ms linear;
pointer-events: none;
will-change: transform;
z-index: 0;
}

View File

@@ -3,8 +3,6 @@ import { ActionIcon, Loader, Tooltip } from "@mantine/core";
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDictation } from "@/features/dictation/hooks/use-dictation";
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
import classes from "./mic-button.module.css";
interface MicButtonProps {
onText: (text: string) => void;
@@ -13,14 +11,6 @@ interface MicButtonProps {
// Mantine ActionIcon size token; "lg" matches the chat composer, "md" the
// editor toolbar.
size?: "md" | "lg";
// Optional Mantine color override for the idle/transcribing states (the
// recording state stays red). Defaults to the theme primary when omitted.
color?: string;
// Optional explicit glyph size override; defaults to the size-token value.
iconSize?: number;
// When true, use the streaming (Silero-VAD) dictation controller, which emits
// text progressively as the user pauses; otherwise use the batch controller.
streaming?: boolean;
}
/**
@@ -34,64 +24,35 @@ export const MicButton: FC<MicButtonProps> = ({
onStart,
disabled,
size = "lg",
color,
iconSize,
streaming = false,
}) => {
const { t } = useTranslation();
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
// active is a render-time choice, but both must be invoked every render. This
// is safe because both controllers are inert until start() is called — neither
// opens the mic on mount — so the unused one costs nothing.
const batchCtl = useDictation({ onText, onStart });
const streamingCtl = useStreamingDictation({ onText, onStart });
const ctl = streaming ? streamingCtl : batchCtl;
const { status, start, stop, audioLevel } = ctl;
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
const { status, start, stop } = useDictation({ onText, onStart });
const iconSize = size === "lg" ? 18 : 16;
if (status === "recording") {
// Live volume-driven halo: the scale follows the current mic level.
const haloScale = 1 + Math.min(1, audioLevel) * 0.9;
return (
<Tooltip label={t("Stop recording")} withArrow>
<span className={classes.recordingWrap}>
<span
className={classes.pulse}
style={{ transform: `scale(${haloScale})` }}
aria-hidden="true"
/>
<ActionIcon
size={size}
color="red"
variant="light"
onClick={stop}
aria-label={t("Stop recording")}
style={{ position: "relative", zIndex: 1 }}
>
<IconPlayerStopFilled size={resolvedIconSize} />
</ActionIcon>
</span>
<ActionIcon
size={size}
color="red"
variant="light"
onClick={stop}
aria-label={t("Stop recording")}
>
<IconPlayerStopFilled size={iconSize} />
</ActionIcon>
</Tooltip>
);
}
if (
status === "loading" ||
status === "transcribing" ||
status === "error"
) {
// "loading" (streaming hook fetching the VAD model on first use) shows the
// same spinner+disabled state so the first click is visibly acknowledged and
// a confusing second click can't fire while the model loads.
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
if (status === "transcribing" || status === "error") {
return (
<Tooltip label={label} withArrow>
<Tooltip label={t("Transcribing…")} withArrow>
<ActionIcon
size={size}
variant="subtle"
color={color}
disabled
aria-label={label}
aria-label={t("Transcribing…")}
>
<Loader size="xs" />
</ActionIcon>
@@ -104,12 +65,11 @@ export const MicButton: FC<MicButtonProps> = ({
<ActionIcon
size={size}
variant="subtle"
color={color}
onClick={() => void start()}
disabled={disabled}
aria-label={t("Start dictation")}
>
<IconMicrophone size={resolvedIconSize} />
<IconMicrophone size={iconSize} />
</ActionIcon>
</Tooltip>
);

View File

@@ -3,15 +3,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
// "loading" is set only by the streaming hook while it lazily loads the VAD
// model on first use; the batch hook never sets it. It exists so the streaming
// hook and the mic button can show immediate feedback during that load.
export type DictationStatus =
| "idle"
| "recording"
| "transcribing"
| "error"
| "loading";
export type DictationStatus = "idle" | "recording" | "transcribing" | "error";
interface UseDictationOptions {
onText: (text: string) => void;
@@ -24,8 +16,6 @@ interface UseDictationResult {
start: () => Promise<void>;
stop: () => void;
cancel: () => void;
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
audioLevel: number;
}
// Candidate container/codec combinations in preference order. The first one the
@@ -66,7 +56,6 @@ export function useDictation(
): UseDictationResult {
const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Keep the latest callbacks in a ref so the recorder's onstop closure always
// calls the current handlers without re-creating the recorder.
@@ -81,15 +70,6 @@ export function useDictation(
const canceledRef = useRef(false);
const startingRef = useRef(false);
// Web Audio metering: derives a live input level from the captured stream.
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const rafRef = useRef<number | null>(null);
// Exponentially smoothed level, and the last value pushed to React state.
const smoothedLevelRef = useRef(0);
const emittedLevelRef = useRef(0);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
@@ -102,91 +82,6 @@ export function useDictation(
streamRef.current = null;
}, []);
// Tear the audio meter down fully. Safe to call multiple times and on any exit
// path; defensive try/catch so cleanup never throws.
const stopMeter = useCallback(() => {
// Cancel the rAF first so getByteTimeDomainData can't run on a closed context.
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
try {
sourceRef.current?.disconnect();
sourceRef.current = null;
analyserRef.current = null;
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
void audioContextRef.current.close();
}
audioContextRef.current = null;
} catch (err) {
// Cleanup must never throw; just log for diagnosis.
console.warn("[dictation] audio meter teardown failed", err);
}
smoothedLevelRef.current = 0;
emittedLevelRef.current = 0;
setAudioLevel(0);
}, []);
// Set up Web Audio metering on the already-captured stream. Reuses the existing
// MediaStream — never requests a second mic. Failure here must not break
// recording: on any error we warn and return, leaving the recorder running.
const startMeter = useCallback((stream: MediaStream) => {
try {
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return;
const audioContext = new Ctor();
// Some browsers start the context suspended; resume so the loop produces
// data. Swallow rejection (e.g. context already closed by a fast
// start/stop race) to avoid an unhandled promise rejection.
audioContext.resume().catch(() => {});
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.5;
// Connect ONLY to the analyser — never to destination, which would echo the
// mic back to the speakers.
source.connect(analyser);
audioContextRef.current = audioContext;
sourceRef.current = source;
analyserRef.current = analyser;
// Allocate the time-domain buffer once and reuse it on every tick.
const data = new Uint8Array(analyser.fftSize);
const tick = () => {
const a = analyserRef.current;
if (!a) return;
a.getByteTimeDomainData(data);
// RMS of the centered waveform (samples are 0..255, midpoint 128).
let sumSquares = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sumSquares += v * v;
}
const rms = Math.sqrt(sumSquares / data.length);
// Boost + clamp so normal speech maps to a visible 0..1 range.
const level = Math.min(1, rms * 3);
// Exponential smoothing to avoid jitter.
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
// Throttle React re-renders: only push when it changed meaningfully.
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
emittedLevelRef.current = smoothedLevelRef.current;
setAudioLevel(smoothedLevelRef.current);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
} catch (err) {
// Web Audio unavailable or threw: recording continues without the meter.
console.warn("[dictation] audio meter unavailable", err);
}
}, []);
const start = useCallback(async (): Promise<void> => {
// Synchronous live guard: status is stale between renders, so also block on
// refs to prevent a double-click from opening two MediaStreams (the first
@@ -268,9 +163,8 @@ export function useDictation(
const recordedMime = recorder.mimeType || mimeType || "audio/webm";
const wasCanceled = canceledRef.current;
// Stop the mic tracks and the audio meter regardless of how we got here.
// Stop the mic tracks regardless of how we got here.
stopTracks();
stopMeter();
recorderRef.current = null;
if (wasCanceled) {
@@ -343,49 +237,34 @@ export function useDictation(
// Recording has truly begun; release the synchronous start guard.
startingRef.current = false;
// Start the live audio meter on the stream we already acquired.
startMeter(stream);
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
timerRef.current = setTimeout(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, maxDurationMs);
}, [status, t, clearTimer, stopTracks, startMeter, stopMeter]);
}, [status, t, clearTimer, stopTracks]);
const stop = useCallback((): void => {
clearTimer();
const recorder = recorderRef.current;
if (recorder && recorder.state === "recording") {
// Normal path: onstop tears down tracks + meter and runs transcription.
recorder.stop();
} else {
// No live recorder (e.g. the track ended on its own): tear everything
// down directly so the meter/AudioContext and stream don't leak, and
// recover the UI to idle.
stopTracks();
stopMeter();
recorderRef.current = null;
chunksRef.current = [];
setStatus("idle");
}
}, [clearTimer, stopTracks, stopMeter]);
}, [clearTimer]);
const cancel = useCallback((): void => {
clearTimer();
canceledRef.current = true;
const recorder = recorderRef.current;
if (recorder && recorder.state === "recording") {
// onstop sees canceledRef and skips transcription; it also stops tracks
// and the meter.
// onstop sees canceledRef and skips transcription; it also stops tracks.
recorder.stop();
} else {
stopTracks();
stopMeter();
}
setStatus("idle");
}, [clearTimer, stopTracks, stopMeter]);
}, [clearTimer, stopTracks]);
// Clean up on unmount: stop any live recorder/stream and clear the timers.
useEffect(() => {
@@ -401,9 +280,8 @@ export function useDictation(
recorder.stop();
}
stopTracks();
stopMeter();
};
}, [clearTimer, stopTracks, stopMeter]);
}, [clearTimer, stopTracks]);
return { status, start, stop, cancel, audioLevel };
return { status, start, stop, cancel };
}

View File

@@ -1,474 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
// only fetched when the user actually begins dictation.
type MicVADInstance = {
start: () => Promise<void>;
pause: () => Promise<void>;
destroy: () => Promise<void>;
};
interface UseStreamingDictationOptions {
onText: (text: string) => void;
onStart?: () => void;
maxDurationMs?: number;
}
interface UseStreamingDictationResult {
status: DictationStatus;
start: () => Promise<void>;
stop: () => void;
cancel: () => void;
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
audioLevel: number;
}
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
const VAD_SAMPLE_RATE = 16000;
// Asset paths for the VAD worklet/Silero model and the onnxruntime-web WASM
// binaries. vad-web 0.0.30's default asset path is "./" (relative to the current
// page URL), NOT a CDN — in this SPA that request hits the client-side catch-all
// route and returns index.html (text/html), so the onnxruntime ESM/wasm backend
// fails to initialize. We instead self-host the four needed files (the vad-web
// worklet + `silero_vad_v5.onnx` model and the onnxruntime-web `*.jsep.mjs`/
// `*.jsep.wasm`) under `apps/client/public/vad/` — populated by
// `scripts/copy-vad-assets.mjs`, which runs before `dev`/`build` — and point both
// paths at the fixed absolute "/vad/".
const VAD_BASE_ASSET_PATH: string | undefined = "/vad/";
const VAD_ONNX_WASM_BASE_PATH: string | undefined = "/vad/";
/**
* Streaming variant of useDictation. Detects speech with a real (Silero) VAD and,
* each time the speaker pauses, cuts that speech segment and POSTs it to the same
* batch transcription endpoint, so text appears progressively as the user speaks.
*
* Returns the SAME shape as useDictation ({ status, start, stop, cancel,
* audioLevel }) so MicButton can use either interchangeably. Refs hold the live
* VAD instance / counters / timer so component re-renders never lose them, and
* every exit path destroys the VAD and stops the MediaStream.
*/
export function useStreamingDictation(
options: UseStreamingDictationOptions,
): UseStreamingDictationResult {
const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
// current handlers without re-creating the VAD.
const optionsRef = useRef(options);
optionsRef.current = options;
const vadRef = useRef<MicVADInstance | null>(null);
// AudioContext we create+resume inside the click gesture and inject into
// MicVAD (see start()). We own it; MicVAD does not close an injected context.
const audioContextRef = useRef<AudioContext | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const canceledRef = useRef(false);
const startingRef = useRef(false);
// True while a recording session is active (VAD listening). Used to ignore late
// VAD callbacks that fire after stop()/cancel().
const activeRef = useRef(false);
// In-order emission: each segment gets a monotonically increasing seq when its
// speech ends; completed transcriptions are buffered by seq and flushed in
// order so out-of-order HTTP responses can't scramble the text.
const nextSeqRef = useRef(0);
const nextEmitSeqRef = useRef(0);
const resultsRef = useRef<Map<number, string>>(new Map());
// Number of transcription requests still in flight.
const inFlightRef = useRef(0);
// Session epoch: bumped when a NEW session starts (start) or everything is
// hard-discarded (cancel). Each in-flight request captures the epoch at send
// time; if the epoch has since changed, the request is stale and its
// then/catch/finally are skipped so old text can't leak into a new session and
// the in-flight counter can't be driven negative across sessions.
const epochRef = useRef(0);
// Exponentially smoothed speech level, and the last value pushed to React state.
const smoothedLevelRef = useRef(0);
const emittedLevelRef = useRef(0);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Reset the level meter back to zero (refs + React state).
const resetLevel = useCallback(() => {
smoothedLevelRef.current = 0;
emittedLevelRef.current = 0;
setAudioLevel(0);
}, []);
// Destroy the live VAD instance (which also releases the mic stream and audio
// context it created). Safe to call multiple times and on any exit path;
// defensive try/catch so teardown never throws.
const destroyVad = useCallback(() => {
const vad = vadRef.current;
vadRef.current = null;
if (vad) {
try {
// destroy() pauses + tears down the worklet/stream/context internally.
// It returns a promise, so attach a .catch too: the surrounding
// try/catch only catches synchronous throws, and a rejected destroy()
// would otherwise surface as an unhandled rejection.
void vad
.destroy()
.catch((err) =>
console.warn("[dictation] VAD teardown failed", err),
);
} catch (err) {
// Cleanup must never throw; just log for diagnosis.
console.warn("[dictation] VAD teardown failed", err);
}
}
}, []);
// Decide the status once recording has ended: stay "transcribing" while
// requests are in flight, otherwise return to "idle".
const settleAfterStop = useCallback(() => {
if (inFlightRef.current > 0) {
setStatus("transcribing");
} else {
setStatus("idle");
}
}, []);
// Drain the in-order result buffer: while the next expected seq is ready, trim
// it, emit it if non-empty, and advance. Called after every resolved request.
const drainResults = useCallback(() => {
const results = resultsRef.current;
while (results.has(nextEmitSeqRef.current)) {
const text = results.get(nextEmitSeqRef.current)!;
results.delete(nextEmitSeqRef.current);
nextEmitSeqRef.current += 1;
const trimmed = text.trim();
// Whisper often returns a leading space; emit the trimmed value.
if (trimmed.length > 0) optionsRef.current.onText(trimmed);
}
}, []);
// Map a transcription error to a user-facing message, mirroring the batch hook.
const transcriptionErrorMessage = useCallback(
(err: unknown): string => {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider 404, bad format,
// STT not configured) — show it verbatim.
return serverMsg;
}
if (resp?.status === 503 || resp?.status === 403) {
return t("Voice dictation is not configured");
}
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
},
[t],
);
// Handle one ended speech segment: encode to WAV and transcribe. Results are
// buffered by seq and flushed in order. A single failed segment does NOT kill
// the session: log + one notification, then advance past that seq so later
// segments still flush.
const handleSegment = useCallback(
(audio: Float32Array) => {
const seq = nextSeqRef.current;
nextSeqRef.current += 1;
inFlightRef.current += 1;
// Capture the epoch for this request synchronously at send time.
const epoch = epochRef.current;
const wavBlob = encodeWavPcm16(audio, VAD_SAMPLE_RATE);
void transcribeAudio(wavBlob, "speech.wav")
.then((text) => {
// Stale request from a previous session: drop it without touching any
// current-session state.
if (epoch !== epochRef.current) return;
// Defend against a non-string server value before drainResults trims.
resultsRef.current.set(seq, typeof text === "string" ? text : "");
drainResults();
})
.catch((err: unknown) => {
if (epoch !== epochRef.current) return;
// Log the full error for diagnosis (status + body + stack).
console.error("[dictation] segment transcription failed", err);
notifications.show({
color: "red",
message: transcriptionErrorMessage(err),
});
// Skip this seq so later segments can still flush in order.
if (nextEmitSeqRef.current === seq) {
nextEmitSeqRef.current += 1;
drainResults();
} else {
resultsRef.current.set(seq, "");
drainResults();
}
})
.finally(() => {
if (epoch !== epochRef.current) return;
inFlightRef.current -= 1;
// If recording already stopped, flip to idle once everything drained.
if (!activeRef.current && inFlightRef.current === 0) {
setStatus("idle");
}
});
},
[drainResults, transcriptionErrorMessage],
);
const start = useCallback(async (): Promise<void> => {
// Synchronous live guard: status is stale between renders, so also block on
// refs to prevent a double-click from creating two VAD instances (the first
// would leak its mic stream).
if (startingRef.current || vadRef.current || activeRef.current) return;
if (status !== "idle") return;
startingRef.current = true;
// Notify the caller right when dictation begins (before any async work) so the
// editor can snapshot the caret position.
optionsRef.current.onStart?.();
// Reset per-session in-order emission state. Bump the epoch so any request
// still in flight from a previous (stopped) session becomes stale and its
// then/catch/finally are skipped — it can neither emit old text into this
// new session nor decrement this session's freshly-zeroed in-flight counter.
epochRef.current += 1;
canceledRef.current = false;
nextSeqRef.current = 0;
nextEmitSeqRef.current = 0;
resultsRef.current = new Map();
inFlightRef.current = 0;
resetLevel();
// Create and resume the AudioContext NOW, inside the click gesture, before
// the (first-time-slow) model load below. A context first touched outside a
// user gesture stays "suspended" and the VAD audio worklet never runs — that
// is exactly why the first click did nothing and only the second (model
// already cached, so MicVAD.new was fast enough to create the context inside
// the gesture) started recording. We own this context and inject it into
// MicVAD (which then will NOT close it); it is reused across start/stop and
// closed only on unmount.
const AudioCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (AudioCtor && !audioContextRef.current) {
audioContextRef.current = new AudioCtor();
}
// Resume within the gesture; swallow rejection (e.g. already running/closed).
void audioContextRef.current?.resume().catch(() => {});
// Show immediate feedback while the model loads (see Part B).
setStatus("loading");
let vad: MicVADInstance;
try {
// Lazy import so the heavy onnx model/worklet are only fetched on first use
// and code-split out of the main bundle.
const { MicVAD } = await import("@ricky0123/vad-web");
vad = await MicVAD.new({
// Silero v5 model (smaller/faster than the legacy model).
model: "v5",
// vad-web 0.0.30 defaults startOnLoad:true, which opens the mic (calls
// getUserMedia) inside new() and leaves the later vad.start() a no-op —
// making its mic-permission error handling dead code. Force it off so the
// mic is opened only by the explicit vad.start() below, where the real
// getUserMedia errors are caught and mapped.
startOnLoad: false,
// Inject the AudioContext we created+resumed inside the click gesture so
// the VAD worklet runs on a "running" context. When provided, the library
// uses it and does NOT take ownership/close it.
...(audioContextRef.current
? { audioContext: audioContextRef.current }
: {}),
// Only pass asset paths when defined; otherwise the library uses its
// bundled CDN defaults.
...(VAD_BASE_ASSET_PATH !== undefined
? { baseAssetPath: VAD_BASE_ASSET_PATH }
: {}),
...(VAD_ONNX_WASM_BASE_PATH !== undefined
? { onnxWASMBasePath: VAD_ONNX_WASM_BASE_PATH }
: {}),
// --- VAD tuning (all tunable) ---
// Probability over which a frame counts as speech.
positiveSpeechThreshold: 0.5,
// Probability under which a frame counts as non-speech (~0.15 below the
// positive threshold, per Silero guidance).
negativeSpeechThreshold: 0.35,
// Silence to wait through before ending a segment (the "don't cut
// immediately" delay). Each ended segment is ONE transcription request, so
// cutting on short gaps over-fragments normal speech into a flood of tiny
// requests (and trips the server's per-user rate limit). Wait ~1.5s — a
// real sentence/thought boundary — so request count tracks actual pauses,
// not every inter-word gap. Higher = fewer requests but more latency
// before text appears. NOTE: vad-web 0.0.30 takes this in ms, not frames
// (one Silero frame is ~32ms at 16k).
redemptionMs: 1500,
// Audio kept before speech start (left padding so the first word isn't
// clipped) — ~0.3s.
preSpeechPadMs: 320,
// Ignore sub-100ms blips like clicks.
minSpeechMs: 96,
onFrameProcessed: (probabilities: { isSpeech: number }) => {
// Drive the level meter from the speech probability. Light exponential
// smoothing + a throttle so React state isn't updated every frame; this
// powers the existing button halo. Reuses the VAD's own frame
// probabilities — no second AudioContext/AnalyserNode.
if (!activeRef.current) return;
const level = Math.min(1, Math.max(0, probabilities.isSpeech));
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
emittedLevelRef.current = smoothedLevelRef.current;
setAudioLevel(smoothedLevelRef.current);
}
},
onSpeechStart: () => {
// No-op: the segment is only handled once it ends.
},
onSpeechEnd: (audio: Float32Array) => {
// A pause was detected — cut this segment and transcribe it. Ignore late
// callbacks that fire after stop()/cancel().
if (!activeRef.current || canceledRef.current) return;
handleSegment(audio);
},
});
} catch (err) {
// With startOnLoad:false, new() loads the model/worklet/wasm but does NOT
// open the mic, so a throw here is an asset/init failure (model fetch,
// worklet, onnxruntime wasm), not a mic-permission error. Map it as a
// generic "could not start" with the underlying detail. (The mic-permission
// name checks are kept in the vad.start() catch below, where getUserMedia
// actually runs.)
console.error("[dictation] VAD init failed", err);
const detail = (err as { message?: string })?.message ?? String(err);
notifications.show({
color: "red",
message: `${t("Could not start recording")}: ${detail}`,
});
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
// don't leak it.
destroyVad();
setStatus("idle");
startingRef.current = false;
return;
}
vadRef.current = vad;
// Accept frames once start() resolves; the VAD callbacks already guard on
// activeRef, so setting it before start() is safe.
activeRef.current = true;
try {
// With startOnLoad:false this is where getUserMedia actually runs, so map
// mic-permission errors here the same way the batch hook does; otherwise
// fall back to a generic "could not start" message.
await vad.start();
} catch (err) {
// Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] VAD.start failed", err);
const name = (err as { name?: string })?.name;
const detail = (err as { message?: string })?.message ?? String(err);
let message: string;
if (name === "NotAllowedError" || name === "SecurityError") {
message = t("Microphone access denied");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
message = t("No microphone found");
} else if (name === "NotReadableError" || name === "AbortError") {
message = t("Microphone is unavailable or already in use");
} else {
message = `${t("Could not start recording")}: ${detail}`;
}
notifications.show({ color: "red", message });
activeRef.current = false;
destroyVad();
setStatus("idle");
startingRef.current = false;
return;
}
setStatus("recording");
// Recording has truly begun; release the synchronous start guard.
startingRef.current = false;
// Optional overall safety cap: auto-stop after maxDurationMs like the batch
// hook does.
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
timerRef.current = setTimeout(() => {
if (activeRef.current) stopRef.current();
}, maxDurationMs);
}, [status, t, resetLevel, destroyVad, handleSegment]);
const stop = useCallback((): void => {
clearTimer();
if (!activeRef.current && !vadRef.current) {
// Nothing is running; make sure the UI is idle.
setStatus("idle");
return;
}
// Mark inactive first so late onSpeechEnd/onFrameProcessed callbacks are
// ignored. Any speech segment that has NOT yet ended (user clicks Stop
// mid-utterance) is dropped — acceptable for v1; users normally pause before
// stopping.
activeRef.current = false;
destroyVad();
resetLevel();
settleAfterStop();
}, [clearTimer, destroyVad, resetLevel, settleAfterStop]);
// Keep stop() reachable from the maxDuration timer closure (which is created
// before stop is defined) without re-creating the VAD.
const stopRef = useRef(stop);
stopRef.current = stop;
const cancel = useCallback((): void => {
clearTimer();
canceledRef.current = true;
activeRef.current = false;
// Hard discard: bump the epoch so any in-flight request becomes stale and is
// ignored the moment it resolves (no emit, no counter touch).
epochRef.current += 1;
// Drop pending results / queue; in-flight requests will resolve into a now-
// empty buffer and be ignored.
resultsRef.current = new Map();
nextSeqRef.current = 0;
nextEmitSeqRef.current = 0;
inFlightRef.current = 0;
destroyVad();
resetLevel();
setStatus("idle");
}, [clearTimer, destroyVad, resetLevel]);
// Clean up on unmount: destroy the VAD, stop the mic stream, clear the timer.
// Defensive try/catch lives inside destroyVad so teardown never throws.
useEffect(() => {
return () => {
clearTimer();
activeRef.current = false;
canceledRef.current = true;
destroyVad();
// Close the AudioContext we own (MicVAD never closes an injected one).
if (
audioContextRef.current &&
audioContextRef.current.state !== "closed"
) {
void audioContextRef.current.close().catch(() => {});
}
audioContextRef.current = null;
};
}, [clearTimer, destroyVad]);
return { status, start, stop, cancel, audioLevel };
}

View File

@@ -1,32 +0,0 @@
// Encode mono Float32 PCM samples into a 16-bit PCM WAV blob (audio/wav).
// The server STT endpoint whitelists audio/wav, so this is sent as-is.
export function encodeWavPcm16(samples: Float32Array, sampleRate = 16000): Blob {
const bytesPerSample = 2;
const blockAlign = bytesPerSample; // mono
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
};
writeStr(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeStr(8, "WAVE");
writeStr(12, "fmt ");
view.setUint32(16, 16, true); // PCM fmt chunk size
view.setUint16(20, 1, true); // audio format = PCM
view.setUint16(22, 1, true); // channels = mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); // bits per sample
writeStr(36, "data");
view.setUint32(40, dataSize, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const clamped = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: "audio/wav" });
}

View File

@@ -13,6 +13,7 @@ import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { DictationGroup } from "./groups/dictation-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
@@ -30,6 +31,7 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
if (!editor || !state) return null;
@@ -65,6 +67,12 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
{isDictationEnabled && (
<>
<div className={classes.divider} />
<DictationGroup editor={editor} />
</>
)}
</div>
</div>
<div className={classes.spacer} aria-hidden />

View File

@@ -4,62 +4,45 @@ import { MicButton } from "@/features/dictation/components/mic-button";
interface Props {
editor: Editor;
color?: string;
iconSize?: number;
}
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
// Caret snapshot taken when dictation starts (where the first segment lands).
export const DictationGroup: FC<Props> = ({ editor }) => {
const rangeRef = useRef<{ from: number; to: number } | null>(null);
// Running insertion point: after each inserted segment we remember the caret
// end so the NEXT segment appends right after it, contiguously, regardless of
// where the user's caret currently is. Null until the first segment lands.
const insertPosRef = useRef<number | null>(null);
const handleStart = () => {
const { from, to } = editor.state.selection;
rangeRef.current = { from, to };
// New session: forget any insertion point from a previous dictation so the
// first segment uses the fresh snapshot above.
insertPosRef.current = null;
};
const handleText = (text: string) => {
// The editor may be gone by the time async transcription returns; bail out
// instead of operating on a destroyed instance.
if (!editor || editor.isDestroyed) return;
const snapshot = rangeRef.current;
rangeRef.current = null;
// The document may have shrunk during transcription (e.g. a collaborative
// edit), so clamp any position into the current bounds before inserting.
// edit), so clamp the snapshot into the current bounds before inserting.
const docSize = editor.state.doc.content.size;
const clamp = (p: number) => Math.max(0, Math.min(p, docSize));
// First segment lands at the snapshotted caret range; subsequent segments
// land at a zero-length range at the running insertion point so they stay
// contiguous even if the user clicked elsewhere mid-dictation.
const snapshot = rangeRef.current;
const range =
insertPosRef.current !== null
? { from: clamp(insertPosRef.current), to: clamp(insertPosRef.current) }
: snapshot
? { from: clamp(snapshot.from), to: clamp(snapshot.to) }
: null;
try {
if (range) {
// Insert at the resolved range; a trailing space keeps words separated
// (the hook already trims the transcribed text).
editor.chain().focus().insertContentAt(range, `${text} `).run();
if (snapshot) {
// Insert at the snapshotted caret; a trailing space keeps words
// separated (the hook already trims the transcribed text).
editor
.chain()
.focus()
.insertContentAt(
{ from: clamp(snapshot.from), to: clamp(snapshot.to) },
`${text} `,
)
.run();
} else {
// No snapshot and no running point (shouldn't happen normally) — fall
// back to the current caret.
editor.chain().focus().insertContent(`${text} `).run();
}
// Remember where the inserted text ends so the next segment appends right
// after it, independent of later user caret moves.
insertPosRef.current = editor.state.selection.to;
} catch {
// The range drifted out of bounds; fall back to the current caret.
// The snapshot drifted out of range; fall back to the current caret.
try {
editor.chain().focus().insertContent(`${text} `).run();
insertPosRef.current = editor.state.selection.to;
} catch {
// The editor may have been destroyed; ignore so a dead editor can't
// surface an uncaught error.
@@ -70,12 +53,9 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
return (
<MicButton
size="md"
streaming
onStart={handleStart}
onText={handleText}
disabled={!editor.isEditable}
color={color}
iconSize={iconSize}
/>
);
};

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

@@ -1,84 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getSuggestionItems } from "./menu-items";
// Coverage for the filter/sort half of `getSuggestionItems` (distinct from the
// HTML-embed gating suite). A slash query is matched against each item three
// ways — fuzzy on the title, substring on the description, and substring on the
// searchTerms — and matched items are sorted so title-substring hits float to
// the top of their group. We also cover `excludeItems`.
//
// `getSuggestionItems` -> `isHtmlEmbedFeatureEnabled` reads the persisted
// `currentUser` localStorage entry, so a working in-memory Storage stub is a
// prerequisite (installed by vitest.setup.ts). We persist a `currentUser` with
// the HTML-embed toggle OFF (the production default) so the gated "HTML embed"
// item never leaks into these non-HTML queries.
const KEY = "currentUser";
function flatTitles(groups: ReturnType<typeof getSuggestionItems>): string[] {
return Object.values(groups)
.flat()
.map((item) => item.title);
}
beforeEach(() => {
// Default workspace state: HTML-embed feature OFF (matches production default).
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
});
afterEach(() => {
localStorage.clear();
});
describe("getSuggestionItems — filter and sort", () => {
it("fuzzy-matches a title (non-contiguous characters)", () => {
// "tdo" is not a substring of "to-do list" but matches fuzzily (t..d..o).
const titles = flatTitles(getSuggestionItems({ query: "tdo" }));
expect(titles).toContain("To-do list");
});
it("matches via the description when the title does not match", () => {
// "numbering" only appears in the description "Create a list with numbering.",
// not in the "Numbered list" title nor its searchTerms.
const titles = flatTitles(getSuggestionItems({ query: "numbering" }));
expect(titles).toContain("Numbered list");
});
it("matches via searchTerms when title and description do not match", () => {
// "blockquote" is only present in the "Quote" item's searchTerms.
const titles = flatTitles(getSuggestionItems({ query: "blockquote" }));
expect(titles).toContain("Quote");
});
it("sorts title-substring matches before non-title (description) matches", () => {
// For "page": several titles contain "page" (e.g. "Page break"), while
// "Synced block" matches only through its description (".. across pages.").
// The sort tie-break must place every title hit ahead of the non-title hit.
const titles = flatTitles(getSuggestionItems({ query: "page" }));
const syncedIndex = titles.indexOf("Synced block");
const pageBreakIndex = titles.indexOf("Page break");
// Sanity: both items survived the filter for this query.
expect(syncedIndex).toBeGreaterThanOrEqual(0);
expect(pageBreakIndex).toBeGreaterThanOrEqual(0);
// The title match ("Page break") sorts before the description-only match.
expect(pageBreakIndex).toBeLessThan(syncedIndex);
});
it("removes a named item via excludeItems", () => {
const withBullet = flatTitles(getSuggestionItems({ query: "list" }));
expect(withBullet).toContain("Bullet list");
const withoutBullet = flatTitles(
getSuggestionItems({
query: "list",
excludeItems: new Set(["Bullet list"]),
}),
);
expect(withoutBullet).not.toContain("Bullet list");
// Other "list" matches remain unaffected by the exclusion.
expect(withoutBullet).toContain("Numbered list");
});
});

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

@@ -14,11 +14,8 @@ import {
UnstyledButton,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
@@ -27,11 +24,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx";
import {
currentPageEditModeAtom,
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -72,8 +65,6 @@ export function FullEditor({
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
@@ -113,9 +104,6 @@ export function FullEditor({
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
@@ -130,24 +118,11 @@ export function FullEditor({
type PageBylineProps = {
creator?: PageUser;
contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
};
function PageByline({
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
}: PageBylineProps) {
function PageByline({ creator, contributors }: PageBylineProps) {
const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details");
const editor = useAtomValue(pageEditorAtom);
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -222,23 +197,16 @@ function PageByline({
</Popover.Dropdown>
</Popover>
)}
<Group gap={4} wrap="nowrap">
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
{...detailsTriggerProps}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
{/* Shown only in edit mode when workspace dictation is enabled, so
dictation stays reachable even when the fixed toolbar is hidden. */}
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
</Group>
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
{...detailsTriggerProps}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
);
}

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

@@ -152,17 +152,7 @@ export function TitleEditor({
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
useEffect(() => {
// Do not overwrite the title while the user is actively editing it. The
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
// carry a title that lags behind what the user has just typed; resetting
// content from it here would drop in-progress characters and jump the
// cursor. Apply external title changes only when the field is not focused.
if (
titleEditor &&
!titleEditor.isDestroyed &&
!titleEditor.isFocused &&
title !== titleEditor.getText()
) {
if (titleEditor && title !== titleEditor.getText()) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);

View File

@@ -1,110 +0,0 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
// The /spaces list endpoint returns membership.role but NOT membership.permissions
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
// for the current user when their role is ADMIN or WRITER.
function canCreatePage(space: ISpace): boolean {
const role = space.membership?.role;
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
}
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
// useCreatePageMutation already surfaces a red notification on error.
}
};
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
</Button>
);
}
// Multiple writable spaces → pick the target space from a dropdown.
return (
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
loading={isPending}
>
{t("New note")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Create in space")}</Menu.Label>
{writableSpaces.map((space) => (
<Menu.Item
key={space.id}
disabled={isPending}
leftSection={
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
}
onClick={() => createNote(space)}
>
<Text size="sm" lineClamp={1}>
{space.name}
</Text>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -1,93 +0,0 @@
import { describe, it, expect } from "vitest";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
/**
* Tests for the deterministic label-color hashing. `hashName` is not exported,
* so we exercise it through `getLabelColor`. We assert determinism, that light
* and dark schemes resolve to the SAME palette key (so a label's "blue" stays
* "blue" across themes), that the returned color is always a real palette
* entry, and that a realistic sample of names does not all collapse into one
* bucket (guards the murmur fmix finalizer that de-clusters the % bucket).
*/
// The 8 distinct light-scheme bg colors, used to recover a name's bucket index.
const LIGHT_BGS = [
"#eef1f5", // slate
"#e6f0ff", // blue
"#e3f5ea", // green
"#fbf0d9", // amber
"#fde6e6", // red
"#efe9fb", // purple
"#fce6ee", // pink
"#daf1ee", // teal
];
const DARK_BGS = [
"#2a3140",
"#152a52",
"#143b27",
"#3d2c0e",
"#401a1a",
"#2a1f4d",
"#3c1a2a",
"#103633",
];
describe("getLabelColor — determinism", () => {
it("returns the same color object shape for the same name", () => {
const a = getLabelColor("bug");
const b = getLabelColor("bug");
expect(a).toEqual(b);
expect(a).toMatchObject({
bg: expect.any(String),
fg: expect.any(String),
dot: expect.any(String),
});
});
it("is stable across many repeated calls", () => {
const first = getLabelColor("enhancement");
for (let i = 0; i < 50; i++) {
expect(getLabelColor("enhancement")).toEqual(first);
}
});
});
describe("getLabelColor — scheme parity", () => {
it("light and dark resolve to the SAME palette key for a given name", () => {
const names = ["bug", "enhancement", "wontfix", "duplicate", "p1", "docs"];
for (const name of names) {
const lightIdx = LIGHT_BGS.indexOf(getLabelColor(name, "light").bg);
const darkIdx = DARK_BGS.indexOf(getLabelColor(name, "dark").bg);
expect(lightIdx).toBeGreaterThanOrEqual(0); // it is a real palette entry
expect(darkIdx).toBeGreaterThanOrEqual(0);
expect(darkIdx).toBe(lightIdx); // same bucket across themes
}
});
it("defaults to the light scheme", () => {
expect(getLabelColor("bug")).toEqual(getLabelColor("bug", "light"));
});
});
describe("getLabelColor — index bounds & distribution", () => {
it("always returns a color whose bg is one of the 8 palette entries", () => {
const names = Array.from({ length: 200 }, (_, i) => `label-${i}`);
for (const name of names) {
expect(LIGHT_BGS).toContain(getLabelColor(name).bg);
}
});
it("handles the empty string without crashing and within bounds", () => {
expect(LIGHT_BGS).toContain(getLabelColor("").bg);
});
it("a sample of distinct names does not all collide into one bucket", () => {
const names = Array.from({ length: 64 }, (_, i) => `name-${i}-${i * 7}`);
const buckets = new Set(names.map((n) => getLabelColor(n).bg));
// The fmix finalizer should spread these across multiple buckets, not 1.
expect(buckets.size).toBeGreaterThan(1);
// Realistically a 64-name sample lands in most/all of the 8 buckets.
expect(buckets.size).toBeGreaterThanOrEqual(4);
});
});

View File

@@ -1,47 +0,0 @@
import { describe, it, expect } from "vitest";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
/**
* `normalizeLabelName` = trim + collapse ALL whitespace runs to a single hyphen
* + lowercase. Used to canonicalize label names so "Bug Fix" and " bug fix "
* map to the same key.
*/
describe("normalizeLabelName", () => {
it("trims leading and trailing whitespace", () => {
expect(normalizeLabelName(" bug ")).toBe("bug");
});
it("lowercases", () => {
expect(normalizeLabelName("BUG")).toBe("bug");
expect(normalizeLabelName("MixedCase")).toBe("mixedcase");
});
it("collapses internal whitespace runs to a single hyphen", () => {
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
expect(normalizeLabelName("a b c")).toBe("a-b-c");
});
it("combines trim + collapse + lowercase", () => {
expect(normalizeLabelName(" Bug Fix ")).toBe("bug-fix");
});
it("treats tab and newline as whitespace", () => {
expect(normalizeLabelName("bug\tfix")).toBe("bug-fix");
expect(normalizeLabelName("bug\nfix")).toBe("bug-fix");
expect(normalizeLabelName("bug\r\nfix")).toBe("bug-fix");
});
it("treats unicode whitespace (no-break space) as a separator", () => {
// U+00A0 NO-BREAK SPACE is matched by the \s class.
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
});
it("leaves an already-normalized name unchanged", () => {
expect(normalizeLabelName("bug-fix")).toBe("bug-fix");
});
it("returns empty string for whitespace-only input", () => {
expect(normalizeLabelName(" ")).toBe("");
expect(normalizeLabelName("")).toBe("");
});
});

View File

@@ -1,134 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getTimeGroup,
groupNotificationsByTime,
} from "@/features/notification/notification.utils.ts";
import type { INotification } from "@/features/notification/types/notification.types.ts";
/**
* `getTimeGroup` classifies a timestamp into today / yesterday / this_week /
* older using LOCAL-time day boundaries derived from `now`. To stay timezone-
* independent, the boundary anchors are computed exactly the way the SUT does
* (local midnight of today, minus 1 day, minus 7 days) and inputs are offset
* from those anchors by a safe margin. `groupNotificationsByTime` buckets a
* list, drops empty groups, and preserves input order within each group, in the
* fixed order today -> yesterday -> this_week -> older.
*/
const FIXED_NOW = new Date("2026-06-21T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// Local midnight of "today" relative to the frozen clock.
function startOfTodayLocal(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
// An ISO string `offsetMs` away from local midnight of today.
function fromTodayStart(offsetMs: number): string {
return new Date(startOfTodayLocal().getTime() + offsetMs).toISOString();
}
function notif(id: string, createdAt: string): INotification {
return {
id,
createdAt,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
const HOUR = 3_600_000;
const DAY = 86_400_000;
describe("getTimeGroup — boundary classification", () => {
it("classifies a time after today's midnight as 'today'", () => {
expect(getTimeGroup(fromTodayStart(HOUR))).toBe("today");
});
it("classifies exactly today's midnight as 'today' (inclusive lower bound)", () => {
expect(getTimeGroup(fromTodayStart(0))).toBe("today");
});
it("classifies the slice between yesterday-midnight and today-midnight as 'yesterday'", () => {
expect(getTimeGroup(fromTodayStart(-HOUR))).toBe("yesterday");
expect(getTimeGroup(fromTodayStart(-DAY))).toBe("yesterday"); // start of yesterday, inclusive
});
it("classifies 2..7 days before today as 'this_week'", () => {
expect(getTimeGroup(fromTodayStart(-DAY - HOUR))).toBe("this_week");
expect(getTimeGroup(fromTodayStart(-7 * DAY))).toBe("this_week"); // start of week, inclusive
});
it("classifies anything before the 7-day window as 'older'", () => {
expect(getTimeGroup(fromTodayStart(-7 * DAY - HOUR))).toBe("older");
expect(getTimeGroup(fromTodayStart(-30 * DAY))).toBe("older");
});
});
describe("groupNotificationsByTime", () => {
const labels = {
today: "Today",
yesterday: "Yesterday",
this_week: "This week",
older: "Older",
};
it("returns groups in the order today -> yesterday -> this_week -> older", () => {
// Provide rows out of order to prove ordering comes from the group order,
// not input order.
const result = groupNotificationsByTime(
[
notif("old", fromTodayStart(-30 * DAY)),
notif("today", fromTodayStart(HOUR)),
notif("week", fromTodayStart(-3 * DAY)),
notif("yest", fromTodayStart(-HOUR)),
],
labels,
);
expect(result.map((g) => g.key)).toEqual([
"today",
"yesterday",
"this_week",
"older",
]);
expect(result.map((g) => g.label)).toEqual([
"Today",
"Yesterday",
"This week",
"Older",
]);
});
it("preserves input order within a single group", () => {
const result = groupNotificationsByTime(
[
notif("t1", fromTodayStart(HOUR)),
notif("t2", fromTodayStart(2 * HOUR)),
notif("t3", fromTodayStart(3 * HOUR)),
],
labels,
);
expect(result).toHaveLength(1);
expect(result[0].key).toBe("today");
expect(result[0].notifications.map((n) => n.id)).toEqual(["t1", "t2", "t3"]);
});
it("drops empty groups", () => {
const result = groupNotificationsByTime(
[notif("only-today", fromTodayStart(HOUR))],
labels,
);
expect(result.map((g) => g.key)).toEqual(["today"]);
});
it("returns an empty array for no notifications", () => {
expect(groupNotificationsByTime([], labels)).toEqual([]);
});
});

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

@@ -30,6 +30,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
@@ -142,6 +143,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: page, isLoading } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
@@ -187,7 +189,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handleDeletePage = () => {
handleDelete(page.id);
openDeleteModal({ onConfirm: () => handleDelete(page.id) });
};
const handleToggleFavorite = () => {

View File

@@ -1,27 +0,0 @@
import { Button, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
type MoveToTrashNotificationProps = {
message: string;
undoLabel: string;
onUndo: () => void;
};
// Builds the body of the "page moved to trash" toast: the status text plus an
// inline Undo action that restores the page from trash. Returned as a ReactNode
// so it can be passed as the `message` of a Mantine notification from a
// non-TSX module (page-query.ts).
export function moveToTrashNotificationMessage({
message,
undoLabel,
onUndo,
}: MoveToTrashNotificationProps): ReactNode {
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm">{message}</Text>
<Button variant="subtle" size="compact-sm" onClick={onUndo}>
{undoLabel}
</Button>
</Group>
);
}

Some files were not shown because too many files have changed in this diff Show More