Compare commits
150 Commits
v0.94.1
...
feat/git-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbaaa84419 | ||
|
|
32cb9eb1e3 | ||
|
|
b47751349f | ||
|
|
b7e5cb6970 | ||
|
|
906733b5c8 | ||
|
|
f020739bfd | ||
|
|
22e3fcdeba | ||
|
|
7179f8a5b2 | ||
|
|
fe4adf23a0 | ||
|
|
eefe17600c | ||
|
|
32e99c6e42 | ||
|
|
e48d7720e9 | ||
|
|
42e618ec7f | ||
|
|
857a0064f7 | ||
|
|
daf6c9ea16 | ||
|
|
9e69d917ee | ||
|
|
2594828758 | ||
|
|
b5ce63a956 | ||
|
|
e777ebcf4f | ||
|
|
abd6e3948b | ||
|
|
5125296bfa | ||
|
|
452a752264 | ||
|
|
a40a00d5c5 | ||
|
|
81c0226be7 | ||
|
|
d5079aa1d8 | ||
|
|
b536a41ad3 | ||
|
|
28d2560dfd | ||
|
|
52959de2f3 | ||
|
|
5da12e89f9 | ||
|
|
3a91e0eca9 | ||
|
|
2e83c9cebf | ||
|
|
f6d22a59a6 | ||
|
|
6baad935f9 | ||
|
|
d255afa611 | ||
|
|
73c5c44301 | ||
|
|
8c42c4f0d6 | ||
|
|
071eae4e2a | ||
|
|
a91405632e | ||
|
|
5d4eb8ede2 | ||
|
|
aa1ee64b7a | ||
|
|
53febfd5b9 | ||
|
|
a2ac08c04c | ||
|
|
40ca04eb08 | ||
|
|
393875d910 | ||
|
|
c3dbee9fbf | ||
|
|
ea1f8da906 | ||
|
|
9baaf1ea58 | ||
|
|
71375e25ee | ||
|
|
e528988d71 | ||
|
|
dc7a0ec9f5 | ||
|
|
969c00aaf1 | ||
|
|
085a30575f | ||
|
|
95bc9fe98d | ||
|
|
cca0bfe306 | ||
|
|
0dbf85b129 | ||
|
|
fb357cd52e | ||
|
|
177d8a31d4 | ||
|
|
8fa32e8438 | ||
|
|
807ff1f5f5 | ||
|
|
fa89cba023 | ||
|
|
3386bf2865 | ||
|
|
98253cf614 | ||
|
|
181a8330f3 | ||
|
|
02daccc453 | ||
|
|
d06cf97ed6 | ||
|
|
04032ae677 | ||
|
|
d9d1d54aaa | ||
|
|
593f181bbc | ||
|
|
582e1976cc | ||
|
|
e0e01157c2 | ||
|
|
8373360a67 | ||
|
|
e2493cafa9 | ||
|
|
5a4d9f84d7 | ||
|
|
70bd0dba4d | ||
|
|
b0cd4bd6cf | ||
|
|
56ab17fbc2 | ||
|
|
106df7c907 | ||
|
|
89edddc5a1 | ||
| c5109aa2a3 | |||
|
|
c4ed4a4855 | ||
|
|
9c1f952b2f | ||
| c6ffdb6536 | |||
|
|
3fd66b4245 | ||
|
|
40d1cdfc77 | ||
|
|
a77a0bc92b | ||
|
|
525172104a | ||
|
|
07ebd8c63e | ||
|
|
c9d252cf2a | ||
|
|
fa929c9e86 | ||
|
|
30cb9d293c | ||
|
|
2d36641f28 | ||
|
|
22852be2e2 | ||
|
|
904f7b4303 | ||
|
|
cac84dec9b | ||
|
|
90dd8f1481 | ||
| 39113c9dbf | |||
|
|
1367070468 | ||
|
|
767ac9e7e2 | ||
|
|
2a4ef9267e | ||
|
|
309719abc6 | ||
|
|
3511301331 | ||
|
|
b65ca6d7dd | ||
| 4a3819373d | |||
|
|
e682bbccd1 | ||
|
|
9d2bec8eb8 | ||
| b6630deb32 | |||
|
|
7ef98a663b | ||
| 109ab10fc5 | |||
|
|
2b7c861f78 | ||
|
|
d181b5c4ff | ||
|
|
12ff76fb89 | ||
|
|
26ca19f89e | ||
|
|
50e79275e1 | ||
|
|
8be8279809 | ||
|
|
19f84ca0e7 | ||
|
|
e9409e245b | ||
|
|
fa6a87e22d | ||
|
|
0fc9c4a998 | ||
|
|
40b8f7922a | ||
| 08c70cf550 | |||
|
|
ae6ed76d9a | ||
|
|
276ccc0783 | ||
|
|
406921ac6a | ||
|
|
c64d7f315e | ||
|
|
7a7aa79eab | ||
| 719bccd80d | |||
| 83e64bad1a | |||
| ee78a96803 | |||
| d971d02346 | |||
|
|
53cbec9354 | ||
|
|
686c3f9d14 | ||
|
|
6faf2475e6 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
e99c00a9ee | ||
|
|
1f459d8d26 | ||
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 | ||
|
|
eb5b696431 | ||
|
|
422389d84e | ||
|
|
fad1aa0501 | ||
|
|
8bb4224a20 | ||
| 13589b3973 | |||
|
|
69fcccd6e8 | ||
|
|
0db48f1706 | ||
|
|
2e72a24d13 | ||
|
|
0643cd1d82 | ||
|
|
1043fe3b51 | ||
|
|
fdeede003b |
47
.env.example
47
.env.example
@@ -132,6 +132,14 @@ MCP_DOCMOST_PASSWORD=
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
|
||||
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
|
||||
# baked into the Docker image at build time per branch (see the Dockerfile ARG
|
||||
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
|
||||
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
|
||||
# admin feature is unavailable. Local-filesystem sources are no longer supported.
|
||||
# AI_AGENT_ROLES_CATALOG_URL=
|
||||
|
||||
# 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
|
||||
@@ -195,3 +203,42 @@ MCP_DOCMOST_PASSWORD=
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||
# GIT_SYNC_ENABLED=false
|
||||
#
|
||||
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||
# GIT_SYNC_ENABLED when unset.
|
||||
# GIT_SYNC_HTTP_ENABLED=false
|
||||
#
|
||||
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||
# operations (create / move / rename / delete) are attributed to.
|
||||
# GIT_SYNC_SERVICE_USER_ID=
|
||||
#
|
||||
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||
# working tree).
|
||||
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||
# GIT_SYNC_DATA_DIR=
|
||||
#
|
||||
# Optional remote URL template to mirror each space's vault to (e.g. a git host).
|
||||
# The literal "{spaceId}" is substituted per-space, so each space mirrors to its
|
||||
# OWN remote — e.g. git@host:vault-{spaceId}.git. Without the placeholder every
|
||||
# space would point at one remote. Leave unset to keep vaults local-only.
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
#
|
||||
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||
# (default: 2000).
|
||||
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||
#
|
||||
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||
# after this deadline so it cannot hold the per-space lock forever.
|
||||
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||
#
|
||||
|
||||
1
.github/workflows/develop.yml
vendored
1
.github/workflows/develop.yml
vendored
@@ -52,6 +52,7 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -17,6 +17,7 @@ permissions:
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
@@ -57,6 +58,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
||||
@@ -85,6 +87,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
push: false
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -68,6 +68,13 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# git-sync and mcp are no longer committed in built form (build/ is
|
||||
# gitignored), so CI must compile them: the server resolves both via their
|
||||
# built build/index.js. The server pretest also builds them, but building
|
||||
# here keeps it explicit and independent of pnpm lifecycle ordering.
|
||||
- name: Build git-sync and mcp
|
||||
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,12 @@ data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
|
||||
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
|
||||
packages/*/node_modules/
|
||||
packages/git-sync/build/
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -42,6 +48,7 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
70
AGENTS.md
70
AGENTS.md
@@ -182,7 +182,7 @@ tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
|
||||
| Path | Name | Stack | Role |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -190,6 +190,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -243,8 +244,10 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
||||
|
||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||
|
||||
Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify routes that bypass the Nest pipeline (so neither `DomainMiddleware` nor `ThrottlerGuard` runs for them — each resolves the workspace and throttles itself): `/mcp` (the embedded MCP server, see below) and `/git/<spaceId>.git/...` (the git-sync smart-HTTP host, see below). Both share `mcp-auth.helpers.ts` (HTTP-Basic parsing, `FailedLoginLimiter`, `clientIp`) and the common `resolveRequestWorkspace` helper.
|
||||
|
||||
### Module structure (server)
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`, `git-sync`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
|
||||
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
|
||||
|
||||
@@ -254,16 +257,22 @@ 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 (39 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
|
||||
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||
|
||||
### Git-sync (native two-way Docmost ↔ git Markdown sync)
|
||||
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
|
||||
|
||||
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
|
||||
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.
|
||||
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
|
||||
@@ -283,37 +292,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
### Cutting a release
|
||||
|
||||
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
|
||||
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
|
||||
|
||||
1. Make sure `main` is clean and pushed (`git status`, `git push`).
|
||||
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
|
||||
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
|
||||
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).
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
|
||||
4. For a real release (skip for a bare hotfix tag), 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 the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
|
||||
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
|
||||
```bash
|
||||
git push gitea develop && git push gitea vX.Y.Z
|
||||
git push github develop && git push github vX.Y.Z
|
||||
```
|
||||
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
|
||||
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only develop # or a merge commit if fast-forward is not possible
|
||||
git push gitea main && git push github main
|
||||
```
|
||||
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
|
||||
|
||||
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
|
||||
|
||||
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>`.
|
||||
`git describe --tags --always` (see `vite.config.ts`) 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`.
|
||||
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
|
||||
|
||||
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.
|
||||
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
|
||||
|
||||
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
|
||||
|
||||
`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`.
|
||||
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
|
||||
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just 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 in scope.
|
||||
|
||||
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.)
|
||||
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
|
||||
|
||||
## Planning docs
|
||||
|
||||
|
||||
164
CHANGELOG.md
164
CHANGELOG.md
@@ -10,6 +10,128 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
|
||||
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
|
||||
whether a still-conflicted page is held back or pushed with its conflict
|
||||
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
|
||||
Markdown files and reconciled in both directions (Docmost → vault and vault →
|
||||
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
|
||||
through the collaboration layer so concurrent human edits aren't clobbered.
|
||||
Git-originated changes are attributed to a configurable service account and
|
||||
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
|
||||
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
|
||||
Basic auth, space-permission authorized). Off by default and configured via the
|
||||
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
|
||||
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
|
||||
(#119)
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
resolving the target space the same way the regular button does — created
|
||||
directly when you can write to a single space, or via a space picker when
|
||||
several. Each space overview screen gains two buttons — "New note" and "New
|
||||
temporary note" — that create the page directly in that space and open it,
|
||||
mirroring the existing space-sidebar actions and shown only to members who can
|
||||
manage pages.
|
||||
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
|
||||
message gains a "send now" action that interrupts the streaming turn and
|
||||
immediately sends that message, keeping the agent's partial output. The
|
||||
follow-up turn is tagged as an interrupt so the model is told its previous
|
||||
answer was cut off and builds on it instead of restarting; the rest of the
|
||||
queue still flushes normally afterward. (#198)
|
||||
|
||||
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
|
||||
catalog of agent roles, grouped into bundles and offered in several languages,
|
||||
and import the ones they want into the workspace (with skip-or-rename handling
|
||||
for name collisions); the same role in a different language imports as a
|
||||
separate install. An imported role remembers its catalog origin and offers a
|
||||
one-click update when the catalog ships a newer revision. Backed by four new
|
||||
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||
catalog slug/language/version. The catalog source is configured via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
|
||||
catalog's raw files; the image ships a per-branch default baked in CI, and it
|
||||
can be overridden at runtime via the env var (see `.env.example`). (#222)
|
||||
- **Author footnotes inline from an agent, and deterministic server-side footnote
|
||||
canonicalization on every non-editor write path.** A new MCP `insert_footnote`
|
||||
tool places a footnote at a body anchor by content only — the agent supplies
|
||||
WHERE (anchor text) and WHAT (markdown); the number and the bottom
|
||||
`footnotesList` are derived server-side, so an agent can never assign a number,
|
||||
edit the list, or desync, and a same-content note reuses one definition. Under
|
||||
the hood, the editor's footnote-integrity invariant (one trailing list,
|
||||
numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now
|
||||
enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths
|
||||
that bypass the editor's plugins: server markdown/HTML import, `PageService`
|
||||
create and full-document (`replace`) updates, the client markdown paste, and the
|
||||
MCP markdown page-import / `update_page` (markdown) / `update_page_json` /
|
||||
`docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is
|
||||
idempotent (a no-op once canonical) and is deliberately NOT applied to
|
||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#228)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||
toggle. Previously the create call defaulted to including sub-pages, silently
|
||||
exposing every child of a freshly shared page. (#216)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
link whose target page name had no file extension (e.g. a bare title) was
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
link; the page name is now preserved. (#204)
|
||||
- **Deep pages no longer render a blank breadcrumb while the sidebar tree loads.**
|
||||
The breadcrumb now falls back to the page's own ancestor chain (fetched
|
||||
independently of the lazily-built sidebar tree) so a deep page resolves its
|
||||
trail immediately; navigating away no longer leaves the previously-viewed
|
||||
page's breadcrumb showing until the new one resolves. (#206, #218)
|
||||
- **Pasted GitHub-style callouts (`> [!NOTE]` …) now convert to real callouts.**
|
||||
GitHub admonition blocks pasted as Markdown are recognized and rendered as
|
||||
callout blocks instead of plain block-quotes. (#192)
|
||||
- **The editor stays read-only until collaboration has synced.** While a page is
|
||||
connecting, the body is shown as a non-editable static view with a
|
||||
"Connecting… (read-only)" banner, so edits typed before the document finishes
|
||||
syncing can no longer be silently dropped. (#218)
|
||||
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
||||
page's vanity slug previously inserted a second `share_aliases` row instead of
|
||||
renaming the existing one, leaving the old `/l/<old>` link live forever and
|
||||
making the share modal's lookup nondeterministic. Slug edits and confirmed
|
||||
reassigns now rename/retarget the single row, and a new partial unique index on
|
||||
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
|
||||
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
|
||||
orphaned duplicate alias rows the old bug created (keeping the newest per
|
||||
page), so any previously-live duplicate `/l/<old>` link begins returning the
|
||||
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
|
||||
#227)
|
||||
- **Typing a custom address already used by another page no longer looks like a
|
||||
dead end.** The share modal previously flagged such a name with a red "This
|
||||
address is already in use" error, hiding the fact that saving offers to MOVE
|
||||
the address to the current page. The field now shows an informational hint —
|
||||
"This address is in use. Saving will move it to this page." — and keeps Save
|
||||
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` →
|
||||
"Move custom address?") is discoverable instead of reading as terminal. (#227)
|
||||
|
||||
### Security
|
||||
|
||||
- **The anonymous public-share page payload is trimmed to an explicit allowlist.**
|
||||
The `/shares/page-info` route (the only unauthenticated path serializing a
|
||||
page + its share) now returns only the fields the public renderer needs;
|
||||
internal metadata — creator/last-updater/contributor ids, space/workspace ids,
|
||||
AI/source bookkeeping, lock/template flags, parent/position and raw timestamps
|
||||
— is no longer exposed to anonymous viewers. (#218)
|
||||
- **A forged or mismatched share id can no longer render a page off its slug
|
||||
alone.** When the public URL carries a share id/key, the page must be reachable
|
||||
through that exact share (its own share or an ancestor `includeSubPages`
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
@@ -22,6 +144,26 @@ per-workspace rolling-day token budget.
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
@@ -62,9 +204,31 @@ per-workspace rolling-day token budget.
|
||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||
byline reads the live editor content (including unsaved edits), generates a
|
||||
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
- **AI chat: header button auto-opens the chat bound to the current document.**
|
||||
Clicking the AI-chat button in the header while viewing a page now reopens the
|
||||
latest chat tied to that document instead of whatever chat was last active,
|
||||
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
|
||||
chat you created on the page wins; with no bound chat — or off a page, or if
|
||||
the lookup fails — it falls soft to a fresh chat and keeps the current
|
||||
selection otherwise. (#191)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||
rows, which silently dropped the beginning of any longer chat. It is now
|
||||
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||
safety net far above any realistic chat, not a conversational limit). On a
|
||||
very long chat this can eventually reach the model's context window; the
|
||||
client already surfaces that as "start a new chat". (#202)
|
||||
|
||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -17,12 +17,18 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
# git: required by the git-sync VaultGit (shells out to git)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& apt-get install -y --no-install-recommends curl bash git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Agent-roles catalog base URL: per-branch default set at build time (CI);
|
||||
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
|
||||
ARG AI_AGENT_ROLES_CATALOG_URL=""
|
||||
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
|
||||
|
||||
# Copy apps
|
||||
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
|
||||
@@ -33,6 +39,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# git-sync: the server loads @docmost/git-sync at runtime via the loader
|
||||
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
|
||||
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
|
||||
# these copied build artifacts that resolve/import fails and the server crashes on
|
||||
# first use. Built fresh by the builder's `pnpm build` (nx builds the package's tsc
|
||||
# `build` target).
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
| --- | --- |
|
||||
| **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. |
|
||||
| **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. |
|
||||
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
|
||||
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
|
||||
| **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. |
|
||||
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
|
||||
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
|
||||
@@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
### Embedded MCP server
|
||||
|
||||
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
|
||||
agent-native tools**: surgical per-block edits (patch / insert / delete by id),
|
||||
structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff,
|
||||
structured table editing, version history with diff / restore, comments, images and share
|
||||
@@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license.
|
||||
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise license** | Not required | Required |
|
||||
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
|
||||
| **Structured table editing, version diff / restore** | ✅ | — |
|
||||
| **Comments, images, share links** | ✅ | — |
|
||||
@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||
|
||||
### In progress
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| --- | --- |
|
||||
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
|
||||
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
||||
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
||||
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
|
||||
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
|
||||
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
|
||||
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
|
||||
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise-лицензия** | Не нужна | Нужна |
|
||||
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
|
||||
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
|
||||
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
|
||||
@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
|
||||
### В процессе
|
||||
|
||||
|
||||
193
agent-roles-catalog/README.md
Normal file
193
agent-roles-catalog/README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Agent roles catalog
|
||||
|
||||
This directory is **data, not application code**. It holds the content of an
|
||||
"agent roles catalog": reusable agent role definitions (system prompts plus a
|
||||
little metadata), grouped into bundles and translated into one or more
|
||||
languages. A separate server reads these files and serves them; nothing here is
|
||||
executable application logic except the validation script.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
agent-roles-catalog/
|
||||
index.json # the catalog manifest: bundles, languages, role versions
|
||||
bundles/
|
||||
<bundle-id>/
|
||||
<lang>.json # one file per declared language (e.g. ru.json, en.json)
|
||||
scripts/
|
||||
check.mjs # validates the catalog (no dependencies)
|
||||
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
|
||||
package.json # defines the `check` script
|
||||
README.md
|
||||
```
|
||||
|
||||
Currently shipped bundles:
|
||||
|
||||
- `editorial` — the editorial suite (structural-editor, line-editor,
|
||||
fact-checker, proofreader, narrator), languages `ru`, `en`.
|
||||
- `research` — a single `researcher` role, languages `ru`, `en`.
|
||||
|
||||
## How it's served
|
||||
|
||||
The server does not bundle this data; it reads it at request time from a single
|
||||
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
|
||||
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
|
||||
to the catalog's raw files. The server fetches `<base>/index.json` for the
|
||||
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
|
||||
file (REMOTE only).
|
||||
|
||||
That base URL is provided as a per-branch default in the Docker image (set in
|
||||
CI: a `develop` build points at the `develop` raw URL, a release build at the
|
||||
`main` raw URL) and can be overridden at runtime via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
|
||||
supported; if the value is unset the catalog is unavailable.
|
||||
|
||||
The fetched JSON is re-validated server-side (the catalog is treated as
|
||||
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
|
||||
rollout.
|
||||
|
||||
## `index.json` schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial", // unique bundle id; matches bundles/<id>/
|
||||
"name": { "ru": "...", "en": "..." }, // localized display name
|
||||
"description": { "ru": "...", "en": "..." },
|
||||
"languages": ["ru", "en"], // which <lang>.json files must exist
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 1 }
|
||||
// ...
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`version` lives **here, in index.json**, per role. Bump it whenever a role's
|
||||
content (instructions, name, description, etc.) changes, so consumers can detect
|
||||
updates.
|
||||
|
||||
## Bundle (`<lang>.json`) schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"language": "ru",
|
||||
"roles": [
|
||||
{
|
||||
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
|
||||
"emoji": "🧱",
|
||||
"name": "...", // REQUIRED, localized
|
||||
"description": "...", // localized
|
||||
"instructions": "...", // REQUIRED, the system prompt, localized
|
||||
"autoStart": true, // whether the role starts working immediately
|
||||
"launchMessage": "..." // first message sent on launch (or null)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `modelConfig` is intentionally absent; the server treats an absent
|
||||
`modelConfig` as `null`.
|
||||
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
|
||||
files of the same bundle. Only `name`, `description`, `instructions`, and
|
||||
`launchMessage` are translated.
|
||||
|
||||
## Slug uniqueness
|
||||
|
||||
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
|
||||
bundle. A slug appears once per language file of its bundle (same slug in
|
||||
`ru.json` and `en.json`), but no two different bundles may share a slug.
|
||||
`scripts/check.mjs` enforces this.
|
||||
|
||||
## How to add things
|
||||
|
||||
### Add a role to an existing bundle
|
||||
|
||||
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
|
||||
`slug` and `version: 1`.
|
||||
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
|
||||
bundle, translating `name`, `description`, `instructions`, and
|
||||
`launchMessage`.
|
||||
3. Run the check (see below).
|
||||
|
||||
### Add a bundle
|
||||
|
||||
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
|
||||
`languages`, `roles`).
|
||||
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
|
||||
object per `roles[]` entry.
|
||||
3. Run the check.
|
||||
|
||||
### Add a language to a bundle
|
||||
|
||||
1. Add the language code to that bundle's `languages[]` in `index.json`.
|
||||
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
|
||||
translated.
|
||||
3. Run the check.
|
||||
|
||||
### Change a role's content
|
||||
|
||||
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
|
||||
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
|
||||
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
|
||||
now **fails if a role's content changed but its `version` was not bumped**, so
|
||||
this step is mandatory — the lock can only be refreshed after the bump.
|
||||
|
||||
## Validating
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs # or: npm run check
|
||||
```
|
||||
|
||||
It fails (exit code 1) if any slug is duplicated across the catalog, if a
|
||||
bundle's index `roles[]` don't match the slugs present in each language file, if
|
||||
a declared language file is missing, or if any role is missing a required field
|
||||
(`slug`, `name`, `instructions`). It prints `OK` on success.
|
||||
|
||||
### Content-hash guard
|
||||
|
||||
`check.mjs` also guards against changing a role's content without bumping its
|
||||
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
|
||||
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
|
||||
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
|
||||
`launchMessage`) across all of its language files, in a deterministic canonical
|
||||
form. This lockfile is a **check artifact only** — the server fetches only
|
||||
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
|
||||
effect on the served catalog or its schema.
|
||||
|
||||
On a normal run, for every role the check recomputes the hash and compares it
|
||||
against the lock:
|
||||
|
||||
- content unchanged and versions agree → OK;
|
||||
- content changed but `version` not bumped above the lock → **error** asking you
|
||||
to bump and refresh;
|
||||
- content changed and `version` bumped → **error** asking you to record it by
|
||||
refreshing the lock;
|
||||
- role missing from the lock, or a lock entry for a role that no longer exists →
|
||||
**error** asking you to refresh.
|
||||
|
||||
Refresh the lock with:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs --update-hashes # alias: --fix
|
||||
```
|
||||
|
||||
This recomputes the lock from the current catalog, prunes entries for removed
|
||||
roles, and prints what changed — but it **refuses to write** (exit 1) if any
|
||||
role's content changed while its `index.json` version was not bumped, so the
|
||||
version bump is always enforced first. The check also requires every
|
||||
`index.json` role to carry a finite numeric `version` (the server requires the
|
||||
same).
|
||||
|
||||
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
|
||||
role and run `--update-hashes`, then re-add it with changed content at the same
|
||||
version) is **not** caught, because a brand-new slug has no lock baseline to
|
||||
enforce a bump against.
|
||||
51
agent-roles-catalog/bundles/editorial/en.json
Normal file
51
agent-roles-catalog/bundles/editorial/en.json
Normal file
File diff suppressed because one or more lines are too long
51
agent-roles-catalog/bundles/editorial/ru.json
Normal file
51
agent-roles-catalog/bundles/editorial/ru.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/en.json
Normal file
15
agent-roles-catalog/bundles/research/en.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/ru.json
Normal file
15
agent-roles-catalog/bundles/research/ru.json
Normal file
File diff suppressed because one or more lines are too long
31
agent-roles-catalog/index.json
Normal file
31
agent-roles-catalog/index.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial",
|
||||
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
|
||||
"description": {
|
||||
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
|
||||
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 2 },
|
||||
{ "slug": "line-editor", "version": 2 },
|
||||
{ "slug": "fact-checker", "version": 3 },
|
||||
{ "slug": "proofreader", "version": 3 },
|
||||
{ "slug": "narrator", "version": 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": { "ru": "Исследование", "en": "Research" },
|
||||
"description": {
|
||||
"ru": "Глубокое исследование темы с подготовкой отчёта.",
|
||||
"en": "Deep research on a topic with a prepared report."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [ { "slug": "researcher", "version": 1 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
agent-roles-catalog/package.json
Normal file
8
agent-roles-catalog/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "agent-roles-catalog",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "node scripts/check.mjs"
|
||||
}
|
||||
}
|
||||
353
agent-roles-catalog/scripts/check.mjs
Normal file
353
agent-roles-catalog/scripts/check.mjs
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env node
|
||||
// Validates the agent roles catalog.
|
||||
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
|
||||
// between a bundle's index roles[] and the slugs present in each language
|
||||
// file, a missing declared language file, or a role missing required fields.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const catalogDir = join(__dirname, "..");
|
||||
|
||||
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
|
||||
// the current catalog instead of just validating against it.
|
||||
const updateHashes =
|
||||
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
|
||||
|
||||
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
|
||||
// the server never fetches it, so it has zero impact on the served schema.
|
||||
const lockPath = join(__dirname, "content-hashes.json");
|
||||
|
||||
const errors = [];
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
errors.push(`Cannot read/parse ${path}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const indexPath = join(catalogDir, "index.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
console.error(`Missing index.json at ${indexPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const index = readJson(indexPath);
|
||||
if (!index) {
|
||||
for (const e of errors) console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
|
||||
if (bundles.length === 0) {
|
||||
errors.push("index.json has no bundles[]");
|
||||
}
|
||||
|
||||
// Track every slug seen across the whole catalog to detect duplicates.
|
||||
const slugSeen = new Map(); // slug -> "bundleId/lang"
|
||||
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) {
|
||||
errors.push("A bundle in index.json is missing an id");
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
|
||||
// Duplicate slugs inside the bundle index roles[].
|
||||
const indexSlugSet = new Set(indexSlugs);
|
||||
if (indexSlugSet.size !== indexSlugs.length) {
|
||||
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
|
||||
}
|
||||
|
||||
// Each index role must carry a finite numeric "version". The server requires
|
||||
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
|
||||
// below relies on it for the bump comparison, so enforce it here too.
|
||||
for (const r of bundle.roles || []) {
|
||||
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
if (languages.length === 0) {
|
||||
errors.push(`Bundle "${bundleId}" declares no languages`);
|
||||
}
|
||||
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) {
|
||||
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
const fileSlugs = roles.map((r) => r && r.slug);
|
||||
|
||||
// (d) Required fields per role.
|
||||
for (const role of roles) {
|
||||
for (const field of ["slug", "name", "instructions"]) {
|
||||
if (role == null || role[field] == null || role[field] === "") {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) index roles[] must match the slugs present in each language file.
|
||||
const fileSlugSet = new Set(fileSlugs);
|
||||
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
|
||||
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
|
||||
if (missingInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (extraInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// (a) Duplicate slugs across the whole catalog.
|
||||
for (const slug of fileSlugs) {
|
||||
if (!slug) continue;
|
||||
const where = `${bundleId}/${lang}`;
|
||||
// Only flag duplicates across DIFFERENT bundles or files; the same slug
|
||||
// is expected to appear once per language file of the same bundle.
|
||||
if (slugSeen.has(slug)) {
|
||||
const prev = slugSeen.get(slug);
|
||||
const prevBundle = prev.split("/")[0];
|
||||
if (prevBundle !== bundleId) {
|
||||
errors.push(
|
||||
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
slugSeen.set(slug, where);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-hash guard: detect "content changed without a version bump".
|
||||
//
|
||||
// check.mjs cannot use git history, so we maintain a lockfile
|
||||
// (scripts/content-hashes.json) mapping each role slug to its recorded
|
||||
// { version, hash }. On every run we recompute each role's content hash and
|
||||
// compare it against the lock; a content change is only allowed once the role's
|
||||
// version in index.json has been bumped and the lock refreshed.
|
||||
//
|
||||
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
|
||||
// the role and run --update-hashes, then re-add it with changed content at the
|
||||
// same version) is NOT caught, because a brand-new slug has no lock baseline to
|
||||
// enforce a bump against. We document this rather than building tombstones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Content fields hashed for each role, in a fixed canonical order. `slug` is
|
||||
// identity (not content) and `version` lives in index.json, so neither is here.
|
||||
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
|
||||
// EXCLUDED: no shipped role uses it today, and being an object it would need a
|
||||
// deterministic deep canonicalization (recursive key sort) before hashing —
|
||||
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
|
||||
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
|
||||
// change to it is still caught by the bump guard.
|
||||
const CONTENT_FIELDS = [
|
||||
"emoji",
|
||||
"autoStart",
|
||||
"name",
|
||||
"description",
|
||||
"instructions",
|
||||
"launchMessage",
|
||||
];
|
||||
|
||||
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
|
||||
// current catalog so we can compute hashes and read index versions.
|
||||
function collectCatalogRoles() {
|
||||
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) continue;
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
for (const r of bundle.roles || []) {
|
||||
if (!r || !r.slug) continue;
|
||||
if (!out.has(r.slug)) {
|
||||
out.set(r.slug, { version: r.version, langRoles: new Map() });
|
||||
} else {
|
||||
// Same slug declared twice in index.json roles[]; already flagged above.
|
||||
out.get(r.slug).version = r.version;
|
||||
}
|
||||
}
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) continue;
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
for (const role of roles) {
|
||||
if (!role || !role.slug) continue;
|
||||
const entry = out.get(role.slug);
|
||||
if (!entry) continue; // role not declared in index.json; flagged above.
|
||||
entry.langRoles.set(lang, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deterministic content hash for a role: languages sorted ascending, each
|
||||
// language's content fields taken in CONTENT_FIELDS order (null when absent).
|
||||
function contentHash(langRoles) {
|
||||
const langs = [...langRoles.keys()].sort();
|
||||
const canonical = langs.map((lang) => {
|
||||
const role = langRoles.get(lang);
|
||||
const fields = {};
|
||||
for (const field of CONTENT_FIELDS) {
|
||||
fields[field] = role && role[field] != null ? role[field] : null;
|
||||
}
|
||||
return [lang, fields];
|
||||
});
|
||||
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
||||
}
|
||||
|
||||
// Compute current { version, hash } for every catalog role.
|
||||
const catalogRoles = collectCatalogRoles();
|
||||
const current = new Map(); // slug -> { version, hash }
|
||||
for (const [slug, entry] of catalogRoles) {
|
||||
current.set(slug, {
|
||||
version: entry.version,
|
||||
hash: contentHash(entry.langRoles),
|
||||
});
|
||||
}
|
||||
|
||||
// Load the existing lock (may be absent on first run).
|
||||
let lock = {};
|
||||
if (existsSync(lockPath)) {
|
||||
const parsed = readJson(lockPath);
|
||||
if (parsed && typeof parsed === "object") lock = parsed;
|
||||
}
|
||||
|
||||
if (updateHashes) {
|
||||
// Refresh the lock from the current catalog, but refuse to write if any role's
|
||||
// content changed without its version being bumped above the existing lock.
|
||||
const blockers = [];
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) continue; // new role; nothing to enforce a bump against.
|
||||
if (cur.hash === prev.hash) continue; // content unchanged.
|
||||
// Defense-in-depth: a non-numeric version must never pass the bump check via
|
||||
// `undefined <= N` (which is false). The standard checks already flag a
|
||||
// missing numeric version, but guard here too before comparing.
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Still honor the standard checks before allowing a write.
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
console.error("Refusing to update content-hash lock:");
|
||||
for (const b of blockers) console.error(` - ${b}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Compute the change summary relative to the old lock, pruning removed slugs.
|
||||
const newLock = {};
|
||||
const added = [];
|
||||
const changed = [];
|
||||
const removed = [];
|
||||
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
newLock[slug] = { version: cur.version, hash: cur.hash };
|
||||
const prev = lock[slug];
|
||||
if (!prev) added.push(slug);
|
||||
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
|
||||
}
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) removed.push(slug);
|
||||
}
|
||||
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
|
||||
console.log(`Wrote ${lockPath}`);
|
||||
if (added.length) console.log(` added: ${added.join(", ")}`);
|
||||
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
|
||||
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
|
||||
if (!added.length && !changed.length && !removed.length) {
|
||||
console.log(" (no changes; lock already up to date)");
|
||||
}
|
||||
console.log("OK");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Normal run: validate current content against the lock.
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) {
|
||||
errors.push(
|
||||
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (cur.hash === prev.hash) {
|
||||
// Content unchanged; the lock version must still agree with index.json.
|
||||
if (cur.version !== prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Content changed.
|
||||
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
|
||||
// comparison, so a missing version can never silently pass the bump check
|
||||
// (and we avoid a misleading "version bumped to undefined" message).
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Lock entries for slugs that no longer exist in the catalog.
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) {
|
||||
errors.push(
|
||||
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
}
|
||||
}
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
|
||||
"Toggle AI search": "KI-Suche umschalten",
|
||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
|
||||
"Toggle generative AI": "Generative KI umschalten",
|
||||
"Upgrade your plan": "Upgrade Ihres Plans",
|
||||
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
||||
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
||||
|
||||
@@ -598,6 +598,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Make temporary": "Make temporary",
|
||||
"Make permanent": "Make permanent",
|
||||
"New temporary note": "New temporary note",
|
||||
"Temporary note": "Temporary note",
|
||||
"Temporary notes": "Temporary notes",
|
||||
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||
"Note is now permanent": "Note is now permanent",
|
||||
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -676,9 +687,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
"Toggle AI search": "Toggle AI search",
|
||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||
"Toggle generative AI": "Toggle generative AI",
|
||||
"Upgrade your plan": "Upgrade your plan",
|
||||
"Available with a paid license": "Available with a paid license",
|
||||
"Upgrade your license tier.": "Upgrade your license tier.",
|
||||
@@ -1180,6 +1188,8 @@
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Send now": "Send now",
|
||||
"Interrupt and send now": "Interrupt and send now",
|
||||
"Stop": "Stop",
|
||||
"Response stopped.": "Response stopped.",
|
||||
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
|
||||
@@ -1207,6 +1217,8 @@
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Git sync": "Git sync",
|
||||
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
@@ -1231,6 +1243,10 @@
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.": "When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
@@ -1318,5 +1334,42 @@
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)"
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address",
|
||||
"Generate title with AI": "Generate title with AI",
|
||||
"Title generated": "Title generated",
|
||||
"Failed to generate title": "Failed to generate title",
|
||||
"The note is empty": "The note is empty",
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later",
|
||||
"Import from catalog": "Import from catalog",
|
||||
"Browse the catalog": "Browse the catalog",
|
||||
"Role catalog": "Role catalog",
|
||||
"On name conflict": "On name conflict",
|
||||
"Skip": "Skip",
|
||||
"Import": "Import",
|
||||
"Installed": "Installed",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
||||
"Connecting… (read-only)": "Connecting… (read-only)"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
|
||||
"Toggle AI search": "Alternar búsqueda de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
|
||||
"Toggle generative AI": "Activar IA generativa",
|
||||
"Upgrade your plan": "Mejora tu plan",
|
||||
"Available with a paid license": "Disponible con una licencia de pago",
|
||||
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
|
||||
"Toggle AI search": "Basculer la recherche IA",
|
||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
|
||||
"Toggle generative AI": "Activer/désactiver l'IA générative",
|
||||
"Upgrade your plan": "Mettez à niveau votre forfait",
|
||||
"Available with a paid license": "Disponible avec une licence payante",
|
||||
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
|
||||
"Toggle AI search": "Attiva/disattiva ricerca AI",
|
||||
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
|
||||
"Toggle generative AI": "Attiva/Disattiva AI generativa",
|
||||
"Upgrade your plan": "Aggiorna il tuo piano",
|
||||
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
||||
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||
"Toggle AI search": "AI検索を切り替え",
|
||||
"Generative AI (Ask AI)": "生成AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
||||
"Toggle generative AI": "生成AIを切り替える",
|
||||
"Upgrade your plan": "プランをアップグレードする",
|
||||
"Available with a paid license": "有料ライセンスで利用可能",
|
||||
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||
"Toggle AI search": "AI 검색 전환",
|
||||
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
||||
"Toggle generative AI": "생성 AI 토글",
|
||||
"Upgrade your plan": "요금제를 업그레이드하세요",
|
||||
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
||||
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
|
||||
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
|
||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
|
||||
"Toggle generative AI": "Generatieve AI schakelen",
|
||||
"Upgrade your plan": "Upgrade je abonnement",
|
||||
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
||||
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
|
||||
"Toggle AI search": "Alternar pesquisa de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
|
||||
"Toggle generative AI": "Alternar IA generativa",
|
||||
"Upgrade your plan": "Faça upgrade do seu plano",
|
||||
"Available with a paid license": "Disponível com uma licença paga",
|
||||
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
|
||||
|
||||
@@ -607,6 +607,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Make temporary": "Сделать временной",
|
||||
"Make permanent": "Сделать постоянной",
|
||||
"New temporary note": "Новая временная заметка",
|
||||
"Temporary note": "Временная заметка",
|
||||
"Temporary notes": "Временные заметки",
|
||||
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||
"Note is now permanent": "Заметка теперь постоянная",
|
||||
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Permanently delete": "Удалить навсегда",
|
||||
@@ -723,6 +734,8 @@
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
@@ -736,9 +749,6 @@
|
||||
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
||||
"Toggle AI search": "Переключить поиск ИИ",
|
||||
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
||||
"Toggle generative AI": "Переключить генеративный ИИ",
|
||||
"Upgrade your plan": "Обновите свой тарифный план",
|
||||
"Available with a paid license": "Доступно с платной лицензией",
|
||||
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
|
||||
@@ -1175,5 +1185,43 @@
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)"
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
|
||||
"Generate title with AI": "Сгенерировать название через AI",
|
||||
"Title generated": "Название сгенерировано",
|
||||
"Failed to generate title": "Не удалось сгенерировать название",
|
||||
"The note is empty": "Заметка пустая",
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
|
||||
"Import from catalog": "Импорт из каталога",
|
||||
"Browse the catalog": "Открыть каталог",
|
||||
"Role catalog": "Каталог ролей",
|
||||
"On name conflict": "При конфликте имён",
|
||||
"Skip": "Пропустить",
|
||||
"Import": "Импортировать",
|
||||
"Installed": "Установлено",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
||||
"Toggle AI search": "Переключити пошук з ШІ",
|
||||
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
||||
"Toggle generative AI": "Переключити генеративний ШІ",
|
||||
"Upgrade your plan": "Оновіть свій тарифний план",
|
||||
"Available with a paid license": "Доступно за платною ліцензією",
|
||||
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||
"Toggle AI search": "切换AI搜索",
|
||||
"Generative AI (Ask AI)": "生成型AI (询问AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
||||
"Toggle generative AI": "切换生成型AI",
|
||||
"Upgrade your plan": "升级您的方案",
|
||||
"Available with a paid license": "需付费许可才可用",
|
||||
"Upgrade your license tier.": "升级您的许可等级。",
|
||||
|
||||
@@ -10,12 +10,12 @@ 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 { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
@@ -38,7 +38,9 @@ export function AppHeader() {
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
// Opening from the header auto-opens the document's bound chat (last chat
|
||||
// created on the current page); off a page it keeps the current selection.
|
||||
const openAiChat = useOpenAiChatForCurrentPage();
|
||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
@@ -105,7 +107,7 @@ export function AppHeader() {
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("AI chat")}
|
||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||
onClick={openAiChat}
|
||||
>
|
||||
<IconMessage size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
37
apps/client/src/components/ui/git-sync-badge.tsx
Normal file
37
apps/client/src/components/ui/git-sync-badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitSyncBadgeProps {
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||
* version is created on the PUSH path — when an incoming git body is written back
|
||||
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||
* neutral, non-clickable label.
|
||||
*/
|
||||
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||
>
|
||||
{t("Git sync")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
142
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
142
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
// the spies back to the test body.
|
||||
const h = vi.hoisted(() => ({
|
||||
state: {
|
||||
status: "streaming" as string,
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
|
||||
function renderThread() {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("tags exactly the next send as interrupted (one-shot flag)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
it("sends immediately without an interrupt when not streaming", () => {
|
||||
h.state.status = "ready";
|
||||
renderThread();
|
||||
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconClockHour4,
|
||||
IconPlayerPlayFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@@ -23,6 +27,7 @@ import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
@@ -201,12 +206,25 @@ export default function ChatThread({
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// "Send now" single-flight flags. Kept in refs (not state) so they are read
|
||||
// inside the stable `onFinish` callback and the transport closure WITHOUT a
|
||||
// re-render or a stale closure. Both are one-shot (read-and-clear).
|
||||
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
|
||||
// though an aborted turn normally keeps the queue intact.
|
||||
// - interruptNextSendRef: tag the next send as a user interrupt so the server
|
||||
// injects the "your previous answer was interrupted" note for that turn only.
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return;
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
@@ -232,17 +250,26 @@ export default function ChatThread({
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
// are read live from refs so changing chats/pages does NOT recreate the
|
||||
// transport. `openPage` is null on a non-page route.
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
// Read-and-clear the interrupt flag so the "you were interrupted" note
|
||||
// is carried by ONLY this request (the one resending the promoted
|
||||
// message right after we aborted the previous turn). The server still
|
||||
// confirms it against history before acting on it.
|
||||
const interrupted = interruptNextSendRef.current;
|
||||
interruptNextSendRef.current = false; // one-shot
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
interrupted,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -277,6 +304,21 @@ export default function ChatThread({
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
// interrupt note travels with this send via interruptNextSendRef.
|
||||
if (flushOnAbortRef.current) {
|
||||
flushOnAbortRef.current = false;
|
||||
// Suppress the "Response stopped." flash for an intentional interrupt.
|
||||
setStopNotice(null);
|
||||
// If the promoted head vanished (e.g. the user removed it before the
|
||||
// abort landed) flushNext sends nothing — clear the one-shot interrupt
|
||||
// tag so it can't leak onto the next unrelated send. On a real send the
|
||||
// tag is consumed by prepareSendMessagesRequest and stays untouched.
|
||||
if (!flushNext()) interruptNextSendRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
@@ -298,6 +340,13 @@ export default function ChatThread({
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
|
||||
// CURRENT status rather than a value captured in a stale render closure — a turn
|
||||
// can finish between render and click, and arming the interrupt refs against a
|
||||
// no-op stop() would leave them set to leak into a later, unrelated Stop.
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
|
||||
// on the assistant message metadata at the `start` chunk (message.metadata.
|
||||
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
|
||||
@@ -329,9 +378,49 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
// queue/flush machinery: promote the target to the head, then abort — the
|
||||
// onFinish flush-on-abort branch sends exactly that head, tagged as an
|
||||
// interrupt so the server notes the previous answer was cut off.
|
||||
const sendNow = useCallback(
|
||||
(id: string) => {
|
||||
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
|
||||
// the turn may have finished between this render and the click, in which case
|
||||
// stop() is a no-op and arming the interrupt refs would strand them for a
|
||||
// later, unrelated Stop. Reading the ref always sees the current status.
|
||||
const liveStreaming =
|
||||
statusRef.current === "submitted" || statusRef.current === "streaming";
|
||||
if (liveStreaming) {
|
||||
// Promote to head so the onFinish -> flushNext path sends exactly it.
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
|
||||
} else {
|
||||
// Nothing to interrupt: just send it now (no interrupt note).
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
);
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
|
||||
// the race where a flag was armed but the expected abort never fired (the turn
|
||||
// finished in the same tick as the click), so it cannot leak into a later turn.
|
||||
useEffect(() => {
|
||||
if (isStreaming) setStopNotice(null);
|
||||
if (isStreaming) {
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
@@ -423,6 +512,17 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
});
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||
// live (mutable) message — see message-item.tsx.
|
||||
const renderRow = (message: UIMessage) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} />
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
|
||||
]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={next} />
|
||||
<MessageItem message={next} signature={messageSignature(next)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
expect(callsFor("gamm")).toBe(1);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||
// (reasoning-first providers render nothing at first) must still stream its text
|
||||
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||
// memo recomputed the signature from the (mutated) message — identical on both
|
||||
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does).
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Empty text part: nothing visible rendered yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||
expect(callsFor("streamed answer")).toBe(1);
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
import { arePropsEqual } from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||
* return false on any visible prop/content change (so the row re-renders) and
|
||||
* true when nothing visible changed (so a finalized row is skipped). A FIXED
|
||||
* message id is used so a content-identical clone yields an equal signature.
|
||||
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||
* content-identical clone yields an equal signature.
|
||||
*/
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||
// the signature must never be recomputed inside arePropsEqual).
|
||||
const props = (
|
||||
message: UIMessage,
|
||||
over: Record<string, unknown> = {},
|
||||
) => ({
|
||||
message,
|
||||
signature: messageSignature(message),
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on the identity fast path (same message object, equal props)", () => {
|
||||
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
|
||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||
// recomputing `messageSignature(message)` inside the comparator would read
|
||||
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||
// every streamed update and the assistant row freezes at its initial empty
|
||||
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||
// the parent captured at each render. This fails against the old implementation
|
||||
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||
// live objects).
|
||||
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||
(message.parts[0] as { text: string }).text = "hello world";
|
||||
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||
expect(prevSig).not.toBe(nextSig);
|
||||
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||
const base = {
|
||||
message,
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
};
|
||||
expect(
|
||||
arePropsEqual(
|
||||
{ ...base, signature: prevSig },
|
||||
{ ...base, signature: nextSig },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.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;
|
||||
/**
|
||||
* Immutable content signature for `message`, computed by the PARENT
|
||||
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||
*
|
||||
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||
* array/objects in place and handing back a message wrapper that SHARES those
|
||||
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||
* value from the previous render (old content) and `next.signature` the new
|
||||
* content, so they differ as the turn streams in and the row re-renders.
|
||||
*/
|
||||
signature: string;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
@@ -88,6 +106,8 @@ function MessageItem({
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -203,24 +223,30 @@ function MessageItem({
|
||||
}
|
||||
|
||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||
* TAIL message gets a fresh object whose signature changes each delta, so it
|
||||
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
||||
* per-token whole-transcript re-render into a tail-only one. */
|
||||
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||
* the same signature and is skipped, turning a per-token whole-transcript
|
||||
* re-render into a tail-only one.
|
||||
*
|
||||
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||
* here — recomputing the signature from them yields equal strings every time and
|
||||
* freezes the row at its initial empty render (the bug this guards against). See
|
||||
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||
* signature changed. */
|
||||
export function arePropsEqual(
|
||||
prev: MessageItemProps,
|
||||
next: MessageItemProps,
|
||||
): boolean {
|
||||
if (
|
||||
prev.showCitations !== next.showCitations ||
|
||||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
||||
prev.assistantName !== next.assistantName
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Fast path: identical message object (finalized rows keep their identity
|
||||
// across deltas) — skip without building signatures.
|
||||
if (prev.message === next.message) return true;
|
||||
return messageSignature(prev.message) === messageSignature(next.message);
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageItem, arePropsEqual);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
|
||||
// Mirrors the t-mock pattern used by the other component tests in this folder
|
||||
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
|
||||
// every OTHER named export of markdown.ts intact via `importActual`, and override
|
||||
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
|
||||
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
|
||||
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
|
||||
// factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
|
||||
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
|
||||
// whole point of this file (it closes the parent-side coverage gap left by the
|
||||
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
|
||||
// harness). Use the relative import for the component under test, mirroring how
|
||||
// message-list.tsx itself imports `MessageItem from "./message-item"`.
|
||||
import MessageList from "./message-list";
|
||||
|
||||
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
|
||||
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
|
||||
//
|
||||
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
|
||||
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||
// regression test to model how the AI SDK hands back the SAME message object.
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
describe("MessageList", () => {
|
||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const { queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList
|
||||
messages={[msg([{ type: "text", text: "hello world" }])]}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
// The assistant text renders, which proves MessageList mounted the real
|
||||
// MessageItem and handed it a valid `signature` prop (computed from the real
|
||||
// `messageSignature`) — the full parent -> child -> markdown path is live.
|
||||
expect(queryByText("hello world")).not.toBeNull();
|
||||
});
|
||||
|
||||
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
|
||||
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
|
||||
// delta that REUSES the same message object. The fix moved the content signature
|
||||
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
|
||||
// every render and forward it as the immutable `signature` prop, so MessageItem's
|
||||
// memo (which compares that prop snapshot) sees it change and re-renders the row.
|
||||
//
|
||||
// This test exercises the PARENT half that the memo tests only simulate: if
|
||||
// MessageList ever cached/memoized the signature keyed on the message object's
|
||||
// identity (which stays stable across deltas while its `parts` mutate in place),
|
||||
// the snapshot would never change, MessageItem's memo would skip every delta, and
|
||||
// the row would freeze at its empty mount — exactly the regression class. That
|
||||
// would make this test fail. See message-item.tsx (`signature` prop +
|
||||
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
|
||||
// snapshot at render time).
|
||||
it("reflects in-place part mutation of a reused message object across renders", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does). The empty text
|
||||
// part means MessageItem renders nothing visible initially.
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Nothing streamed yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place on the SAME message object...
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
// ...then re-render with a NEW array literal that still holds the SAME mutated
|
||||
// message object (this mirrors useChat handing back a fresh array of reused
|
||||
// message objects on each delta).
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders: MessageList re-snapshotted the signature, so the
|
||||
// row re-rendered instead of freezing at its empty mount.
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
expect(
|
||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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 { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -196,9 +197,16 @@ export default function MessageList({
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
// shared `parts` in place, so prev/next message objects both read the
|
||||
// latest content there and the memo would skip every streamed update
|
||||
// (freezing the row at its empty render). See message-item.tsx.
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
|
||||
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// useMatch is the only react-router-dom export the hook uses; drive its return
|
||||
// per test to simulate "on a page" vs "off a page".
|
||||
const useMatchMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useMatch: () => useMatchMock(),
|
||||
}));
|
||||
|
||||
// The bound-chat resolver is the network boundary; stub it per test.
|
||||
const getBoundChatMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
|
||||
}));
|
||||
|
||||
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
|
||||
// tests override useMatch to go off-page.
|
||||
function onPage(pageSlug = "doc-p1") {
|
||||
useMatchMock.mockReturnValue({ params: { pageSlug } });
|
||||
}
|
||||
function offPage() {
|
||||
useMatchMock.mockReturnValue(null);
|
||||
}
|
||||
|
||||
// Render the hook inside an explicit jotai store so atom side effects are
|
||||
// assertable; the store is returned for setup + assertions.
|
||||
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
|
||||
const store = createStore();
|
||||
seed?.(store);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
|
||||
return { store, open: () => act(() => result.current()) };
|
||||
}
|
||||
|
||||
describe("useOpenAiChatForCurrentPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onPage();
|
||||
});
|
||||
|
||||
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound-chat-1");
|
||||
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
|
||||
});
|
||||
|
||||
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockResolvedValue(null);
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("off a page: keeps the current selection and does NOT resolve", async () => {
|
||||
offPage();
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "keep-me");
|
||||
s.set(aiChatDraftAtom, "untouched");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
|
||||
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
|
||||
getBoundChatMock.mockResolvedValue("would-switch");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(aiChatWindowOpenAtom, true);
|
||||
s.set(activeAiChatIdAtom, "current");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("current");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
|
||||
getBoundChatMock.mockResolvedValue("same");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "same");
|
||||
s.set(aiChatDraftAtom, "in-progress");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockRejectedValue(new Error("network"));
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the picked role on a real switch", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound");
|
||||
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import {
|
||||
aiChatWindowOpenAtom,
|
||||
activeAiChatIdAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
/**
|
||||
* The generic "open the AI chat" action, WITH document binding: when invoked
|
||||
* while viewing a page, it resolves that page's bound chat and selects it before
|
||||
* opening — so the last chat for this document re-opens by itself. With no bound
|
||||
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
|
||||
* by the app-header entry point; NOT by the provenance badge (which deep-links).
|
||||
*/
|
||||
export function useOpenAiChatForCurrentPage() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// Same route-match trick the window uses: read :pageSlug from the pathname.
|
||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||
// see :pageSlug — match the full path against the authenticated page route.
|
||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||
// re-resolve and yank the user to another chat: resolve only on a genuine
|
||||
// closed -> open transition. (`windowOpen` is already true here, so there
|
||||
// is nothing to set — just bail.)
|
||||
if (windowOpen) return;
|
||||
// Open the window FIRST so the control feels instant: the bound-chat
|
||||
// round-trip below must never gate the window appearing, or on a slow
|
||||
// connection the first click reads as a hung control until the POST returns.
|
||||
setWindowOpen(true);
|
||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||
if (pageId) {
|
||||
try {
|
||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
||||
} catch {
|
||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||
}
|
||||
}
|
||||
// Clear the composer draft / picked role ONLY on an actual switch, so
|
||||
// reopening the same chat does not wipe an in-progress draft. Applied after
|
||||
// the resolve so the window is already visible while the switch settles.
|
||||
if (resolved !== activeChatId) {
|
||||
setActiveChatId(resolved);
|
||||
setDraft("");
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
}, [
|
||||
windowOpen,
|
||||
activeChatId,
|
||||
pageId,
|
||||
setWindowOpen,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
}
|
||||
@@ -13,21 +13,40 @@ import {
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
getAiRoles,
|
||||
importAiRolesFromCatalog,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
updateAiRoleFromCatalog,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} 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"];
|
||||
// Catalog reads resolve bundle names per language, so the language is part of
|
||||
// the cache key (a language switch refetches rather than reusing stale names).
|
||||
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
||||
"ai-role-catalog",
|
||||
language,
|
||||
];
|
||||
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
||||
bundleId: string,
|
||||
language: string,
|
||||
) => ["ai-role-catalog-bundle", bundleId, language];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
|
||||
* fetch runs only when the catalog modal is open. The catalog can 502 when the
|
||||
* curated source is unreachable; callers handle the error state in the UI.
|
||||
*/
|
||||
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
|
||||
return useQuery<IAiRoleCatalog, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
|
||||
queryFn: () => getAiRoleCatalog(language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
|
||||
* fetch only runs when a bundle is actually expanded.
|
||||
*/
|
||||
export function useAiRoleCatalogBundleQuery(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiRoleCatalogBundle, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Failed to import {{count}} role(s)", {
|
||||
count: result.errors.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// Imported roles can appear in the chat picker / badges.
|
||||
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 useUpdateAiRoleFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
|
||||
mutationFn: (id) => updateAiRoleFromCatalog(id),
|
||||
onSuccess: (result) => {
|
||||
// The server returns updated:false with a reason for a no-op (already
|
||||
// up to date / removed from catalog / language no longer offered). Map
|
||||
// each reason to a specific message instead of a generic "up to date".
|
||||
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
||||
// boolean discriminant does not narrow under this project's
|
||||
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
||||
// union, so the comparisons below are compiler-checked.
|
||||
let message: string;
|
||||
if (!("reason" in result)) {
|
||||
message = t("Updated to the latest version");
|
||||
} else if (result.reason === "not-in-catalog") {
|
||||
message = t("This role is no longer in the catalog");
|
||||
} else if (result.reason === "language-unavailable") {
|
||||
message = t("This language is no longer available in the catalog");
|
||||
} else {
|
||||
// "up-to-date" (the only remaining reason).
|
||||
message = t("Already up to date");
|
||||
}
|
||||
notifications.show({ message });
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
|
||||
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
|
||||
// the result carries partial errors. These tests pin both branches via
|
||||
// renderHook with a mocked service (twin precedent:
|
||||
// update-from-catalog-message.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key with interpolated values so we assert against the exact
|
||||
// English message strings (mirrors react-i18next's default interpolation).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars
|
||||
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleImportResult) {
|
||||
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useImportAiRolesFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate({
|
||||
bundleId: "general",
|
||||
language: "en",
|
||||
conflict: "rename",
|
||||
});
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
});
|
||||
});
|
||||
|
||||
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
|
||||
await runMutation({
|
||||
created: 1,
|
||||
renamed: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
message: "Imported 1, renamed 0, skipped 0",
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
|
||||
color: "red",
|
||||
message: "Failed to import 2 role(s)",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
||||
// a user-facing notification message. These tests pin each of the four branches
|
||||
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
||||
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key so we assert against the exact English message strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
}));
|
||||
|
||||
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
||||
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useUpdateAiRoleFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate("role-1");
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("updated:true -> 'Updated to the latest version'", async () => {
|
||||
await runMutation({
|
||||
updated: true,
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
role: { id: "role-1" } as never,
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Updated to the latest version",
|
||||
});
|
||||
});
|
||||
|
||||
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "not-in-catalog" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This role is no longer in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "language-unavailable" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This language is no longer available in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("up-to-date -> 'Already up to date'", async () => {
|
||||
await runMutation({ updated: false, reason: "up-to-date" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Already up to date",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,13 @@ import {
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -37,6 +42,17 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
*/
|
||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||
pageId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
export async function renameAiChat(data: {
|
||||
chatId: string;
|
||||
@@ -68,6 +84,19 @@ export async function exportAiChat(
|
||||
return req.data.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from note content (markdown). One-shot, non-streaming
|
||||
* (#199): the server only summarizes the supplied text and returns a suggestion;
|
||||
* it never writes the page. The caller applies the title via /pages/update.
|
||||
*/
|
||||
export async function generatePageTitle(content: string): Promise<string> {
|
||||
const req = await api.post<{ title: string }>(
|
||||
"/ai-chat/generate-page-title",
|
||||
{ content },
|
||||
);
|
||||
return req.data.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
@@ -99,3 +128,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
|
||||
* Browse a curated catalog, import roles/bundles into the workspace, and update
|
||||
* an imported role when the catalog ships a newer version. Same `{ data }`
|
||||
* unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** Browse the catalog, optionally localized to `language`. */
|
||||
export async function getAiRoleCatalog(
|
||||
language?: string,
|
||||
): Promise<IAiRoleCatalog> {
|
||||
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
|
||||
language,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a language (role content + versions). */
|
||||
export async function getAiRoleCatalogBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<IAiRoleCatalogBundle> {
|
||||
const req = await api.post<IAiRoleCatalogBundle>(
|
||||
"/ai-chat/roles/catalog/bundle",
|
||||
{ bundleId, language },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace (admin). */
|
||||
export async function importAiRolesFromCatalog(
|
||||
payload: IAiRoleImportPayload,
|
||||
): Promise<IAiRoleImportResult> {
|
||||
const req = await api.post<IAiRoleImportResult>(
|
||||
"/ai-chat/roles/import",
|
||||
payload,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source (admin). */
|
||||
export async function updateAiRoleFromCatalog(
|
||||
id: string,
|
||||
): Promise<IAiRoleUpdateFromCatalogResult> {
|
||||
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
|
||||
"/ai-chat/roles/update-from-catalog",
|
||||
{ id },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,79 @@ export interface IAiRole {
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one.
|
||||
// Admin-only (present only in the admin list view); the picker view omits it.
|
||||
// The admin UI compares `version` against the catalog to offer an update.
|
||||
source?: { slug: string; language: string; version: number } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
|
||||
export interface IAiRoleCatalogBundleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** The browsable catalog index (mirrors `getCatalog()`). */
|
||||
export interface IAiRoleCatalog {
|
||||
languages: string[];
|
||||
bundles: IAiRoleCatalogBundleSummary[];
|
||||
}
|
||||
|
||||
/** A single role inside an opened catalog bundle (localized content + version). */
|
||||
export interface IAiRoleCatalogRole {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
|
||||
export interface IAiRoleCatalogBundle {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: IAiRoleCatalogRole[];
|
||||
}
|
||||
|
||||
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
|
||||
export interface IAiRoleImportPayload {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
slugs?: string[];
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
|
||||
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||
* to a specific message; a successful update carries the version bump + new role.
|
||||
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||
* comparisons be compiler-checked.
|
||||
*/
|
||||
export type IAiRoleUpdateFromCatalogResult =
|
||||
| {
|
||||
updated: false;
|
||||
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||
}
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
|
||||
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Build a workspace role with a catalog source. Fields irrelevant to the
|
||||
// install-state decision are filled with harmless defaults.
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const catalogRole = { slug: "writer", version: 3 };
|
||||
|
||||
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
|
||||
// pure function so the import/installed/update decision is testable directly.
|
||||
describe("catalogRoleInstallState", () => {
|
||||
it("no matching installed role -> import", () => {
|
||||
const result = catalogRoleInstallState(catalogRole, [], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version > catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version == catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version < catalog -> update (from/to)", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("same slug but DIFFERENT language -> import (a separate install)", () => {
|
||||
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
|
||||
// fresh import, not treat the ru copy as already installed.
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "ru",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("matches the right language when the same slug is installed in several", () => {
|
||||
const ru = installedRole(
|
||||
{ slug: "writer", language: "ru", version: 5 },
|
||||
{ id: "ru-role" },
|
||||
);
|
||||
const en = installedRole(
|
||||
{ slug: "writer", language: "en", version: 1 },
|
||||
{ id: "en-role" },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed: en,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source) sharing the name", () => {
|
||||
const manual = installedRole(
|
||||
{ slug: "writer", language: "en", version: 9 },
|
||||
{ source: null },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [manual], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* The install state of a single catalog role relative to the workspace's
|
||||
* existing roles. Extracted as a pure function so the catalog modal's role-state
|
||||
* computation is unit-testable without mounting the component (mirrors the
|
||||
* `roleLaunchMessage` precedent in role-launch.ts).
|
||||
*
|
||||
* A catalog role is matched to an installed role by BOTH `source.slug` and
|
||||
* `source.language`: the same slug in a different language is a separate install
|
||||
* (so it shows as "import", not "installed"). When matched, the installed source
|
||||
* version decides the state:
|
||||
* - no match -> "import"
|
||||
* - matched & installed version >= catalog version -> "installed"
|
||||
* - matched & installed version < catalog version -> "update" (from -> to)
|
||||
*/
|
||||
export type CatalogRoleInstallState =
|
||||
| { state: "import" }
|
||||
| { state: "installed"; installed: IAiRole }
|
||||
| {
|
||||
state: "update";
|
||||
installed: IAiRole;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
};
|
||||
|
||||
export function catalogRoleInstallState(
|
||||
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogRoleInstallState {
|
||||
const installed = workspaceRoles.find(
|
||||
(r) => r.source?.slug === role.slug && r.source?.language === language,
|
||||
);
|
||||
if (!installed) return { state: "import" };
|
||||
const fromVersion = installed.source?.version ?? 0;
|
||||
if (fromVersion >= role.version) {
|
||||
return { state: "installed", installed };
|
||||
}
|
||||
return {
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion,
|
||||
toVersion: role.version,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enqueueMessage,
|
||||
dequeue,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "./queue-helpers";
|
||||
@@ -89,6 +90,52 @@ describe("removeQueuedById", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteToHead", () => {
|
||||
it("moves the matching id to the front, preserving the rest's order", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
expect(promoteToHead(queue, "c")).toEqual([
|
||||
{ id: "c", text: "third" },
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op order-wise when the id is already the head", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "a")).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an equivalent list when the id is not present", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "missing")).toEqual(queue);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
promoteToHead(queue, "b");
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO order", () => {
|
||||
it("preserves order across enqueue -> dequeue", () => {
|
||||
let queue: QueuedMessage[] = [];
|
||||
|
||||
@@ -32,3 +32,16 @@ export function removeQueuedById(
|
||||
): QueuedMessage[] {
|
||||
return queue.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
/** Move the queued message with the given id to the FRONT (returns a new array).
|
||||
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
|
||||
* "send now" action: promoting a message to the head lets the existing
|
||||
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
|
||||
export function promoteToHead(
|
||||
queue: QueuedMessage[],
|
||||
id: string,
|
||||
): QueuedMessage[] {
|
||||
const target = queue.find((m) => m.id === id);
|
||||
if (!target) return queue;
|
||||
return [target, ...queue.filter((m) => m.id !== id)];
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { templateMode = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const user = useAtomValue(userAtom);
|
||||
const editorToolbarEnabled =
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
||||
const showLinkMenuRef = useRef(showLinkMenu);
|
||||
|
||||
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
showAiMenuRef.current = showAiMenu;
|
||||
}, [showAiMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
showLinkMenuRef.current = showLinkMenu;
|
||||
}, [showLinkMenu]);
|
||||
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
empty ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
showAiMenuRef.current ||
|
||||
showLinkMenuRef.current ||
|
||||
showCommentPopupRef?.current
|
||||
) {
|
||||
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
if (showAiMenu || showLinkMenu) return;
|
||||
// Hide the bubble menu immediately when the link menu is shown
|
||||
if (showLinkMenu) return;
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
style={{ zIndex: 199, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
{isGenerativeAiEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className={clsx(classes.buttonRoot)}
|
||||
radius="0"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => {
|
||||
setShowAiMenu(true);
|
||||
}}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)}
|
||||
{!editorToolbarEnabled && (
|
||||
<>
|
||||
<NodeSelector
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
sortFrequentlyUsedEmoji,
|
||||
getFrequentlyUsedEmoji,
|
||||
LOCAL_STORAGE_FREQUENT_KEY,
|
||||
} from "./utils";
|
||||
|
||||
describe("sortFrequentlyUsedEmoji", () => {
|
||||
it("orders known emoji by descending usage count", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
rocket: 1,
|
||||
joy: 9,
|
||||
heart_eyes: 5,
|
||||
});
|
||||
expect(result.map((e) => e.id)).toEqual(["joy", "heart_eyes", "rocket"]);
|
||||
});
|
||||
|
||||
it("caps the result at the top 5 most frequent", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
rocket: 1,
|
||||
joy: 2,
|
||||
heart_eyes: 3,
|
||||
grinning: 4,
|
||||
laughing: 5,
|
||||
scream: 6,
|
||||
sweat_smile: 7,
|
||||
});
|
||||
expect(result).toHaveLength(5);
|
||||
// Highest counts retained, lowest (rocket:1, joy:2) dropped.
|
||||
expect(result.map((e) => e.id)).toEqual([
|
||||
"sweat_smile",
|
||||
"scream",
|
||||
"laughing",
|
||||
"grinning",
|
||||
"heart_eyes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops ids that have no matching emoji in the index", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
__definitely_not_a_real_emoji_id__: 100,
|
||||
rocket: 1,
|
||||
});
|
||||
expect(result.map((e) => e.id)).toEqual(["rocket"]);
|
||||
});
|
||||
|
||||
it("maps each entry to its native glyph and a command", async () => {
|
||||
const [entry] = await sortFrequentlyUsedEmoji({ rocket: 5 });
|
||||
expect(entry.id).toBe("rocket");
|
||||
expect(typeof entry.emoji).toBe("string");
|
||||
expect(entry.emoji.length).toBeGreaterThan(0);
|
||||
expect(typeof entry.command).toBe("function");
|
||||
});
|
||||
|
||||
it("returns an empty list for empty input", async () => {
|
||||
expect(await sortFrequentlyUsedEmoji({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFrequentlyUsedEmoji", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("falls back to the default map when nothing is stored", () => {
|
||||
const result = getFrequentlyUsedEmoji();
|
||||
expect(result["+1"]).toBe(10);
|
||||
expect(result["rocket"]).toBe(1);
|
||||
});
|
||||
|
||||
it("parses a valid stored JSON map", () => {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_FREQUENT_KEY,
|
||||
JSON.stringify({ rocket: 42 }),
|
||||
);
|
||||
expect(getFrequentlyUsedEmoji()).toEqual({ rocket: 42 });
|
||||
});
|
||||
|
||||
// BUG (issue #204, Phase 2): getFrequentlyUsedEmoji() does an unprotected
|
||||
// JSON.parse() of the raw localStorage value. A corrupt value (e.g. truncated
|
||||
// by a crash, or written by another tab/extension) makes the emoji menu throw
|
||||
// on open instead of degrading gracefully to the default set.
|
||||
//
|
||||
// Documented with it.fails: this asserts the DESIRED behavior (return a sane
|
||||
// default, never throw). It currently FAILS because the function throws —
|
||||
// flip to `it()` once utils.ts guards the JSON.parse.
|
||||
it.fails(
|
||||
"should degrade to a sane default on corrupt localStorage (currently throws)",
|
||||
() => {
|
||||
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, "{not valid json");
|
||||
let result: Record<string, number> | undefined;
|
||||
expect(() => {
|
||||
result = getFrequentlyUsedEmoji();
|
||||
}).not.toThrow();
|
||||
// Should hand back a usable, non-empty map rather than nothing.
|
||||
expect(result).toBeTruthy();
|
||||
expect(Object.keys(result ?? {}).length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
|
||||
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 { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
type FixedToolbarProps = {
|
||||
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||
const editor = editorProp ?? editorFromAtom;
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={classes.inner}>
|
||||
{/* {isGenerativeAiEnabled && (
|
||||
<>
|
||||
<AskAiGroup />
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)} */}
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const AskAiGroup: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const setShowAiMenu = useSetAtom(showAiMenuAtom);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="xs"
|
||||
leftSection={<IconSparkles size={14} />}
|
||||
onClick={() => setShowAiMenu(true)}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
color?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI "generate title" button (#199). Reads the live editor content and applies a
|
||||
* model-suggested title immediately. Rendered in the page byline, only in edit
|
||||
* mode and when the workspace's AI chat flag is on.
|
||||
*/
|
||||
export const GenerateTitleGroup: FC<Props> = ({
|
||||
pageId,
|
||||
color = "gray",
|
||||
iconSize = 20,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const gen = useGeneratePageTitle(pageId);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={color}
|
||||
aria-label={t("Generate title with AI")}
|
||||
loading={gen.isPending}
|
||||
onClick={() => gen.mutate()}
|
||||
>
|
||||
<IconSparkles size={iconSize} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
isHeaderCell,
|
||||
sortItems,
|
||||
weaveItems,
|
||||
type SortableItem,
|
||||
} from "./sort-cells";
|
||||
|
||||
// isHeaderCell only reads node.type.name and node.attrs?.header, so a minimal
|
||||
// duck-typed node is sufficient (no real ProseMirror schema needed).
|
||||
function fakeNode(typeName: string, attrs: Record<string, unknown> = {}) {
|
||||
return { type: { name: typeName }, attrs } as unknown as ProseMirrorNode;
|
||||
}
|
||||
|
||||
function item<T>(
|
||||
payload: T,
|
||||
text: string,
|
||||
originalOrder: number,
|
||||
opts: { isHeader?: boolean; isEmpty?: boolean } = {},
|
||||
): SortableItem<T> {
|
||||
return {
|
||||
payload,
|
||||
text,
|
||||
originalOrder,
|
||||
isHeader: opts.isHeader ?? false,
|
||||
isEmpty: opts.isEmpty ?? text.trim() === "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("isHeaderCell", () => {
|
||||
it("recognizes the tableHeader node type", () => {
|
||||
expect(isHeaderCell(fakeNode("tableHeader"))).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes the snake_case table_header node type", () => {
|
||||
expect(isHeaderCell(fakeNode("table_header"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a plain cell with header:true attr as a header", () => {
|
||||
expect(isHeaderCell(fakeNode("tableCell", { header: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a regular body cell", () => {
|
||||
expect(isHeaderCell(fakeNode("tableCell", { header: false }))).toBe(false);
|
||||
expect(isHeaderCell(fakeNode("tableCell"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortItems", () => {
|
||||
it("sorts non-empty rows ascending using a base/numeric collator", () => {
|
||||
const data = [
|
||||
item("c", "cherry", 0),
|
||||
item("a", "Apple", 1),
|
||||
item("b", "banana", 2),
|
||||
];
|
||||
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts descending when direction is desc", () => {
|
||||
const data = [
|
||||
item("a", "apple", 0),
|
||||
item("b", "banana", 1),
|
||||
item("c", "cherry", 2),
|
||||
];
|
||||
expect(sortItems(data, "desc").map((i) => i.payload)).toEqual([
|
||||
"c",
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders numerically, not lexically (numeric collator)", () => {
|
||||
const data = [
|
||||
item("ten", "10", 0),
|
||||
item("two", "2", 1),
|
||||
item("one", "1", 2),
|
||||
];
|
||||
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"ten",
|
||||
]);
|
||||
});
|
||||
|
||||
it("always pushes empty cells to the bottom regardless of direction", () => {
|
||||
const data = [
|
||||
item("empty", "", 0, { isEmpty: true }),
|
||||
item("b", "banana", 1),
|
||||
item("a", "apple", 2),
|
||||
];
|
||||
const asc = sortItems(data, "asc");
|
||||
expect(asc.map((i) => i.payload)).toEqual(["a", "b", "empty"]);
|
||||
const desc = sortItems(data, "desc");
|
||||
// Empty stays last even when the rest is reversed.
|
||||
expect(desc[desc.length - 1].payload).toBe("empty");
|
||||
});
|
||||
|
||||
it("keeps empty cells in their original relative order (stable)", () => {
|
||||
const data = [
|
||||
item("e1", "", 5, { isEmpty: true }),
|
||||
item("e2", "", 2, { isEmpty: true }),
|
||||
item("a", "apple", 9),
|
||||
];
|
||||
const sorted = sortItems(data, "asc");
|
||||
// e2 (originalOrder 2) before e1 (originalOrder 5).
|
||||
expect(sorted.map((i) => i.payload)).toEqual(["a", "e2", "e1"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const data = [item("b", "banana", 0), item("a", "apple", 1)];
|
||||
const snapshot = data.map((i) => i.payload);
|
||||
sortItems(data, "asc");
|
||||
expect(data.map((i) => i.payload)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weaveItems", () => {
|
||||
it("keeps header rows pinned in place and fills body slots from sorted data", () => {
|
||||
const header = item("H", "Name", 0, { isHeader: true });
|
||||
const all = [
|
||||
header,
|
||||
item("orig-b", "b", 1),
|
||||
item("orig-a", "a", 2),
|
||||
];
|
||||
const sortedBody = [item("orig-a", "a", 2), item("orig-b", "b", 1)];
|
||||
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
// Header never moves out of row 0...
|
||||
expect(woven[0]).toBe(header);
|
||||
// ...and the body positions are filled in sorted order.
|
||||
expect(woven.slice(1).map((i) => i.payload)).toEqual(["orig-a", "orig-b"]);
|
||||
});
|
||||
|
||||
it("does not consume body data for header positions (header stays at top)", () => {
|
||||
const header = item("H", "head", 0, { isHeader: true });
|
||||
const all = [header, item("x", "x", 1), item("y", "y", 2)];
|
||||
const sortedBody = [item("y", "y", 2), item("x", "x", 1)];
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
expect(woven[0].isHeader).toBe(true);
|
||||
expect(woven.filter((i) => !i.isHeader).map((i) => i.payload)).toEqual([
|
||||
"y",
|
||||
"x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("interleaves correctly when a header sits between body rows", () => {
|
||||
const header = item("H", "head", 1, { isHeader: true });
|
||||
const all = [
|
||||
item("b1", "b1", 0),
|
||||
header,
|
||||
item("b2", "b2", 2),
|
||||
];
|
||||
const sortedBody = [item("b2", "b2", 2), item("b1", "b1", 0)];
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
expect(woven.map((i) => i.payload)).toEqual(["b2", "H", "b1"]);
|
||||
expect(woven[1]).toBe(header);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/features/editor/editor-sync-state.test.ts
Normal file
32
apps/client/src/features/editor/editor-sync-state.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
||||
|
||||
describe("isCollabSynced", () => {
|
||||
it("is true only when Connected and synced", () => {
|
||||
expect(isCollabSynced(WebSocketStatus.Connected, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false while connecting or not yet synced", () => {
|
||||
expect(isCollabSynced(WebSocketStatus.Connecting, true)).toBe(false);
|
||||
expect(isCollabSynced(WebSocketStatus.Connected, false)).toBe(false);
|
||||
expect(isCollabSynced(WebSocketStatus.Disconnected, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
||||
const base = { editable: true, inEditMode: true, showStatic: false };
|
||||
|
||||
it("allows editing only after the static (pre-sync) phase ends", () => {
|
||||
expect(isBodyEditable(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("never editable while the static read-only editor is shown", () => {
|
||||
expect(isBodyEditable({ ...base, showStatic: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("honors read-only and view mode", () => {
|
||||
expect(isBodyEditable({ ...base, editable: false })).toBe(false);
|
||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/features/editor/editor-sync-state.ts
Normal file
32
apps/client/src/features/editor/editor-sync-state.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
|
||||
/**
|
||||
* The collab document is usable only once the provider is Connected AND has
|
||||
* synced (both the local IndexedDB replica and the remote room). Until then the
|
||||
* in-browser Y.Doc is empty/stale, so edits would either be dropped or clobber
|
||||
* the server's authoritative doc when it finally arrives.
|
||||
*/
|
||||
export function isCollabSynced(
|
||||
status: WebSocketStatus | string,
|
||||
isSynced: boolean,
|
||||
): boolean {
|
||||
return status === WebSocketStatus.Connected && isSynced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page BODY editor may accept edits.
|
||||
*
|
||||
* `showStatic` is true during the pre-sync window (a read-only static editor is
|
||||
* shown). Gating editability on `!showStatic` guarantees the body never becomes
|
||||
* editable before the collab doc is synced, so early keystrokes on a freshly
|
||||
* created page can't land only in local ProseMirror and then be lost when the
|
||||
* server's initial empty doc syncs in (#218). Read-only and view modes are
|
||||
* still honored via `editable`/`inEditMode`.
|
||||
*/
|
||||
export function isBodyEditable(opts: {
|
||||
editable: boolean;
|
||||
inEditMode: boolean;
|
||||
showStatic: boolean;
|
||||
}): boolean {
|
||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import {
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
|
||||
|
||||
/**
|
||||
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
|
||||
* applied with a manual transaction (handlePaste returns true), so it bypasses
|
||||
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
|
||||
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
|
||||
* out-of-order markdown footnote block come out canonical (issue #228).
|
||||
*/
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
];
|
||||
|
||||
function makeSchema() {
|
||||
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
|
||||
const { schema } = editor;
|
||||
return { editor, schema };
|
||||
}
|
||||
|
||||
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
|
||||
function listIds(slice: Slice): string[] {
|
||||
const out: string[] = [];
|
||||
slice.content.forEach((node: PMNode) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
node.content.forEach((def: PMNode) => {
|
||||
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasList(slice: Slice): boolean {
|
||||
let found = false;
|
||||
slice.content.forEach((n: PMNode) => {
|
||||
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("canonicalizePastedFootnotes", () => {
|
||||
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
|
||||
const { editor, schema } = makeSchema();
|
||||
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
|
||||
// (z is an orphan) — the exact shape a markdown paste produces.
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.text("body "),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note C")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
// Reference order, orphan z dropped, reused a appears once.
|
||||
expect(listIds(out)).toEqual(["c", "a", "b"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
|
||||
// A paste that reuses an id defined in the TARGET doc must NOT gain a
|
||||
// synthesized empty definition here — it carries no footnotesList of its own.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.from(
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.text("see "),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(hasList(out)).toBe(false);
|
||||
expect(out).toBe(slice); // returned unchanged (same reference)
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => {
|
||||
// A whole-block paste of ONLY definitions (a footnotesList with no matching
|
||||
// footnoteReference anywhere in the selection). Canonicalizing it would strip
|
||||
// the reference-less list -> an EMPTY paste, losing the pasted text. The hook
|
||||
// must leave such a block untouched.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(out).toBe(slice); // returned unchanged (same reference, content kept)
|
||||
expect(listIds(out)).toEqual(["a", "b"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves an open (partial) slice untouched even if it carries a list", () => {
|
||||
// An open slice (openStart/openEnd > 0) is a partial selection, not a
|
||||
// standalone block, so it is returned as-is BEFORE any footnote handling.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("A")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(out).toBe(slice);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||
|
||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||
function root(html: string): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div;
|
||||
}
|
||||
|
||||
function firstRowColWidths(container: HTMLElement): (string | null)[] {
|
||||
const row = container.querySelector("tr");
|
||||
return Array.from(row?.children ?? []).map((c) =>
|
||||
c.getAttribute("colwidth"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("normalizeTableColumnWidths", () => {
|
||||
// The core "squash столбцов вставленной таблицы" concern: markdown has no
|
||||
// widths, so every pasted table would otherwise render at table-layout:fixed
|
||||
// / 100% and squash columns. This stamps an explicit per-column px width.
|
||||
it("stamps the default px width on every column when no widths are present", () => {
|
||||
const container = root(
|
||||
"<table><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]);
|
||||
});
|
||||
|
||||
it("derives column widths from a colgroup", () => {
|
||||
const container = root(
|
||||
"<table>" +
|
||||
'<colgroup><col style="width:200px"><col style="width:80px"></colgroup>' +
|
||||
"<tbody><tr><td>a</td><td>b</td></tr></tbody>" +
|
||||
"</table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["200", "80"]);
|
||||
});
|
||||
|
||||
it("derives column widths from per-cell width attributes", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td width="120">a</td><td width="90">b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["120", "90"]);
|
||||
});
|
||||
|
||||
it("derives column widths from a cell style:width:px", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td style="width:140px">a</td><td>b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
// First cell width parsed; a fully-unmeasured column is left untouched
|
||||
// (the 100 fallback only fills in NULL gaps inside an otherwise-measured
|
||||
// multi-column slice, e.g. a colspan).
|
||||
expect(firstRowColWidths(container)).toEqual(["140", null]);
|
||||
});
|
||||
|
||||
it("fills a null gap inside a measured colspanned slice with 100", () => {
|
||||
// colgroup gives [200, null]; the single colspan=2 cell spans both, so its
|
||||
// slice is [200, null] -> the null is backfilled to 100 => "200,100".
|
||||
const container = root(
|
||||
"<table>" +
|
||||
'<colgroup><col style="width:200px"><col></colgroup>' +
|
||||
'<tbody><tr><td colspan="2">merged</td></tr></tbody>' +
|
||||
"</table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["200,100"]);
|
||||
});
|
||||
|
||||
it("splits a measured width across a colspanned cell", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colspan="2" width="300">merged</td><td width="100">x</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
// 300 / colspan(2) = 150 per underlying column => "150,150" on the merged cell.
|
||||
expect(firstRowColWidths(container)).toEqual(["150,150", "100"]);
|
||||
});
|
||||
|
||||
it("falls back to the default width per spanned column when nothing is measurable", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colspan="2">merged</td><td>x</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["150,150", "150"]);
|
||||
});
|
||||
|
||||
it("leaves cells that already have a colwidth untouched", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colwidth="42">a</td><td>b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["42", "150"]);
|
||||
});
|
||||
|
||||
it("normalizes every table in the subtree", () => {
|
||||
const container = root(
|
||||
"<table><tbody><tr><td>a</td></tr></tbody></table>" +
|
||||
"<table><tbody><tr><td>b</td><td>c</td></tr></tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
const tables = container.querySelectorAll("table");
|
||||
const widths = Array.from(tables).map((t) =>
|
||||
Array.from(t.querySelector("tr")!.children).map((c) =>
|
||||
c.getAttribute("colwidth"),
|
||||
),
|
||||
);
|
||||
expect(widths).toEqual([["150"], ["150", "150"]]);
|
||||
});
|
||||
|
||||
it("only annotates the first row (column widths are defined once)", () => {
|
||||
const container = root(
|
||||
"<table><tbody>" +
|
||||
"<tr><td>a</td><td>b</td></tr>" +
|
||||
"<tr><td>c</td><td>d</td></tr>" +
|
||||
"</tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
const rows = container.querySelectorAll("tr");
|
||||
expect(
|
||||
Array.from(rows[1].children).map((c) => c.getAttribute("colwidth")),
|
||||
).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,14 @@ import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
markdownToHtml,
|
||||
htmlToMarkdown,
|
||||
canonicalizeFootnotes,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import type { Schema } from "@tiptap/pm/model";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
@@ -83,12 +90,25 @@ export const MarkdownClipboard = Extension.create({
|
||||
const body = elementFromString(parsed);
|
||||
normalizeTableColumnWidths(body);
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
const parsedSlice = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(body, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
||||
// reorders an existing list. So a pasted markdown block whose footnote
|
||||
// definitions are out of order (or contains orphan defs) would be
|
||||
// stored out of order. Canonicalize the self-contained pasted block so
|
||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
||||
// to whole-block pastes that carry their own footnotesList.
|
||||
const contentNodes = canonicalizePastedFootnotes(
|
||||
parsedSlice,
|
||||
this.editor.schema,
|
||||
);
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
@@ -133,6 +153,54 @@ export const MarkdownClipboard = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||
* list, so an out-of-order pasted block would otherwise persist out of order).
|
||||
*
|
||||
* Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry
|
||||
* their OWN footnotesList: canonicalizeFootnotes would synthesize empty
|
||||
* definitions for any reference lacking a definition, which is correct for a
|
||||
* standalone block but would be wrong for a reference-only paste that REUSES a
|
||||
* footnote already defined in the target document — so those are left untouched
|
||||
* for the paste/sync plugins to merge. Residual: when the pasted block is merged
|
||||
* into a doc that already has footnotes, ordering RELATIVE to the pre-existing
|
||||
* footnotes is still governed by the sync plugin (which does not reorder).
|
||||
*
|
||||
* Also requires at least one footnoteReference in the selection: a definitions-ONLY
|
||||
* paste (`[^a]: …` with no `[^a]` reference in the same block) has no references,
|
||||
* so canonicalizeFootnotes would drop the whole list and the paste would come out
|
||||
* EMPTY — losing the pasted text. Such a block is left as-is for the sync plugin.
|
||||
*/
|
||||
export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice {
|
||||
if (slice.openStart !== 0 || slice.openEnd !== 0) return slice;
|
||||
|
||||
let hasFootnotesList = false;
|
||||
let hasReference = false;
|
||||
slice.content.forEach((node) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true;
|
||||
// footnoteReference is an inline atom, never a top-level slice child here
|
||||
// (this function early-returns for open slices, so children are whole
|
||||
// blocks), so it is only reachable by descending.
|
||||
node.descendants((child) => {
|
||||
if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true;
|
||||
});
|
||||
});
|
||||
if (!hasFootnotesList) return slice;
|
||||
// No reference anywhere -> a definitions-only paste; canonicalizing would strip
|
||||
// the reference-less list (empty paste). Leave it untouched.
|
||||
if (!hasReference) return slice;
|
||||
|
||||
const content = slice.content.toJSON();
|
||||
if (!Array.isArray(content)) return slice;
|
||||
|
||||
const canonical = canonicalizeFootnotes({ type: "doc", content }) as {
|
||||
content?: unknown[];
|
||||
};
|
||||
const fragment = Fragment.fromJSON(schema, canonical.content ?? []);
|
||||
return new Slice(fragment, 0, 0);
|
||||
}
|
||||
|
||||
function elementFromString(value) {
|
||||
// add a wrapper to preserve leading and trailing whitespace
|
||||
const wrappedValue = `<body>${value}</body>`;
|
||||
|
||||
@@ -26,17 +26,20 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
|
||||
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 { TemporaryNoteBanner } from "@/features/page/components/temporary-note-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 { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
|
||||
|
||||
type PageUser = {
|
||||
id: string;
|
||||
@@ -74,6 +77,9 @@ export function FullEditor({
|
||||
const [user] = useAtom(userAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
@@ -103,6 +109,7 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
@@ -111,11 +118,13 @@ export function FullEditor({
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
@@ -128,19 +137,23 @@ export function FullEditor({
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
pageId: string;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
editable?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDictationEnabled?: boolean;
|
||||
isTitleGenEnabled?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
pageId,
|
||||
creator,
|
||||
contributors,
|
||||
editable,
|
||||
isEditMode,
|
||||
isDictationEnabled,
|
||||
isTitleGenEnabled,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
@@ -148,6 +161,9 @@ function PageByline({
|
||||
const showDictation = Boolean(
|
||||
isDictationEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
const showTitleGen = Boolean(
|
||||
isTitleGenEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -238,6 +254,11 @@ function PageByline({
|
||||
{showDictation && editor && (
|
||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||
)}
|
||||
{/* Shown only in edit mode when the workspace's AI chat flag is on,
|
||||
so AI title generation stays reachable from the byline (#199). */}
|
||||
{showTitleGen && (
|
||||
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// --- Mocks for the hook's collaborators ---------------------------------------
|
||||
|
||||
const generatePageTitleMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
||||
}));
|
||||
|
||||
const updateTitleMock = vi.fn();
|
||||
const updatePageDataMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
||||
updatePageData: (page: unknown) => updatePageDataMock(page),
|
||||
}));
|
||||
|
||||
const emitMock = vi.fn();
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => emitMock,
|
||||
}));
|
||||
|
||||
const localEmitMock = vi.fn();
|
||||
vi.mock("@/lib/local-emitter.ts", () => ({
|
||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||
}));
|
||||
|
||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||
// purely via the fake page editor's getHTML().
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
htmlToMarkdown: (html: string) => html,
|
||||
}));
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Import after mocks are registered.
|
||||
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||
|
||||
// --- Test helpers -------------------------------------------------------------
|
||||
|
||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
getHTML: () => html,
|
||||
storage: { pageId },
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
function makeTitleEditor(): Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
isFocused: false,
|
||||
commands: { setContent: vi.fn() },
|
||||
} as unknown as Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
}
|
||||
|
||||
function setup(pageId: string, store = createStore()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
||||
wrapper,
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
const PAGE_A = {
|
||||
id: "pageA",
|
||||
title: "Generated Title",
|
||||
spaceId: "space1",
|
||||
slugId: "slugA",
|
||||
parentPageId: null,
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useGeneratePageTitle", () => {
|
||||
it("shows a notice and bails when the editor content is empty", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
||||
);
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the title untouched when the model returns nothing usable", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockResolvedValue(" ");
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Could not generate a title",
|
||||
color: "yellow",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
generatePageTitleMock.mockResolvedValue("Generated Title");
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Title generated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Control when generation resolves so we can navigate mid-flight.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// User navigates to page B: the live page editor now belongs to pageB.
|
||||
act(() => {
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// DB write is still correct (keyed by the captured pageId)...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bails before calling the model when the page editor is destroyed", async () => {
|
||||
const store = createStore();
|
||||
const pageEditor = makePageEditor("pageA");
|
||||
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
||||
store.set(pageEditorAtom as never, pageEditor);
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[403, "AI title generation is disabled"],
|
||||
[503, "AI is not configured"],
|
||||
[429, "Too many requests, please try again later"],
|
||||
[500, "Failed to generate title"],
|
||||
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message, color: "red" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
// Maximum length we send to the model. The server truncates again; this is a
|
||||
// cheap client-side bound so we never ship a huge body over the wire.
|
||||
const MAX_CONTENT_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* Generate a title for the given page from the LIVE editor content (#199),
|
||||
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
|
||||
* server endpoint only summarizes the supplied markdown — it never writes the
|
||||
* page; the actual title write goes through the existing /pages/update mutation
|
||||
* (which enforces edit permission), and is mirrored to the title field + other
|
||||
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
|
||||
* button can show a loading state via `isPending`.
|
||||
*/
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
|
||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
||||
if (!markdown) {
|
||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (
|
||||
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
|
||||
).trim();
|
||||
if (!title) {
|
||||
// The model returned nothing usable — keep the existing title untouched.
|
||||
notifications.show({
|
||||
message: t("Could not generate a title"),
|
||||
color: "yellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
|
||||
notifications.show({ message: t("Title generated") });
|
||||
},
|
||||
onError: (err) => {
|
||||
// Map known HTTP statuses to friendly messages, falling back to generic.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
const message =
|
||||
status === 403
|
||||
? t("AI title generation is disabled")
|
||||
: status === 503
|
||||
? t("AI is not configured")
|
||||
: status === 429
|
||||
? t("Too many requests, please try again later")
|
||||
: t("Failed to generate title");
|
||||
notifications.show({ message, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -84,6 +84,10 @@ import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed
|
||||
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";
|
||||
import {
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -440,6 +444,9 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -451,17 +458,21 @@ export default function PageEditor({
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||
}, [currentPageEditMode, editor, editable]);
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
// Keep the body read-only until the collab doc has synced (showStatic), so
|
||||
// early keystrokes on a freshly created page can't be lost (#218).
|
||||
editor.setEditable(
|
||||
isBodyEditable({
|
||||
editable,
|
||||
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||
showStatic,
|
||||
}),
|
||||
);
|
||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||
isSynced
|
||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
@@ -473,17 +484,43 @@ export default function PageEditor({
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Surface the pre-sync read-only window so edits typed before the
|
||||
collab provider connects aren't silently swallowed (#218). Shown
|
||||
only when the user is otherwise allowed to edit. */}
|
||||
{editable && currentPageEditMode === PageEditMode.Edit && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
padding: "2px 8px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--mantine-color-gray-light)",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{t("Connecting… (read-only)")}
|
||||
</div>
|
||||
)}
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Menu, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { canCreatePage } from "./can-create-page.ts";
|
||||
|
||||
// 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() {
|
||||
// A single create-note action, parametrized by `temporary`. Self-contained: it
|
||||
// owns its own create mutation so the regular and temporary buttons show
|
||||
// independent loading state, while the list of writable spaces is resolved once
|
||||
// by the parent and passed in. With exactly one writable space it creates
|
||||
// directly; with several it shows a target-space picker.
|
||||
function CreateNoteButton({
|
||||
writableSpaces,
|
||||
temporary,
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
writableSpaces: ISpace[];
|
||||
temporary: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
// Mantine color token; lets the temporary action tint toward the warm
|
||||
// orange/amber used by the clock marker + banner while "New note" stays neutral.
|
||||
color: string;
|
||||
}) {
|
||||
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.
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
onClick={() => createNote(writableSpaces[0])}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
|
||||
<Menu shadow="md" width="target" position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// Prominent home-screen actions 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. Renders two full-width, vertically stacked buttons: a
|
||||
// neutral regular note and an orange-tinted temporary note (which auto-moves to
|
||||
// Trash after the workspace lifetime). Stacking full-width keeps the longer
|
||||
// "New temporary note" label from clipping on narrow mobile widths.
|
||||
export default function NewNoteButton() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
||||
|
||||
// No writable space → nothing to create in; render nothing.
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={false}
|
||||
label={t("New note")}
|
||||
icon={<IconPlus size={18} />}
|
||||
color="gray"
|
||||
/>
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={true}
|
||||
label={t("New temporary note")}
|
||||
icon={<IconHourglass size={18} />}
|
||||
color="orange"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getDefaultStore } from "jotai";
|
||||
|
||||
// Mock the app entry so importing the query module doesn't boot the whole app
|
||||
// (it only needs queryClient's cache methods, which we stub here). The spies are
|
||||
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
|
||||
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(() => undefined as unknown),
|
||||
invalidateQueries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData, getQueryData, invalidateQueries },
|
||||
}));
|
||||
|
||||
import { syncTemporaryExpiresInCache } from "./page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
|
||||
({
|
||||
id,
|
||||
slugId,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
}) as unknown as SpaceTreeNode;
|
||||
|
||||
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getQueryData.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
const deadline = "2026-07-01T00:00:00.000Z";
|
||||
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
|
||||
|
||||
const next = store.get(treeDataAtom);
|
||||
// A new atom value was written...
|
||||
expect(next).not.toBe(tree);
|
||||
// ...the matching node gained the deadline...
|
||||
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
|
||||
// ...and the untouched sibling is unchanged.
|
||||
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
syncTemporaryExpiresInCache(
|
||||
{ id: "not-in-tree", slugId: "missing" },
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
);
|
||||
|
||||
// treeModel.update is a no-op (same reference) for an unknown id, so the
|
||||
// guard skips the store write entirely — same reference back.
|
||||
expect(store.get(treeDataAtom)).toBe(tree);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
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";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
} from "@/features/page-embed/services/page-embed-api";
|
||||
import type {
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||
* Centralised here so the header menu and the banner can't drift apart on the
|
||||
* cache-key plumbing.
|
||||
*/
|
||||
export function syncTemporaryExpiresInCache(
|
||||
page: { id: string; slugId: string },
|
||||
temporaryExpiresAt: string | null,
|
||||
) {
|
||||
for (const key of [page.slugId, page.id]) {
|
||||
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData(["pages", key], {
|
||||
...cached,
|
||||
temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Patch the in-memory sidebar tree node so its temporary clock marker
|
||||
// appears/disappears immediately — WITHOUT a reload. The page cache update
|
||||
// above only drives the in-page banner/menu; the sidebar reads
|
||||
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
|
||||
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
|
||||
// store the sidebar's hooks read from. `treeModel.update` returns the same
|
||||
// reference (a no-op) when the page isn't in the currently loaded tree.
|
||||
const store = getDefaultStore();
|
||||
const prevTree = store.get(treeDataAtom);
|
||||
const nextTree = treeModel.update(prevTree, page.id, {
|
||||
temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +68,20 @@ export function useToggleTemplateMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemporaryMutation() {
|
||||
return useMutation<
|
||||
ToggleTemporaryResponse,
|
||||
Error,
|
||||
{ pageId: string; temporary?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemporary(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message || "Failed to update temporary note",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemporary(params: {
|
||||
pageId: string;
|
||||
temporary?: boolean;
|
||||
}): Promise<ToggleTemporaryResponse> {
|
||||
const r = await api.post("/pages/toggle-temporary", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ToggleTemporaryResponse = {
|
||||
pageId: string;
|
||||
// null => the note was made permanent; ISO string => armed deadline.
|
||||
temporaryExpiresAt: string | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
|
||||
// it carries an aiChatId — proving the not-clickable assertion above is real.
|
||||
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
const agentBadge = screen.getByText("AI-agent");
|
||||
const root = agentBadge.closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
within(root as HTMLElement).getByText("AI-agent");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -41,6 +42,7 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -108,6 +110,10 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitSyncEdit && (
|
||||
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -15,8 +15,12 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
usePageQuery,
|
||||
usePageBreadcrumbsQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -38,14 +42,29 @@ export default function Breadcrumb() {
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
// The page's own ancestor chain, fetched independently of the lazily-built
|
||||
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
|
||||
// while the tree backfills (#218).
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData?.length > 0 && currentPage) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
setBreadcrumbNodes(breadcrumb || null);
|
||||
}
|
||||
}, [currentPage?.id, treeData]);
|
||||
if (!currentPage) return;
|
||||
|
||||
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
||||
// (#218). It resolves the correct chain when possible and, on a transient
|
||||
// miss, clears a chain left over from a previously-viewed page instead of
|
||||
// showing the wrong trail — while keeping a chain already resolved for THIS
|
||||
// page to avoid a blank flash.
|
||||
setBreadcrumbNodes((previous) =>
|
||||
computeBreadcrumbState(
|
||||
treeData,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
previous,
|
||||
),
|
||||
);
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeBreadcrumbState,
|
||||
resolveBreadcrumbNodes,
|
||||
} from "./breadcrumb.utils";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
|
||||
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
|
||||
// null so the component keeps its prior state.
|
||||
|
||||
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: `node-${id}`,
|
||||
icon: null,
|
||||
position: "a",
|
||||
hasChildren: false,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
children: [],
|
||||
...over,
|
||||
} as SpaceTreeNode;
|
||||
}
|
||||
|
||||
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `title-${id}`,
|
||||
icon: "📄",
|
||||
position: "m",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: true,
|
||||
...over,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe("resolveBreadcrumbNodes", () => {
|
||||
it("tree-hit: returns the path found in the live sidebar tree", () => {
|
||||
const child = treeNode("child");
|
||||
const root = treeNode("root", { hasChildren: true, children: [child] });
|
||||
// findBreadcrumbPath walks the tree; the chain ends at the target page.
|
||||
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
|
||||
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
|
||||
expect(result![result!.length - 1].icon).toBeNull();
|
||||
});
|
||||
|
||||
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
|
||||
// Tree has no node for the target page -> findBreadcrumbPath misses.
|
||||
const unrelated = treeNode("unrelated");
|
||||
const ancestors = [
|
||||
ancestorPage("a", { hasChildren: true }),
|
||||
ancestorPage("b", { hasChildren: undefined as any }),
|
||||
];
|
||||
|
||||
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
// Non-trivial field transform: title -> name.
|
||||
expect(result![0].name).toBe("title-a");
|
||||
// hasChildren defaults to false when the ancestor row omits it.
|
||||
expect(result![1].hasChildren).toBe(false);
|
||||
expect(result![0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to ancestors when the tree is empty", () => {
|
||||
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
|
||||
expect(result!.map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns null when there is no tree hit and no ancestor data", () => {
|
||||
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBreadcrumbState (stale-chain clearing on navigation)", () => {
|
||||
it("uses a freshly resolved chain when available", () => {
|
||||
const child = treeNode("B");
|
||||
const root = treeNode("root", { hasChildren: true, children: [child] });
|
||||
const next = computeBreadcrumbState([root], null, "B", null);
|
||||
expect(next!.map((n) => n.id)).toEqual(["root", "B"]);
|
||||
});
|
||||
|
||||
it("navigating A->B to a page absent from treeData clears the previous A chain (no stale trail)", () => {
|
||||
// Previous chain ends at page A; we are now on page B, which is not yet in
|
||||
// the lazily-built tree and whose ancestors have not loaded.
|
||||
const previous = [treeNode("rootA"), treeNode("A")];
|
||||
const next = computeBreadcrumbState([treeNode("unrelated")], undefined, "B", previous);
|
||||
// Must NOT keep showing A's (clickable) chain.
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a chain that already ends at the current page through a transient miss", () => {
|
||||
// We already resolved B once (chain ends at B); a transient miss must not
|
||||
// blank it.
|
||||
const previous = [treeNode("rootB"), treeNode("B")];
|
||||
const next = computeBreadcrumbState([], undefined, "B", previous);
|
||||
expect(next).toBe(previous);
|
||||
});
|
||||
|
||||
it("returns null when nothing resolves and there is no previous chain", () => {
|
||||
expect(computeBreadcrumbState([], undefined, "B", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { findBreadcrumbPath, pageToTreeNode } from "@/features/page/tree/utils";
|
||||
|
||||
/**
|
||||
* Pure selection/mapping for the breadcrumb nodes (#218). Three branches:
|
||||
* 1. tree-hit — the lazily-built sidebar tree already contains this page's
|
||||
* ancestor chain, so prefer it (stays live with sidebar renames/moves).
|
||||
* 2. tree-miss — fall back to the page's own ancestor data so a deep page
|
||||
* resolves immediately instead of rendering a blank breadcrumb for seconds
|
||||
* while the tree backfills. Mapped through the canonical `pageToTreeNode`
|
||||
* (title -> name, hasChildren defaulted to false).
|
||||
* 3. neither — no data yet, return null (the caller decides whether to keep
|
||||
* a prior chain via computeBreadcrumbState).
|
||||
*/
|
||||
export function resolveBreadcrumbNodes(
|
||||
treeData: SpaceTreeNode[] | null | undefined,
|
||||
ancestors: IPage[] | null | undefined,
|
||||
pageId: string,
|
||||
): SpaceTreeNode[] | null {
|
||||
if (treeData && treeData.length > 0) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) {
|
||||
return breadcrumb;
|
||||
}
|
||||
}
|
||||
|
||||
if (ancestors && ancestors.length > 0) {
|
||||
return ancestors.map((page) =>
|
||||
pageToTreeNode(page, { hasChildren: page.hasChildren ?? false }),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the next breadcrumb state, given the previous one. When a chain
|
||||
* resolves (#218) it always wins. When nothing resolves yet, a stale chain from
|
||||
* a previously-viewed page must be CLEARED rather than left showing the wrong,
|
||||
* clickable trail (the reverse regression of the original blank-breadcrumb fix
|
||||
* when navigating A -> B to a deep page not yet in the lazily-built tree). The
|
||||
* one chain we keep through a transient miss is one that already ends at the
|
||||
* current page — that means we already resolved THIS page, so keeping it avoids
|
||||
* a needless blank flash without ever showing the previous page's chain.
|
||||
*/
|
||||
export function computeBreadcrumbState(
|
||||
treeData: SpaceTreeNode[] | null | undefined,
|
||||
ancestors: IPage[] | null | undefined,
|
||||
pageId: string,
|
||||
previous: SpaceTreeNode[] | null,
|
||||
): SpaceTreeNode[] | null {
|
||||
const resolved = resolveBreadcrumbNodes(treeData, ancestors, pageId);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const previousEndsAtCurrentPage =
|
||||
previous != null && previous[previous.length - 1]?.id === pageId;
|
||||
return previousEndsAtCurrentPage ? previous : null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -24,6 +25,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -160,6 +165,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!page?.temporaryExpiresAt;
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
if (!page?.id) return;
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
||||
// the sidebar tree node so its clock marker updates immediately, no reload.
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -309,6 +337,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleToggleTemporary}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
type TemporaryNoteBannerProps = {
|
||||
slugId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Banner shown on an open temporary note ("structure or die"). Mirrors
|
||||
* DeletedPageBanner: it reads the page from the shared query cache and offers
|
||||
* the explicit rescue action — "Make permanent". Children ride along to trash
|
||||
* with the note, which is noted in the copy.
|
||||
*/
|
||||
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: page.id,
|
||||
temporary: false,
|
||||
});
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">
|
||||
<Trans
|
||||
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
||||
values={{ time: expiresTimeAgo }}
|
||||
/>
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
|
||||
|
||||
// Check if the page already exists in the tree (it shouldn't)
|
||||
if (!treeModel.find(currentTree, restoredPage.id)) {
|
||||
// Create the tree node data with hasChildren from backend
|
||||
const nodeData: SpaceTreeNode = {
|
||||
id: restoredPage.id,
|
||||
slugId: restoredPage.slugId,
|
||||
// Create the tree node data with hasChildren from backend. Routed
|
||||
// through the canonical mapper so the field copy stays in lockstep with
|
||||
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
|
||||
// page is made permanent), so the mapper carries that null through and
|
||||
// the node correctly shows no clock marker.
|
||||
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
|
||||
name: restoredPage.title || "Untitled",
|
||||
icon: restoredPage.icon,
|
||||
position: restoredPage.position,
|
||||
spaceId: restoredPage.spaceId,
|
||||
parentPageId: restoredPage.parentPageId,
|
||||
hasChildren: restoredPage.hasChildren || false,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Determine the parent and index
|
||||
const parentId = restoredPage.parentPageId || null;
|
||||
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
slugId: data.slugId,
|
||||
spaceId: data.spaceId,
|
||||
title: data.title,
|
||||
// Carry the death-timer deadline so a note created as temporary keeps its
|
||||
// sidebar clock marker when the tree is rebuilt from this cached entry
|
||||
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
|
||||
// node's marker with `undefined`, hiding it until a reload.
|
||||
temporaryExpiresAt: data.temporaryExpiresAt,
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -30,9 +31,13 @@ import {
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
||||
import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} from "@/features/page-embed/queries/page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
@@ -65,6 +70,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const isFavorited = favoriteIds.has(node.id);
|
||||
const toggleTemplate = useToggleTemplateMutation();
|
||||
const isTemplate = !!node.isTemplate;
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!node.temporaryExpiresAt;
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
@@ -84,6 +91,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemporary = async () => {
|
||||
const next = !isTemporary;
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
pageId: node.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline locally so the icon/menu update immediately.
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, node.id, {
|
||||
temporaryExpiresAt: res.temporaryExpiresAt,
|
||||
} as any),
|
||||
);
|
||||
notifications.show({
|
||||
message: next
|
||||
? t("Note will move to trash unless made permanent")
|
||||
: t("Note is now permanent"),
|
||||
});
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
||||
@@ -101,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const currentIndex = siblings?.index ?? 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
const treeNodeData: SpaceTreeNode = {
|
||||
id: duplicatedPage.id,
|
||||
slugId: duplicatedPage.slugId,
|
||||
name: duplicatedPage.title,
|
||||
position: duplicatedPage.position,
|
||||
spaceId: duplicatedPage.spaceId,
|
||||
parentPageId: duplicatedPage.parentPageId,
|
||||
icon: duplicatedPage.icon,
|
||||
hasChildren: duplicatedPage.hasChildren,
|
||||
// Routed through the canonical mapper so the field copy stays in lockstep
|
||||
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
||||
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
||||
// carries that null through and the duplicated node correctly shows no
|
||||
// clock marker — matching the server without a reload.
|
||||
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
||||
canEdit: true,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
setData((prev) =>
|
||||
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
||||
@@ -248,6 +274,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleTemporary();
|
||||
}}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{node.temporaryExpiresAt && (
|
||||
<Tooltip
|
||||
// Children ride along to trash with the note (recursive removePage).
|
||||
label={t("Temporary note — moves to trash unless made permanent")}
|
||||
withArrow
|
||||
>
|
||||
<IconClockHour4
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
// Same visual-only indicator pattern as the template icon, but
|
||||
// orange to flag the impending death timer.
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginLeft: rem(4),
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
}}
|
||||
aria-label={t("Temporary note")}
|
||||
role="img"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
||||
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
@@ -22,7 +23,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
handleCreate: (parentId: string | null) => Promise<void>;
|
||||
handleCreate: (
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -119,9 +123,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
|
||||
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
||||
const payload: {
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
temporary?: boolean;
|
||||
} = { spaceId };
|
||||
if (parentId) payload.parentPageId = parentId;
|
||||
// Ask the server to arm the death timer for a "temporary note".
|
||||
if (opts?.temporary) payload.temporary = true;
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
@@ -130,16 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
|
||||
const newNode: SpaceTreeNode = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
// Route through the canonical mapper so the field copy (esp.
|
||||
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
|
||||
// optimistic insert) can't drift from buildTree. `name: ""` because a
|
||||
// freshly created page is untitled; `hasChildren: false` because it has no
|
||||
// children yet.
|
||||
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
|
||||
name: "",
|
||||
position: createdPage.position,
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Read latest tree at call time. Without this, callers that mutate the
|
||||
// tree (e.g. lazy-load children on expand) immediately before calling
|
||||
@@ -162,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||
// the ids match exactly regardless of which path runs first.
|
||||
setData((prev) => {
|
||||
if (treeModel.find(prev, newNode.id)) return prev;
|
||||
const existing = treeModel.find(prev, newNode.id);
|
||||
if (existing) {
|
||||
// The server `addTreeNode` broadcast won the race and already inserted
|
||||
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
||||
// a temporary note WITHOUT its clock marker until reload; patch it on
|
||||
// from the authoritative create response so the marker shows now.
|
||||
if (
|
||||
newNode.temporaryExpiresAt &&
|
||||
!(existing as SpaceTreeNode).temporaryExpiresAt
|
||||
) {
|
||||
return treeModel.update(prev, newNode.id, {
|
||||
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||
});
|
||||
|
||||
|
||||
@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// handleCreate race-guard temporaryExpiresAt patch: when the server's
|
||||
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
|
||||
// updater runs, the updater must not re-insert. Two sub-branches:
|
||||
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
|
||||
// omitted it) while the authoritative create response DOES → patch the
|
||||
// deadline on so the clock marker shows now, without a reload.
|
||||
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
|
||||
// `prev` by reference (a no-op write).
|
||||
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
|
||||
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
|
||||
|
||||
// Mirrors the setData updater in use-tree-mutation handleCreate.
|
||||
const applyOptimisticInsert = (
|
||||
tree: TN[],
|
||||
parentId: string | null,
|
||||
node: TN,
|
||||
index: number,
|
||||
): TN[] => {
|
||||
const existing = treeModel.find(tree, node.id) as TN | null;
|
||||
if (existing) {
|
||||
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
|
||||
return treeModel.update(tree, node.id, {
|
||||
temporaryExpiresAt: node.temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
return treeModel.insert(tree, parentId, node, index);
|
||||
};
|
||||
|
||||
const fixtureTN: TN[] = [
|
||||
{ id: "a", name: "A" },
|
||||
{ id: "b", name: "B" },
|
||||
];
|
||||
|
||||
const deadline = "2026-07-01T00:00:00.000Z";
|
||||
|
||||
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
|
||||
// Server broadcast won the race and inserted the node WITHOUT a deadline.
|
||||
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||
id: "new",
|
||||
name: "",
|
||||
});
|
||||
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// The authoritative create response carries the deadline.
|
||||
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
|
||||
const patched = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
|
||||
// A new reference (the patch wrote) and the node now has the deadline...
|
||||
expect(patched).not.toBe(afterServer);
|
||||
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
|
||||
deadline,
|
||||
);
|
||||
// ...and still exactly one node (no duplicate re-insert).
|
||||
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
|
||||
const existingDeadline = deadline;
|
||||
// The node already exists WITH a deadline (the broadcast carried it).
|
||||
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||
id: "new",
|
||||
name: "",
|
||||
temporaryExpiresAt: existingDeadline,
|
||||
});
|
||||
|
||||
// The create response carries a DIFFERENT deadline; the guard must ignore it.
|
||||
const created: TN = {
|
||||
id: "new",
|
||||
name: "",
|
||||
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
|
||||
};
|
||||
const after = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
|
||||
// prev returned by reference (no write) and the original deadline is kept.
|
||||
expect(after).toBe(afterServer);
|
||||
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
|
||||
existingDeadline,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||
|
||||
@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -9,25 +9,45 @@ export function sortPositionKeys(keys: any[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
|
||||
* materialises a tree node from a page (buildTree, the optimistic insert in
|
||||
* handleCreate, restore, duplicate) routes through here so the field copy —
|
||||
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
|
||||
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
|
||||
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
|
||||
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
|
||||
* so restore (which the server nulls) stays permanent and a temporary create
|
||||
* keeps its clock marker without a reload.
|
||||
*/
|
||||
export function pageToTreeNode(
|
||||
page: IPage,
|
||||
overrides?: Partial<SpaceTreeNode>,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||
|
||||
const tree: SpaceTreeNode[] = [];
|
||||
|
||||
pages.forEach((page) => {
|
||||
pageMap[page.id] = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
children: [],
|
||||
};
|
||||
pageMap[page.id] = pageToTreeNode(page);
|
||||
});
|
||||
|
||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface IPage {
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
// Create-only input flag: ask the server to arm the timer on a new page.
|
||||
temporary?: boolean;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { IShareAlias } from "@/features/share/types/share.types";
|
||||
|
||||
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The mutation + query hooks reach react-query/network; the availability probe
|
||||
// hits the API. Stub them so the section renders in isolation and we can drive
|
||||
// the exact branches (taken name -> hint, 409 -> reassign modal).
|
||||
const setMutateAsync = vi.fn();
|
||||
let currentAlias: IShareAlias | null = null;
|
||||
let availabilityResult: {
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} = { valid: true, available: true, currentPageId: null };
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||
useSetShareAliasMutation: () => ({
|
||||
mutateAsync: setMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useRemoveShareAliasMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/share/services/share-service.ts", () => ({
|
||||
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
|
||||
}));
|
||||
|
||||
import ShareAliasSection from "./share-alias-section";
|
||||
|
||||
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
|
||||
id: `alias-${alias}`,
|
||||
workspaceId: "ws-1",
|
||||
alias,
|
||||
pageId,
|
||||
creatorId: "user-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
function renderSection(pageId = "page-Y") {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ShareAliasSection pageId={pageId} readOnly={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
|
||||
beforeEach(() => {
|
||||
setMutateAsync.mockReset();
|
||||
currentAlias = null;
|
||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||
});
|
||||
|
||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||
// Page Y already owns "bee"; the user retypes a name owned by page X.
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
// The reassign hint replaces the old dead-end red error.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This address is in use. Saving will move it to this page.",
|
||||
),
|
||||
).toBeDefined(),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
// The old terminal "already in use" error must NOT be shown.
|
||||
expect(screen.queryByText("This address is already in use")).toBeNull();
|
||||
|
||||
// Save stays enabled so the confirm-reassign flow can run.
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
// The server rejects the un-confirmed save asking the client to confirm.
|
||||
setMutateAsync.mockRejectedValueOnce({
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
code: "ALIAS_REASSIGN_REQUIRED",
|
||||
currentPageId: "page-X",
|
||||
currentPageTitle: "Alias Test Page X",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
|
||||
timeout: 2000,
|
||||
});
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
// First save sent WITHOUT confirmReassign.
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: false }),
|
||||
),
|
||||
);
|
||||
|
||||
// The "Move custom address?" confirm modal must appear (the path forward).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Move custom address?")).toBeDefined(),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
|
||||
|
||||
// Confirming retries WITH confirmReassign: true.
|
||||
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
useRemoveShareAliasMutation,
|
||||
useSetShareAliasMutation,
|
||||
useShareAliasForPageQuery,
|
||||
} from "@/features/share/queries/share-query.ts";
|
||||
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
interface ShareAliasSectionProps {
|
||||
pageId: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
|
||||
function aliasPrefixLabel(): string {
|
||||
const url = getAppUrl();
|
||||
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
return `${host}/l/`;
|
||||
}
|
||||
|
||||
export default function ShareAliasSection({
|
||||
pageId,
|
||||
readOnly,
|
||||
}: ShareAliasSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
|
||||
const setAliasMutation = useSetShareAliasMutation();
|
||||
const removeAliasMutation = useRemoveShareAliasMutation();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [availability, setAvailability] = useState<{
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} | null>(null);
|
||||
const [reassign, setReassign] = useState<{
|
||||
alias: string;
|
||||
currentPageTitle: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Seed the input from the page's current alias (if any).
|
||||
useEffect(() => {
|
||||
setValue(currentAlias?.alias ?? "");
|
||||
}, [currentAlias?.alias, pageId]);
|
||||
|
||||
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
|
||||
const isValid = isValidShareAlias(normalized);
|
||||
const unchanged = currentAlias?.alias === normalized;
|
||||
|
||||
// Debounced availability probe (skips when invalid or unchanged).
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
setAvailability(null);
|
||||
if (!isValid || unchanged) return;
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkShareAliasAvailability(normalized);
|
||||
setAvailability({
|
||||
valid: res.valid,
|
||||
available: res.available,
|
||||
currentPageId: res.currentPageId,
|
||||
});
|
||||
} catch {
|
||||
setAvailability(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
debounceRef.current && clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [normalized, isValid, unchanged]);
|
||||
|
||||
const prettyLink = currentAlias?.alias
|
||||
? `${getAppUrl()}/l/${currentAlias.alias}`
|
||||
: null;
|
||||
|
||||
const handleSave = async (confirmReassign = false) => {
|
||||
try {
|
||||
await setAliasMutation.mutateAsync({
|
||||
pageId,
|
||||
alias: normalized,
|
||||
confirmReassign,
|
||||
});
|
||||
setReassign(null);
|
||||
} catch (error: any) {
|
||||
// The address already points at another page: prompt to move it here.
|
||||
if (error?.status === 409 || error?.response?.status === 409) {
|
||||
const data = error?.response?.data;
|
||||
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
|
||||
setReassign({
|
||||
alias: normalized,
|
||||
currentPageTitle: data?.currentPageTitle ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!currentAlias?.id) return;
|
||||
await removeAliasMutation.mutateAsync(currentAlias.id);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
|
||||
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
|
||||
// the "Move custom address?" confirm modal that retargets the address here.
|
||||
// So surface it as an informational hint (not a terminal red error) and keep
|
||||
// Save enabled, instead of looking like the address is unusable.
|
||||
const reassignable =
|
||||
isValid && !unchanged && !!availability && !availability.available;
|
||||
|
||||
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||
const prefixLabel = aliasPrefixLabel();
|
||||
const prefixRef = useRef<HTMLDivElement>(null);
|
||||
const [prefixWidth, setPrefixWidth] = useState(0);
|
||||
|
||||
// Measure the real rendered width of the prefix so the slug input sits flush
|
||||
// next to it, instead of after an over-estimated character-counted gap.
|
||||
useLayoutEffect(() => {
|
||||
if (prefixRef.current) {
|
||||
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
|
||||
}
|
||||
}, [prefixLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={6}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
{prettyLink && (
|
||||
<Group my="xs" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={prettyLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={prettyLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={prettyLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
// Show the canonical form once the user pauses so what they type maps
|
||||
// visibly to what gets stored.
|
||||
onBlur={() => setValue(normalized)}
|
||||
leftSection={
|
||||
<Box
|
||||
ref={prefixRef}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
paddingInline: "var(--mantine-spacing-xs)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
borderTopLeftRadius: "var(--input-radius)",
|
||||
borderBottomLeftRadius: "var(--input-radius)",
|
||||
}}
|
||||
>
|
||||
{prefixLabel}
|
||||
</Box>
|
||||
}
|
||||
leftSectionWidth={prefixWidth || undefined}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
reassignable
|
||||
? t("This address is in use. Saving will move it to this page.")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="sm" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
loading={setAliasMutation.isPending}
|
||||
disabled={readOnly || !isValid || unchanged}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{currentAlias?.id && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
loading={removeAliasMutation.isPending}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={!!reassign}
|
||||
onClose={() => setReassign(null)}
|
||||
title={t("Move custom address?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
{reassign?.currentPageTitle
|
||||
? t(
|
||||
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
|
||||
{
|
||||
alias: reassign?.alias,
|
||||
title: reassign?.currentPageTitle,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'The address "{{alias}}" is already in use. Move it to this page?',
|
||||
{ alias: reassign?.alias },
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setReassign(null)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => handleSave(true)}
|
||||
loading={setAliasMutation.isPending}
|
||||
>
|
||||
{t("Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Enabling a public share must NOT silently expose the whole sub-tree (#216):
|
||||
// the create call defaults includeSubPages to false. This was a one-literal,
|
||||
// security-relevant default with no test — lock it.
|
||||
|
||||
const createMutateAsync = vi.fn(async () => ({}));
|
||||
const deleteMutateAsync = vi.fn(async () => ({}));
|
||||
|
||||
// No existing share for this page (toggle starts OFF).
|
||||
let shareData: any = undefined;
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useCreateShareMutation: () => ({ mutateAsync: createMutateAsync }),
|
||||
useDeleteShareMutation: () => ({ mutateAsync: deleteMutateAsync }),
|
||||
useUpdateShareMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useShareForPageQuery: () => ({ data: shareData }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useSpaceQuery: () => ({ data: { settings: {} } }),
|
||||
}));
|
||||
|
||||
import ShareModal from "./share-modal";
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MantineProvider>
|
||||
<ShareModal readOnly={false} />
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ShareModal — enabling a share defaults includeSubPages to false (#216)", () => {
|
||||
beforeEach(() => {
|
||||
createMutateAsync.mockClear();
|
||||
deleteMutateAsync.mockClear();
|
||||
shareData = undefined;
|
||||
});
|
||||
|
||||
it("creates the share with includeSubPages: false when the user turns it on", async () => {
|
||||
renderModal();
|
||||
|
||||
// Open the share popover.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share" }));
|
||||
|
||||
// The "Share to web" toggle is the only switch in the not-yet-shared state.
|
||||
const toggle = await screen.findByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(createMutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(createMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageId: "page-1",
|
||||
includeSubPages: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -72,7 +73,10 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
if (value) {
|
||||
await createShareMutation.mutateAsync({
|
||||
pageId: pageId,
|
||||
includeSubPages: true,
|
||||
// Opt-in: enabling a share must NOT silently expose the whole
|
||||
// sub-tree (#216). Sub-pages are shared only when the user turns on
|
||||
// the dedicated "Include sub-pages" toggle.
|
||||
includeSubPages: false,
|
||||
searchIndexing: false,
|
||||
});
|
||||
} else if (share && share.id) {
|
||||
@@ -253,6 +257,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
{pageId && (
|
||||
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -20,11 +22,14 @@ import {
|
||||
import {
|
||||
createShare,
|
||||
deleteShare,
|
||||
getShareAliasForPage,
|
||||
getSharedPageTree,
|
||||
getShareForPage,
|
||||
getShareInfo,
|
||||
getSharePageInfo,
|
||||
getShares,
|
||||
removeShareAlias,
|
||||
setShareAlias,
|
||||
updateShare,
|
||||
} from "@/features/share/services/share-service.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareAliasForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareAlias | null, Error> {
|
||||
return useQuery({
|
||||
// The endpoint resolves to null when the page has no alias; normalize the
|
||||
// absence so React Query never sees `undefined`.
|
||||
queryKey: ["share-alias-for-page", pageId],
|
||||
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IShareAlias, Error, ISetShareAlias>({
|
||||
mutationFn: (data) => setShareAlias(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
// A 409 reassign-required is handled inline by the modal (it shows the
|
||||
// "move address here?" confirmation), so don't surface a generic toast.
|
||||
if (error?.["status"] === 409) return;
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message || t("Failed to set custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveShareAliasMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (aliasId) => removeShareAlias(aliasId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["share-alias-for-page", "share-list"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message:
|
||||
error?.["response"]?.data?.message ||
|
||||
t("Failed to remove custom address"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSharedPageTreeQuery(
|
||||
shareId: string,
|
||||
): UseQueryResult<ISharedPageTree, Error> {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
ICreateShare,
|
||||
IShare,
|
||||
IShareAlias,
|
||||
IShareAliasAvailability,
|
||||
ISetShareAlias,
|
||||
ISharedItem,
|
||||
ISharedPage,
|
||||
ISharedPageTree,
|
||||
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
|
||||
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareAliasForPage(
|
||||
pageId: string,
|
||||
): Promise<IShareAlias | null> {
|
||||
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
|
||||
pageId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setShareAlias(
|
||||
data: ISetShareAlias,
|
||||
): Promise<IShareAlias> {
|
||||
const req = await api.post<IShareAlias>("/share-aliases/set", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removeShareAlias(aliasId: string): Promise<void> {
|
||||
await api.post("/share-aliases/remove", { aliasId });
|
||||
}
|
||||
|
||||
export async function checkShareAliasAvailability(
|
||||
alias: string,
|
||||
): Promise<IShareAliasAvailability> {
|
||||
const req = await api.post<IShareAliasAvailability>(
|
||||
"/share-aliases/availability",
|
||||
{ alias },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
32
apps/client/src/features/share/share-alias.util.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidShareAlias,
|
||||
normalizeShareAlias,
|
||||
} from "@/features/share/share-alias.util.ts";
|
||||
|
||||
// Mirrors the server-side util so the modal's live feedback matches what the
|
||||
// server will accept/store.
|
||||
describe("normalizeShareAlias", () => {
|
||||
it("lowercases, trims and maps separators to single hyphens", () => {
|
||||
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
|
||||
});
|
||||
|
||||
it("collapses repeated hyphens and trims edges", () => {
|
||||
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidShareAlias", () => {
|
||||
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
|
||||
expect(isValidShareAlias("hello-world")).toBe(true);
|
||||
expect(isValidShareAlias("a".repeat(60))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
|
||||
expect(isValidShareAlias("a")).toBe(false);
|
||||
expect(isValidShareAlias("-a")).toBe(false);
|
||||
expect(isValidShareAlias("a--b")).toBe(false);
|
||||
expect(isValidShareAlias("Hello")).toBe(false);
|
||||
expect(isValidShareAlias("привет")).toBe(false);
|
||||
});
|
||||
});
|
||||
26
apps/client/src/features/share/share-alias.util.ts
Normal file
26
apps/client/src/features/share/share-alias.util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Client copy of the vanity share-alias helpers. Kept in sync with the server
|
||||
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
|
||||
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
|
||||
* separated, length 2..60.
|
||||
*/
|
||||
|
||||
// Normalize a user-provided vanity alias into canonical ASCII storage form.
|
||||
export function normalizeShareAlias(raw: string): string {
|
||||
return (raw ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export function isValidShareAlias(alias: string): boolean {
|
||||
return (
|
||||
typeof alias === "string" &&
|
||||
alias.length >= 2 &&
|
||||
alias.length <= 60 &&
|
||||
ALIAS_RE.test(alias)
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,17 @@ export interface ISharedItem extends IShare {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISharedPage extends IShare {
|
||||
page: IPage;
|
||||
share: IShare & {
|
||||
// The `/shares/page-info` (anonymous) response. Mirrors the server-side
|
||||
// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these
|
||||
// fields exactly, so the client type must not over-declare internal metadata it
|
||||
// will never receive. Keep this in sync with share-public-payload.ts.
|
||||
export interface ISharedPage {
|
||||
page: Pick<IPage, "id" | "slugId" | "title" | "icon" | "content">;
|
||||
share: {
|
||||
id: string;
|
||||
key: string;
|
||||
includeSubPages: boolean;
|
||||
searchIndexing: boolean;
|
||||
level: number;
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
@@ -73,6 +81,34 @@ export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
|
||||
|
||||
export interface IShareInfoInput {
|
||||
pageId: string;
|
||||
// The share id/key from the `/share/:shareId/p/:slug` URL. When present the
|
||||
// server binds content access to this exact share (#218): a forged/mismatched
|
||||
// shareId 404s instead of rendering the page off its slug alone.
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
// Vanity /l/:alias pointer.
|
||||
export interface IShareAlias {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
alias: string;
|
||||
pageId: string | null;
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISetShareAlias {
|
||||
pageId: string;
|
||||
alias: string;
|
||||
confirmReassign?: boolean;
|
||||
}
|
||||
|
||||
export interface IShareAliasAvailability {
|
||||
alias: string;
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
}
|
||||
|
||||
export interface ISharedPageTree {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||
|
||||
// i18n: identity translator — labels stay as their English keys for queries.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||
const mutateAsync = vi.fn();
|
||||
let isPending = false;
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useUpdateSpaceMutation: () => ({
|
||||
mutateAsync,
|
||||
get isPending() {
|
||||
return isPending;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
import { EditSpaceForm } from "./edit-space-form";
|
||||
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||
|
||||
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||
return {
|
||||
id: "space-1",
|
||||
name: "Engineering",
|
||||
description: "",
|
||||
slug: "eng",
|
||||
hostname: "host",
|
||||
creatorId: "u1",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
...overrides,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||
// human-readable label text is present alongside.
|
||||
function getToggle(): HTMLInputElement {
|
||||
screen.getByText("Enable Git sync");
|
||||
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getAutoMergeToggle(): HTMLInputElement {
|
||||
screen.getByText("Auto-merge conflicts on push");
|
||||
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutateAsync.mockReset();
|
||||
isPending = false;
|
||||
});
|
||||
|
||||
describe("EditSpaceForm git-sync toggle", () => {
|
||||
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||
});
|
||||
expect(getToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||
// optimistically flips the switch.
|
||||
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update: the switch reflects the new state immediately.
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// Resolution leaves the toggle on.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
// Test 5: rollback on mutation error — the most valuable test.
|
||||
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||
});
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistically flips on before the rejection lands.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// After the rejected promise settles, the component reverts to OFF so the
|
||||
// user is not misled into believing sync is enabled.
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the toggle while the mutation is pending", () => {
|
||||
isPending = true;
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: true } },
|
||||
}),
|
||||
});
|
||||
expect(getAutoMergeToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getAutoMergeToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Button,
|
||||
TextInput,
|
||||
Stack,
|
||||
Textarea,
|
||||
Divider,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
@@ -29,6 +38,37 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||
space?.settings?.gitSync?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
// One parameterized handler for both git-sync space toggles: they differ only by
|
||||
// the local state setter, the mutation payload field, and the error label. The
|
||||
// update is optimistic and reverts the local state on failure (the mutation
|
||||
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
|
||||
const handleToggle = async (
|
||||
field: "gitSyncEnabled" | "autoMergeConflicts",
|
||||
value: boolean,
|
||||
previous: boolean,
|
||||
setLocal: (next: boolean) => void,
|
||||
errorLabel: string,
|
||||
) => {
|
||||
setLocal(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
[field]: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocal(previous); // revert on failure
|
||||
console.error(errorLabel, err);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -104,6 +144,43 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
</Group>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Switch
|
||||
label={t("Enable Git sync")}
|
||||
description={t("Sync this space's pages to a Git repository.")}
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"gitSyncEnabled",
|
||||
event.currentTarget.checked,
|
||||
gitSyncEnabled,
|
||||
setGitSyncEnabled,
|
||||
"Failed to toggle git-sync for space",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
mt="md"
|
||||
label={t("Auto-merge conflicts on push")}
|
||||
description={t(
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
)}
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"autoMergeConflicts",
|
||||
event.currentTarget.checked,
|
||||
autoMergeConflicts,
|
||||
setAutoMergeConflicts,
|
||||
"Failed to toggle git-sync auto-merge-conflicts",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHourglass,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconStar,
|
||||
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
|
||||
handleCreate(null);
|
||||
}
|
||||
|
||||
function handleCreateTemporaryPage() {
|
||||
handleCreate(null, { temporary: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.navbar}>
|
||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Create page")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Standalone second button: a "temporary note" auto-moves to
|
||||
trash after the workspace lifetime unless made permanent. */}
|
||||
<Tooltip
|
||||
label={t("New temporary note")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreateTemporaryPage}
|
||||
aria-label={t("New temporary note")}
|
||||
>
|
||||
<IconHourglass />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
// Space-overview quick actions: create a regular note or a temporary note
|
||||
// (which auto-moves to Trash after the workspace lifetime) directly in the
|
||||
// current space and open it. Mirrors the sidebar's create buttons but lives on
|
||||
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
|
||||
// page is optimistically inserted into the sidebar tree and navigated to.
|
||||
export default function SpaceCreateNoteButtons() {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
// `handleCreate` is read unconditionally to keep hook order stable; it is
|
||||
// only invoked after the permission guard below confirms a loaded space.
|
||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||
// Which create action is in flight: drives the per-button spinner and the
|
||||
// shared disabled state so a slow create round-trip cannot be double-fired.
|
||||
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
|
||||
|
||||
// Render nothing until the space loads, or when the user cannot manage pages.
|
||||
if (!space) return null;
|
||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createNote = (temporary: boolean) => {
|
||||
if (pending) return;
|
||||
setPending(temporary ? "temporary" : "regular");
|
||||
// handleCreate creates the page then navigates away (unmounting this
|
||||
// component); the create mutation already shows a red notification on
|
||||
// failure, so swallow the rejection and just clear the pending flag.
|
||||
handleCreate(null, temporary ? { temporary: true } : undefined)
|
||||
.catch(() => {})
|
||||
.finally(() => setPending(null));
|
||||
};
|
||||
|
||||
// Two full-width, vertically stacked buttons: a neutral regular note and an
|
||||
// orange-tinted temporary note. Stacking full-width keeps the longer "New
|
||||
// temporary note" label from clipping on narrow mobile widths.
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
fullWidth
|
||||
leftSection={<IconPlus size={18} />}
|
||||
loading={pending === "regular"}
|
||||
disabled={pending !== null}
|
||||
onClick={() => createNote(false)}
|
||||
>
|
||||
{t("New note")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="orange"
|
||||
fullWidth
|
||||
leftSection={<IconHourglass size={18} />}
|
||||
loading={pending === "temporary"}
|
||||
disabled={pending !== null}
|
||||
onClick={() => createNote(true)}
|
||||
>
|
||||
{t("New temporary note")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user