Compare commits
133 Commits
docs/manua
...
fix/ai-cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb61274187 | ||
|
|
eafd15f0ef | ||
|
|
63c26042ba | ||
|
|
2f058a6e40 | ||
|
|
3ddc329bba | ||
|
|
ed3b65c36b | ||
|
|
de115ade1e | ||
|
|
364838d0b2 | ||
|
|
aa7a115f66 | ||
|
|
30c358a2f8 | ||
|
|
ea61c96a7c | ||
|
|
f80276d41a | ||
|
|
8218c1a8ef | ||
|
|
d7e7489654 | ||
|
|
8f1af676ba | ||
|
|
34c5b557ef | ||
|
|
59f0c8b22d | ||
|
|
77ccc596ea | ||
|
|
e536c6f9a9 | ||
|
|
fdaf20ca7b | ||
|
|
47a2ae420b | ||
|
|
1cfad1f6fb | ||
|
|
a766672574 | ||
|
|
5e8cb628f0 | ||
|
|
8413185a1d | ||
|
|
8fee6a86c2 | ||
|
|
ae6faf3abc | ||
|
|
e7b719bbb8 | ||
|
|
27c91e4a69 | ||
|
|
c3596dce68 | ||
|
|
b6787cc542 | ||
| 176b0f575f | |||
|
|
df81851eb3 | ||
|
|
4597183a1e | ||
|
|
99d0cb8773 | ||
|
|
5aa199660d | ||
|
|
bf2ebb9d47 | ||
|
|
ad90e2290e | ||
| e262f1695c | |||
|
|
c065e26d14 | ||
|
|
91e7335d54 | ||
|
|
b0faa2fe32 | ||
|
|
d1fbcc1bfa | ||
|
|
6edbbab43b | ||
|
|
59190148db | ||
| 80a4b5a1b0 | |||
|
|
da15b55786 | ||
|
|
a14560c7c9 | ||
|
|
4cc8df836f | ||
|
|
04a418e1a6 | ||
|
|
255bc06883 | ||
| 8c06553b49 | |||
|
|
0e8af13122 | ||
|
|
b9056e2bee | ||
|
|
a0cc625dfe | ||
|
|
17e683a311 | ||
|
|
13cac155c1 | ||
|
|
6566d2153c | ||
|
|
aca075108c | ||
|
|
c9b012894b | ||
|
|
623c89554a | ||
|
|
f7b99f9fb3 | ||
| e97024343a | |||
|
|
43cf1913e0 | ||
| 9225eeeeed | |||
|
|
044e3f7e6a | ||
|
|
c7c0c28e38 | ||
|
|
99359fa0fa | ||
|
|
7325eeac19 | ||
| b321bbafc4 | |||
|
|
5519f4b23b | ||
|
|
0ebb1adce8 | ||
|
|
8ef66ba712 | ||
|
|
0ec0af405a | ||
|
|
f86b8b69a0 | ||
|
|
3662d21c99 | ||
|
|
acf6d85b07 | ||
| 8a6ee78c44 | |||
|
|
554da63b0e | ||
|
|
8bb441870a | ||
| f11c8d7bf1 | |||
|
|
cbaa120037 | ||
|
|
683b9d5de2 | ||
|
|
67057de214 | ||
|
|
1c39a45bc5 | ||
|
|
7705d44fc6 | ||
|
|
1d54f8ed1c | ||
|
|
0647faefcd | ||
|
|
38544e2ddc | ||
|
|
aeea315618 | ||
|
|
4704c3b7f9 | ||
|
|
5161de8ba9 | ||
|
|
989f99abae | ||
| 0fabaa5bfb | |||
| 6efb865625 | |||
|
|
7c48bab1f2 | ||
|
|
d6cd275469 | ||
|
|
35fc58eaaa | ||
|
|
7884dc2e1a | ||
| 97002f318a | |||
|
|
870df458ed | ||
|
|
8f9a218c68 | ||
|
|
b7abb7ea01 | ||
|
|
fd66ee6cce | ||
|
|
a8d1caf039 | ||
|
|
6946ee4415 | ||
|
|
f59ca3cb0d | ||
|
|
1efc016cd9 | ||
|
|
10fe605159 | ||
|
|
7c308728de | ||
|
|
da058bb6a0 | ||
|
|
ba90147749 | ||
|
|
93c1e6e3e4 | ||
|
|
d21e1430b0 | ||
|
|
580f3442b8 | ||
|
|
1b4de2b420 | ||
|
|
544355a3c8 | ||
|
|
44a1b5b003 | ||
|
|
0edc5aeda8 | ||
|
|
1858a5800d | ||
|
|
ee25d52965 | ||
|
|
2d7f85fccb | ||
| 86bb2742c7 | |||
|
|
8915a875a2 | ||
|
|
ebc3b01dc2 | ||
|
|
9e1d057878 | ||
|
|
c53ce35312 | ||
|
|
fc262636ab | ||
|
|
aebdb6c00c | ||
|
|
1af5d34ae3 | ||
|
|
83c61641c9 | ||
|
|
7ddd0cba05 | ||
|
|
11d5a75c79 |
34
.env.example
34
.env.example
@@ -123,11 +123,45 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# expose the port publicly).
|
# expose the port publicly).
|
||||||
# MCP_TOKEN=
|
# MCP_TOKEN=
|
||||||
# MCP_SESSION_IDLE_MS=1800000
|
# MCP_SESSION_IDLE_MS=1800000
|
||||||
|
#
|
||||||
|
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
|
||||||
|
# attribution is driven by a per-user `is_agent` flag on the users row. There is
|
||||||
|
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
|
||||||
|
# account for the MCP fallback above and flag ONLY that account, e.g.:
|
||||||
|
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
|
||||||
|
# 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.
|
||||||
|
|
||||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||||
|
|
||||||
|
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
|
||||||
|
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
|
||||||
|
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
|
||||||
|
# provider is eventually broken instead of leaking forever. Default 900000 (15 min).
|
||||||
|
# AI_STREAM_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
||||||
|
# A pooled connection idle longer than this is closed instead of reused, so a
|
||||||
|
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||||
|
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
||||||
|
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
||||||
|
# AI_STREAM_KEEPALIVE_MS=10000
|
||||||
|
|
||||||
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||||
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||||
|
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||||
|
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||||
|
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
|
||||||
|
# AI_MCP_STREAM_TIMEOUT_MS=300000
|
||||||
|
|
||||||
|
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||||
|
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||||
|
# but never returns a result — which the silence timeout above never breaks.
|
||||||
|
# Default 900000 (15 min).
|
||||||
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
|
|||||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -15,6 +15,38 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
|
||||||
|
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
|
||||||
|
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
|
||||||
|
# only ran locally, so regressions in those paths stayed green in CI.
|
||||||
|
# Postgres uses the pgvector image because migrations create vector columns
|
||||||
|
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
|
||||||
|
# defaults in apps/server/test/integration/db.ts + global-setup.ts
|
||||||
|
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
|
||||||
|
# TEST_*_URL overrides are needed.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost_dev_pw
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -36,5 +68,12 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
# Integration suite against the real Postgres/Redis services above. Runs
|
||||||
|
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
|
||||||
|
# unit run (mocks only) cannot cover. global-setup drops/recreates the
|
||||||
|
# isolated `docmost_test` DB and migrates it to latest.
|
||||||
|
- name: Run server integration tests
|
||||||
|
run: pnpm --filter server test:int
|
||||||
|
|||||||
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// VSCode tasks for this repo.
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "git push (github + gitea)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "git push github develop && git push gitea develop",
|
||||||
|
"options": { "cwd": "${workspaceFolder}" },
|
||||||
|
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
AGENTS.md
15
AGENTS.md
@@ -157,6 +157,19 @@ below.
|
|||||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||||
| `upstream` | The original Docmost — **never push** |
|
| `upstream` | The original Docmost — **never push** |
|
||||||
|
|
||||||
|
## Creating issues (Gitea `tea` CLI)
|
||||||
|
|
||||||
|
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||||
|
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||||
|
--title '<title>' --description "$(cat body.md)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||||
|
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture and codebase
|
# Architecture and codebase
|
||||||
@@ -210,7 +223,7 @@ pnpm --filter @docmost/mcp test # node --test (unit + mock)
|
|||||||
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
|
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
|
||||||
```
|
```
|
||||||
|
|
||||||
**Database migrations** (Kysely, run from `apps/server`; they auto-run on server startup too):
|
**Database migrations** (Kysely, run from `apps/server`). **Where they auto-apply:** in **production** (the built image / `start:prod`) pending migrations run automatically on server boot. In **local dev** (the `pnpm dev` stand / `nest start --watch`) they do **NOT** auto-run — after you pull or switch branches you must apply them yourself with `pnpm --filter server migration:latest`, or any endpoint touching a new column/table 500s (e.g. a freshly-added `ai_chats.page_id` blanket-500s all of AI chat until migrated).
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server migration:create --name=my_change # new empty migration
|
pnpm --filter server migration:create --name=my_change # new empty migration
|
||||||
pnpm --filter server migration:latest # apply all pending
|
pnpm --filter server migration:latest # apply all pending
|
||||||
|
|||||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -10,8 +10,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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
|
||||||
|
finalized once to `completed`/`error`/`aborted`. A process that dies mid-turn
|
||||||
|
keeps every finished step, and a startup sweep flips any dangling `streaming`
|
||||||
|
row (untouched for 10 minutes) to `aborted`. Chat "Copy" now exports
|
||||||
|
server-side from these rows (`POST /ai-chat/export`) rather than from live
|
||||||
|
client state, so the export is identical whether a chat is freshly streaming,
|
||||||
|
just switched to, or reloaded — and is available from the first turn of a new
|
||||||
|
chat. (#183, #174)
|
||||||
|
|
||||||
|
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
|
||||||
|
the MCP endpoint by a dedicated agent account are now badged as "AI", with
|
||||||
|
unspoofable provenance derived from a per-user `is_agent` flag (not from the
|
||||||
|
request body). **Operator setup:** use a _dedicated_ service account for the
|
||||||
|
MCP fallback and set the flag with SQL —
|
||||||
|
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
|
||||||
|
human or shared account, or its normal edits get mis-attributed as AI. See the
|
||||||
|
AI-agent block in `.env.example`. (#143)
|
||||||
|
- **Footnote import diagnostics.** The MCP page-write tools (`create_page`,
|
||||||
|
`update_page`, `import_page_markdown`) now return a `footnoteWarnings` array
|
||||||
|
flagging dangling references, empty or duplicate definitions, and `[^id]`
|
||||||
|
markers inside table rows, so an agent can fix its own markup. The page is
|
||||||
|
still created; the field is omitted when there are no problems. (#166)
|
||||||
|
- **AI chat "Protocol" setting (`chatApiStyle`).** A new admin choice in AI
|
||||||
|
settings for the `openai` driver: `openai-compatible` (default) routes chat
|
||||||
|
through `@ai-sdk/openai-compatible`, which surfaces a provider's streamed
|
||||||
|
reasoning (`reasoning_content` → reasoning parts) for z.ai/GLM, DeepSeek,
|
||||||
|
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
||||||
|
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
||||||
|
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
||||||
|
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
||||||
|
now has an admin-authored `instructions` field ("how/when to use this server's
|
||||||
|
tools") that is injected into the agent's system prompt next to that server's
|
||||||
|
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
|
||||||
|
shown only for a server that actually connected and contributed ≥1 callable
|
||||||
|
tool. (#180)
|
||||||
|
- **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)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
||||||
|
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
||||||
|
|
||||||
|
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
||||||
|
same id are ONE footnote — one number, one definition, several back-references
|
||||||
|
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
||||||
|
first-wins on import (the rest are dropped and reported via `footnoteWarnings`),
|
||||||
|
and a reference with no definition yields a single empty footnote rather than
|
||||||
|
one per occurrence. This supersedes the 0.93.0 "survive duplicate-id
|
||||||
|
definitions" behavior for the import path. (#166)
|
||||||
|
|
||||||
- **Public share AI: default per-workspace hourly assistant cap lowered
|
- **Public share AI: default per-workspace hourly assistant cap lowered
|
||||||
300 → 100.** The limiter falls back to this default whenever
|
300 → 100.** The limiter falls back to this default whenever
|
||||||
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
|
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
|
||||||
@@ -19,6 +76,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
|
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
|
||||||
keep the previous limit. (#62)
|
keep the previous limit. (#62)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
|
||||||
|
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
|
||||||
|
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
|
||||||
|
per-turn work grew quadratically and saturated the main thread. The stream is now
|
||||||
|
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
|
||||||
|
markdown part / reasoning block is memoized, so a long turn no longer re-parses
|
||||||
|
already-finished content. (#182)
|
||||||
|
- **Editor: caret/selection landed on the wrong line when clicking inside code
|
||||||
|
blocks and footnotes.** The affected NodeViews rendered their non-editable
|
||||||
|
chrome (language menu, footnotes heading, footnote number marker) before the
|
||||||
|
editable content, so the browser's click hit-testing missed the contentDOM and
|
||||||
|
snapped the caret to a previous node. Content now renders first in the DOM
|
||||||
|
(chrome is lifted back into place via CSS flex `order`), and scroll containers
|
||||||
|
are nudged after a paste to refresh stale hit-testing geometry. The caret
|
||||||
|
symptom is macOS-specific and was confirmed manually on macOS; the automated
|
||||||
|
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
|
||||||
|
- **AI chat: the live token counter now ticks between agent steps.** During a
|
||||||
|
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
|
||||||
|
no longer froze on the previous step's authoritative usage; the current step's
|
||||||
|
estimate is combined per-component with `max`, so the count rises smoothly and
|
||||||
|
never jumps backwards. (#163)
|
||||||
|
|
||||||
## [0.93.0] - 2026-06-21
|
## [0.93.0] - 2026-06-21
|
||||||
|
|
||||||
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
|
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
|
||||||
@@ -101,8 +182,7 @@ embeds — plus a large batch of security hardening and test coverage.
|
|||||||
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
||||||
in-flight page-embed id, and add defense-in-depth workspace checks.
|
in-flight page-embed id, and add defense-in-depth workspace checks.
|
||||||
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
||||||
- Import: surface the real error cause from `/pages/import` instead of a generic
|
- Import: surface the real error cause from `/pages/import` instead of a generic 400.
|
||||||
400.
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,7 @@
|
|||||||
"Copy to space": "Copy to space",
|
"Copy to space": "Copy to space",
|
||||||
"Copy chat": "Copy chat",
|
"Copy chat": "Copy chat",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
|
"Failed to export chat": "Failed to export chat",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
@@ -420,6 +421,8 @@
|
|||||||
"{{count}} command available_other": "{{count}} commands available",
|
"{{count}} command available_other": "{{count}} commands available",
|
||||||
"{{count}} result available_one": "1 result available",
|
"{{count}} result available_one": "1 result available",
|
||||||
"{{count}} result available_other": "{{count}} results available",
|
"{{count}} result available_other": "{{count}} results available",
|
||||||
|
"{{count}} result found_one": "{{count}} result found",
|
||||||
|
"{{count}} result found_other": "{{count}} results found",
|
||||||
"Equal columns": "Equal columns",
|
"Equal columns": "Equal columns",
|
||||||
"Left sidebar": "Left sidebar",
|
"Left sidebar": "Left sidebar",
|
||||||
"Right sidebar": "Right sidebar",
|
"Right sidebar": "Right sidebar",
|
||||||
@@ -708,6 +711,7 @@
|
|||||||
"Authorization header": "Authorization header",
|
"Authorization header": "Authorization header",
|
||||||
"Tool allowlist": "Tool allowlist",
|
"Tool allowlist": "Tool allowlist",
|
||||||
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
|
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
|
||||||
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
|
||||||
"Test": "Test",
|
"Test": "Test",
|
||||||
"Available tools": "Available tools",
|
"Available tools": "Available tools",
|
||||||
"No tools available": "No tools available",
|
"No tools available": "No tools available",
|
||||||
@@ -1075,6 +1079,8 @@
|
|||||||
"Undo": "Undo",
|
"Undo": "Undo",
|
||||||
"Redo": "Redo",
|
"Redo": "Redo",
|
||||||
"Backlinks": "Backlinks",
|
"Backlinks": "Backlinks",
|
||||||
|
"Back to references": "Back to references",
|
||||||
|
"Back to reference {{label}}": "Back to reference {{label}}",
|
||||||
"Last updated by": "Last updated by",
|
"Last updated by": "Last updated by",
|
||||||
"Last updated": "Last updated",
|
"Last updated": "Last updated",
|
||||||
"Stats": "Stats",
|
"Stats": "Stats",
|
||||||
@@ -1127,15 +1133,32 @@
|
|||||||
"Removed from favorites": "Removed from favorites",
|
"Removed from favorites": "Removed from favorites",
|
||||||
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
||||||
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
||||||
|
"Label added": "Label added",
|
||||||
|
"Label removed": "Label removed",
|
||||||
|
"Image updated": "Image updated",
|
||||||
|
"Unsupported image type": "Unsupported image type",
|
||||||
|
"Member deactivated": "Member deactivated",
|
||||||
|
"Member activated": "Member activated",
|
||||||
|
"Name is required": "Name is required",
|
||||||
|
"Name must be 40 characters or fewer": "Name must be 40 characters or fewer",
|
||||||
|
"Group name must be at least 2 characters": "Group name must be at least 2 characters",
|
||||||
|
"Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer",
|
||||||
|
"Description must be 500 characters or fewer": "Description must be 500 characters or fewer",
|
||||||
|
"Invalid invitation link": "Invalid invitation link",
|
||||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||||
"AI chat": "AI chat",
|
"AI chat": "AI chat",
|
||||||
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
||||||
"Ask a question…": "Ask a question…",
|
"Ask a question…": "Ask a question…",
|
||||||
"Thinking…": "Thinking…",
|
"Thinking…": "Thinking…",
|
||||||
|
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
|
||||||
|
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
|
||||||
|
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
|
||||||
|
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
|
||||||
|
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
|
||||||
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
||||||
"Public share assistant": "Public share assistant",
|
"Public share assistant": "Public share assistant",
|
||||||
"Enabled": "Enabled",
|
|
||||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||||
"Public assistant model": "Public assistant model",
|
"Public assistant model": "Public assistant model",
|
||||||
"Defaults to the chat model": "Defaults to the chat model",
|
"Defaults to the chat model": "Defaults to the chat model",
|
||||||
@@ -1145,6 +1168,7 @@
|
|||||||
"Built-in assistant persona": "Built-in assistant persona",
|
"Built-in assistant persona": "Built-in assistant persona",
|
||||||
"Minimize": "Minimize",
|
"Minimize": "Minimize",
|
||||||
"Current context size": "Current context size",
|
"Current context size": "Current context size",
|
||||||
|
"Tokens generated this turn": "Tokens generated this turn",
|
||||||
"AI agent": "AI agent",
|
"AI agent": "AI agent",
|
||||||
"Take a look at the current document": "Take a look at the current document",
|
"Take a look at the current document": "Take a look at the current document",
|
||||||
"AI agent is typing…": "AI agent is typing…",
|
"AI agent is typing…": "AI agent is typing…",
|
||||||
@@ -1154,6 +1178,9 @@
|
|||||||
"Queue message": "Queue message",
|
"Queue message": "Queue message",
|
||||||
"Remove queued message": "Remove queued message",
|
"Remove queued message": "Remove queued message",
|
||||||
"Stop": "Stop",
|
"Stop": "Stop",
|
||||||
|
"Response stopped.": "Response stopped.",
|
||||||
|
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
|
||||||
|
"Response stopped (manually or the connection dropped).": "Response stopped (manually or the connection dropped).",
|
||||||
"Chat menu": "Chat menu",
|
"Chat menu": "Chat menu",
|
||||||
"No chats yet.": "No chats yet.",
|
"No chats yet.": "No chats yet.",
|
||||||
"Delete this chat?": "Delete this chat?",
|
"Delete this chat?": "Delete this chat?",
|
||||||
@@ -1185,8 +1212,11 @@
|
|||||||
"Semantic search": "Semantic search",
|
"Semantic search": "Semantic search",
|
||||||
"Voice / STT": "Voice / STT",
|
"Voice / STT": "Voice / STT",
|
||||||
"Voice dictation": "Voice dictation",
|
"Voice dictation": "Voice dictation",
|
||||||
|
"Streaming dictation": "Streaming dictation",
|
||||||
|
"Transcribe as you speak, cutting on pauses": "Transcribe as you speak, cutting on pauses",
|
||||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||||
"Test endpoint": "Test endpoint",
|
"Test endpoint": "Test endpoint",
|
||||||
|
"Save and test": "Save and test",
|
||||||
"Save endpoints": "Save endpoints",
|
"Save endpoints": "Save endpoints",
|
||||||
"Configured and enabled": "Configured and enabled",
|
"Configured and enabled": "Configured and enabled",
|
||||||
"Configured but disabled": "Configured but disabled",
|
"Configured but disabled": "Configured but disabled",
|
||||||
@@ -1219,6 +1249,8 @@
|
|||||||
"No microphone found": "No microphone found",
|
"No microphone found": "No microphone found",
|
||||||
"Could not start recording": "Could not start recording",
|
"Could not start recording": "Could not start recording",
|
||||||
"Transcription failed": "Transcription failed",
|
"Transcription failed": "Transcription failed",
|
||||||
|
"Transcribe": "Transcribe",
|
||||||
|
"No speech detected": "No speech detected",
|
||||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||||
@@ -1245,6 +1277,10 @@
|
|||||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||||
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||||
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||||
|
"Start automatically": "Start automatically",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
|
||||||
|
"Launch message": "Launch message",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
|
||||||
"Agent roles": "Agent roles",
|
"Agent roles": "Agent roles",
|
||||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||||
"No roles configured": "No roles configured",
|
"No roles configured": "No roles configured",
|
||||||
@@ -1264,5 +1300,20 @@
|
|||||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||||
"Analytics / tracker": "Analytics / tracker",
|
"Analytics / tracker": "Analytics / tracker",
|
||||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
|
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||||
|
"Go to login page": "Go to login page",
|
||||||
|
"Move to space": "Move to space",
|
||||||
|
"Float left (wrap text)": "Float left (wrap text)",
|
||||||
|
"Float right (wrap text)": "Float right (wrap text)",
|
||||||
|
"Switch to tree": "Switch to tree",
|
||||||
|
"Switch to flat list": "Switch to flat list",
|
||||||
|
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||||
|
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
|
||||||
|
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
|
||||||
|
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
|
||||||
|
"Showing {{count}} subpages_other": "Showing {{count}} subpages",
|
||||||
|
"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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,7 @@
|
|||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
"Select a user": "Выберите пользователя",
|
"Select a user": "Выберите пользователя",
|
||||||
"Select a group": "Выберите группу",
|
"Select a group": "Выберите группу",
|
||||||
@@ -385,6 +386,11 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -400,6 +406,8 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
"Empty footnote": "Пустая сноска",
|
"Empty footnote": "Пустая сноска",
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
@@ -672,9 +680,21 @@
|
|||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI agent": "AI-агент",
|
"AI agent": "AI-агент",
|
||||||
"Take a look at the current document": "Посмотри текущий документ",
|
"Take a look at the current document": "Посмотри текущий документ",
|
||||||
|
"Start automatically": "Запускать автоматически",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||||
|
"Launch message": "Стартовое сообщение",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||||
"AI agent is typing…": "AI-агент печатает…",
|
"AI agent is typing…": "AI-агент печатает…",
|
||||||
"{{name}} is typing…": "{{name}} печатает…",
|
"{{name}} is typing…": "{{name}} печатает…",
|
||||||
"Thinking…": "Думаю…",
|
"Thinking…": "Думаю…",
|
||||||
|
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||||
|
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||||
|
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||||
|
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||||
|
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||||
"Agent role": "Роль агента",
|
"Agent role": "Роль агента",
|
||||||
"AI chat": "AI-чат",
|
"AI chat": "AI-чат",
|
||||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||||
@@ -685,6 +705,7 @@
|
|||||||
"Copy chat": "Копировать чат",
|
"Copy chat": "Копировать чат",
|
||||||
"Created successfully": "Успешно создано",
|
"Created successfully": "Успешно создано",
|
||||||
"Current context size": "Текущий размер контекста",
|
"Current context size": "Текущий размер контекста",
|
||||||
|
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
"Deleted successfully": "Успешно удалено",
|
"Deleted successfully": "Успешно удалено",
|
||||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||||
@@ -731,6 +752,8 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||||
|
"Instructions": "Инструкции",
|
||||||
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -1132,5 +1155,19 @@
|
|||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
|
"Switch to tree": "Переключить на дерево",
|
||||||
|
"Switch to flat list": "Переключить на плоский список",
|
||||||
|
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||||
|
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
|
||||||
|
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
|
||||||
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||||
|
"Protocol": "Протокол",
|
||||||
|
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||||
|
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||||
|
"OpenAI (official)": "OpenAI (официальный)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export default function AvatarUploader({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate file type. The `accept` attribute only filters the dialog;
|
||||||
|
// a user can still select a non-image file, which previously failed
|
||||||
|
// silently. Surface a visible error instead (issue #133). Accept any
|
||||||
|
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
|
||||||
|
// what the server accepts; only genuinely non-image files are rejected.
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Unsupported image type"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
// Reset the input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file size (max 10MB)
|
// Validate file size (max 10MB)
|
||||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||||
if (file.size > maxSizeInBytes) {
|
if (file.size > maxSizeInBytes) {
|
||||||
@@ -58,6 +75,8 @@ export default function AvatarUploader({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onUpload(file);
|
await onUpload(file);
|
||||||
|
// Notify on success so the upload gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Image updated") });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -117,7 +136,7 @@ export default function AvatarUploader({
|
|||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
accept="image/*"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(page?.space.name)}
|
color={getInitialsColor(page?.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
tt="none"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={rem(size)}
|
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||||
height={rem(size)}
|
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
|
||||||
|
// it via CSS style instead (matching the other icon components).
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={rem(size)}
|
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||||
height={rem(size)}
|
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
|
||||||
|
// it via CSS style instead (matching the other icon components).
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
import { IconMessage } from "@tabler/icons-react";
|
||||||
import classes from "./app-header.module.css";
|
import classes from "./app-header.module.css";
|
||||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
@@ -107,7 +107,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("AI chat")}
|
aria-label={t("AI chat")}
|
||||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<IconSparkles size={20} />
|
<IconMessage size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
|
|||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
||||||
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
@@ -157,6 +158,10 @@ export default function GlobalAppShell({
|
|||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
and self-hides when closed, so its place in the tree is not critical. */}
|
||||||
<AiChatWindow />
|
<AiChatWindow />
|
||||||
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
<GitmostGlobalBridge />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import { AiAgentBadge } from "./ai-agent-badge";
|
||||||
|
import {
|
||||||
|
activeAiChatIdAtom,
|
||||||
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatDraftAtom,
|
||||||
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<AiAgentBadge {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||||
|
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||||
|
// assertable. Returns the store and spies.
|
||||||
|
function setupClickable() {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||||
|
const onActivate = vi.fn();
|
||||||
|
const onParentClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<MantineProvider>
|
||||||
|
<div onClick={onParentClick}>
|
||||||
|
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||||
|
</div>
|
||||||
|
</MantineProvider>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||||
|
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||||
|
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||||
|
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||||
|
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AiAgentBadge", () => {
|
||||||
|
it("renders the AI-agent label", () => {
|
||||||
|
renderBadge({ authorName: "Bot" });
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||||
|
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||||
|
const badge = screen.getByRole("button");
|
||||||
|
expect(badge).toBeDefined();
|
||||||
|
expect(badge.textContent).toContain("AI-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||||
|
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||||
|
fireEvent.click(badge);
|
||||||
|
expectDeepLinked(store, onActivate);
|
||||||
|
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Enter", " "])(
|
||||||
|
"keyboard %j activates the deep-link (same side effects as click)",
|
||||||
|
(key) => {
|
||||||
|
const { store, onActivate, badge } = setupClickable();
|
||||||
|
fireEvent.keyDown(badge, { key });
|
||||||
|
expectDeepLinked(store, onActivate);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("an unrelated key does NOT activate the badge", () => {
|
||||||
|
const { store, onActivate, badge } = setupClickable();
|
||||||
|
fireEvent.keyDown(badge, { key: "Tab" });
|
||||||
|
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||||
|
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||||
|
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||||
|
expect(onActivate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ aiChatId: null }, {}])(
|
||||||
|
"is a plain non-clickable label without a chat target (%o)",
|
||||||
|
(props) => {
|
||||||
|
renderBadge({ authorName: "Bot", ...props });
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
// No interactive role is exposed when there is no chat to deep-link into.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Badge, Tooltip } from "@mantine/core";
|
||||||
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
activeAiChatIdAtom,
|
||||||
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatDraftAtom,
|
||||||
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
|
|
||||||
|
interface AiAgentBadgeProps {
|
||||||
|
authorName?: string;
|
||||||
|
aiChatId?: string | null;
|
||||||
|
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||||
|
// context (e.g. the page-history row closes the history modal) so this generic
|
||||||
|
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||||
|
onActivate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||||
|
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||||
|
* page-history list and the comments sidebar.
|
||||||
|
*
|
||||||
|
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||||
|
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||||
|
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||||
|
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||||
|
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||||
|
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||||
|
* also trigger an enclosing row's click handler.
|
||||||
|
*/
|
||||||
|
export function AiAgentBadge({
|
||||||
|
authorName,
|
||||||
|
aiChatId,
|
||||||
|
onActivate,
|
||||||
|
}: AiAgentBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||||
|
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||||
|
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||||
|
|
||||||
|
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||||
|
name: authorName ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const openChat = useCallback(
|
||||||
|
(event: React.SyntheticEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!aiChatId) return;
|
||||||
|
setActiveChatId(aiChatId);
|
||||||
|
// Switching to another chat must start with a clean composer — clear any
|
||||||
|
// unsent draft so it does not leak from the previously open chat.
|
||||||
|
setDraft("");
|
||||||
|
setAiChatWindowOpen(true);
|
||||||
|
onActivate?.();
|
||||||
|
},
|
||||||
|
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="violet"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||||
|
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||||
|
{...(aiChatId
|
||||||
|
? {
|
||||||
|
// Keep the default Badge root element (not a <button>) to avoid an
|
||||||
|
// invalid <button>-in-<button> nesting inside a row's
|
||||||
|
// UnstyledButton; expose it as an accessible button via
|
||||||
|
// role/keyboard.
|
||||||
|
role: "button",
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: openChat,
|
||||||
|
onKeyDown: (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openChat(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{t("AI-agent")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={tooltip} withArrow>
|
||||||
|
{badge}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AiAgentBadge;
|
||||||
@@ -1,4 +1,22 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted floating AI chat window geometry (position + size). Held in
|
||||||
|
* localStorage so a drag/resize survives a full page reload. `null` means
|
||||||
|
* "never placed yet" — the window then computes an initial top-right placement.
|
||||||
|
* On restore the value is clamped to the current viewport (see AiChatWindow).
|
||||||
|
*/
|
||||||
|
export type AiChatWindowGeom = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
|
||||||
|
"ai-chat-window-geom",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { generateId } from "ai";
|
|
||||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
@@ -25,6 +24,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatWindowGeomAtom,
|
||||||
aiChatDraftAtom,
|
aiChatDraftAtom,
|
||||||
selectedAiRoleIdAtom,
|
selectedAiRoleIdAtom,
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
@@ -39,7 +39,8 @@ import {
|
|||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
isHeaderClick,
|
isHeaderClick,
|
||||||
@@ -78,17 +79,31 @@ function computeInitialGeom() {
|
|||||||
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
||||||
);
|
);
|
||||||
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
|
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
|
||||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
|
const maxTop = Math.max(
|
||||||
|
EDGE_MARGIN,
|
||||||
|
window.innerHeight - height - EDGE_MARGIN,
|
||||||
|
);
|
||||||
const top = Math.min(60, maxTop);
|
const top = Math.min(60, maxTop);
|
||||||
return { left, top, width, height };
|
return { left, top, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp a geometry so the window stays within the current viewport.
|
// Clamp a geometry so the window stays within the current viewport.
|
||||||
function clampGeom(g: { left: number; top: number; width: number; height: number }) {
|
function clampGeom(g: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) {
|
||||||
const effWidth = Math.max(g.width, MIN_WIDTH);
|
const effWidth = Math.max(g.width, MIN_WIDTH);
|
||||||
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
||||||
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN);
|
const maxLeft = Math.max(
|
||||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN);
|
EDGE_MARGIN,
|
||||||
|
window.innerWidth - effWidth - EDGE_MARGIN,
|
||||||
|
);
|
||||||
|
const maxTop = Math.max(
|
||||||
|
EDGE_MARGIN,
|
||||||
|
window.innerHeight - effHeight - EDGE_MARGIN,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...g,
|
...g,
|
||||||
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
||||||
@@ -99,12 +114,13 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
|
|||||||
/**
|
/**
|
||||||
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
||||||
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
||||||
* chat, new chat, adopt-new-chat, open-page context, token sum) and wraps the
|
* chat, new chat, in-place id adoption from streamed metadata, open-page
|
||||||
|
* context, token sum) and wraps the
|
||||||
* reused inner components (ConversationList + ChatThread) in window chrome
|
* reused inner components (ConversationList + ChatThread) in window chrome
|
||||||
* ported from the GitmostAgent.jsx design.
|
* ported from the GitmostAgent.jsx design.
|
||||||
*/
|
*/
|
||||||
export default function AiChatWindow() {
|
export default function AiChatWindow() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||||
@@ -122,39 +138,13 @@ export default function AiChatWindow() {
|
|||||||
minimizedRef.current = minimized;
|
minimizedRef.current = minimized;
|
||||||
|
|
||||||
const winRef = useRef<HTMLDivElement>(null);
|
const winRef = useRef<HTMLDivElement>(null);
|
||||||
// Live window geometry (position + size); initialized lazily on first open so
|
// Live window geometry (position + size); persisted to localStorage so a
|
||||||
// it is anchored to the current viewport (top-right corner). Kept in state so
|
// drag/resize survives a full page reload (and close/reopen). `null` means
|
||||||
// a user resize survives close/reopen and can be re-clamped to the viewport.
|
// "never placed yet" — the layout effect below then computes an initial
|
||||||
const [geom, setGeom] = useState<{
|
// top-right placement anchored to the current viewport, and on restore it is
|
||||||
left: number;
|
// re-clamped to the viewport (so a placement saved on a larger screen is not
|
||||||
top: number;
|
// left partly off-screen).
|
||||||
width: number;
|
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Track whether we are awaiting the id of a just-created (new) chat, so we
|
|
||||||
// can adopt it once the chat list refreshes after the first turn finishes.
|
|
||||||
const adoptNewChat = useRef(false);
|
|
||||||
|
|
||||||
// Latch: the chat id whose full persisted history has finished loading while
|
|
||||||
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
|
|
||||||
// messages invalidation) never tears the live thread back down to the loader.
|
|
||||||
const historyLoadedKeyRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Mount key for ChatThread + the chat the currently-mounted thread represents.
|
|
||||||
// `threadKey` normally tracks the active chat, so selecting a different chat
|
|
||||||
// (incl. from page history) remounts and re-seeds. The ONE exception is
|
|
||||||
// in-place adoption of a brand-new chat's server id: the adopt effect moves
|
|
||||||
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
|
|
||||||
// check below does not fire and the SAME thread stays mounted (its useChat
|
|
||||||
// already holds the just-finished turn) instead of being re-seeded from
|
|
||||||
// not-yet-persisted history.
|
|
||||||
const [threadKey, setThreadKey] = useState<string>(
|
|
||||||
() => activeChatId ?? `new-${generateId()}`,
|
|
||||||
);
|
|
||||||
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
|
|
||||||
activeChatId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: chats } = useAiChatsQuery();
|
const { data: chats } = useAiChatsQuery();
|
||||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||||
@@ -171,6 +161,12 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
|
|
||||||
|
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||||
|
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||||
|
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||||
|
// context size below.
|
||||||
|
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||||
|
|
||||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// pathname against the authenticated page route instead so "the current page"
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
@@ -188,21 +184,47 @@ export default function AiChatWindow() {
|
|||||||
? { id: openPageData.id, title: openPageData.title }
|
? { id: openPageData.id, title: openPageData.title }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||||
|
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||||
|
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||||
|
// The invalidate closures are passed inline: `onTurnFinished` is read live by
|
||||||
|
// useChat's onFinish (never in an effect dep array), so their identity does not
|
||||||
|
// matter — no memoization ceremony needed.
|
||||||
|
const {
|
||||||
|
threadKey,
|
||||||
|
waitingForHistory,
|
||||||
|
onTurnFinished,
|
||||||
|
onServerChatId,
|
||||||
|
cancelPendingAdoption,
|
||||||
|
} = useChatSession({
|
||||||
|
activeChatId,
|
||||||
|
setActiveChatId,
|
||||||
|
chats,
|
||||||
|
messagesLoading,
|
||||||
|
onInvalidateChatList: () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
|
||||||
|
onInvalidateChatMessages: (id) =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// startNewChat/selectChat set the public atom; the hook's render-phase
|
||||||
|
// reconciler handles the remount when activeChatId actually CHANGES. But
|
||||||
|
// pressing "New chat" while already in a new chat leaves activeChatId === null
|
||||||
|
// (a no-op for the atom), so the reconciler never fires — explicitly disarm any
|
||||||
|
// armed error-path fallback here so a late refetch can't yank the user into a
|
||||||
|
// just-failed chat after they chose a fresh one.
|
||||||
const startNewChat = useCallback((): void => {
|
const startNewChat = useCallback((): void => {
|
||||||
// Cancel any pending adoption so a just-finished new chat can't yank the user
|
cancelPendingAdoption();
|
||||||
// back here after they explicitly started a fresh one.
|
|
||||||
adoptNewChat.current = false;
|
|
||||||
setActiveChatId(null);
|
setActiveChatId(null);
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
}, [setActiveChatId, setDraft, setSelectedRoleId]);
|
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
|
||||||
|
|
||||||
const selectChat = useCallback(
|
const selectChat = useCallback(
|
||||||
(chatId: string): void => {
|
(chatId: string): void => {
|
||||||
// Cancel any pending adoption so it can't override an explicit selection.
|
cancelPendingAdoption();
|
||||||
adoptNewChat.current = false;
|
|
||||||
setActiveChatId(chatId);
|
setActiveChatId(chatId);
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
@@ -210,43 +232,31 @@ export default function AiChatWindow() {
|
|||||||
// chat's header/assistant-name (which prefers the chat's persisted role).
|
// chat's header/assistant-name (which prefers the chat's persisted role).
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
},
|
},
|
||||||
[setActiveChatId, setDraft, setSelectedRoleId],
|
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
// The active chat object (for its title) and an export gate. The export is now
|
||||||
// yet), the server has just created the row; adopt the newest chat id so the
|
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
|
||||||
// thread switches from "new" to the persisted chat (and loads its history on
|
// row is persisted upfront + per step, so even a brand-new chat whose first
|
||||||
// later opens).
|
// turn is streaming/interrupted has a server row to render. Enable the button
|
||||||
const onTurnFinished = useCallback(() => {
|
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
|
||||||
if (activeChatId === null) adoptNewChat.current = true;
|
// chat that id is adopted EARLY — at the stream's `start` chunk via
|
||||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
// onServerChatId (#174) — so the Copy button is available during the first
|
||||||
// Re-sync the persisted message rows for the active chat so the Markdown
|
// turn's stream, not only after it terminates.
|
||||||
// export and the token counters reflect the turn that just finished. The
|
|
||||||
// live thread renders from its own useChat store (stable threadKey / store
|
|
||||||
// id), so refetching these rows never re-seeds or tears down the open
|
|
||||||
// thread. For a brand-new chat activeChatId is still null here; that chat's
|
|
||||||
// first row load happens right after id adoption, and every later turn hits
|
|
||||||
// this invalidation with the adopted id.
|
|
||||||
if (activeChatId) {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeChatId, queryClient]);
|
|
||||||
|
|
||||||
// The active chat object (for its title) and an export gate: only enable the
|
|
||||||
// export button when an existing chat with loaded persisted rows is active.
|
|
||||||
const activeChat = useMemo(
|
const activeChat = useMemo(
|
||||||
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
||||||
[chats, activeChatId],
|
[chats, activeChatId],
|
||||||
);
|
);
|
||||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
const canExport = !!activeChatId;
|
||||||
|
|
||||||
// The role to display in the header and as the assistant's name. Prefer the
|
// The role to display in the header and as the assistant's name. Prefer the
|
||||||
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
||||||
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
||||||
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
||||||
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
|
const currentRole = useMemo<{
|
||||||
|
name: string;
|
||||||
|
emoji: string | null;
|
||||||
|
} | null>(() => {
|
||||||
if (activeChat?.roleName) {
|
if (activeChat?.roleName) {
|
||||||
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||||
}
|
}
|
||||||
@@ -254,76 +264,21 @@ export default function AiChatWindow() {
|
|||||||
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
||||||
}, [activeChat, enabledRoles, selectedRoleId]);
|
}, [activeChat, enabledRoles, selectedRoleId]);
|
||||||
|
|
||||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
|
||||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
// server is the single source of truth (#183): it renders the transcript from
|
||||||
// feedback.
|
// the persisted rows — including an interrupted turn's in-progress row — so the
|
||||||
const handleCopy = useCallback(() => {
|
// export is identical whether the chat is freshly streaming, just switched to,
|
||||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
// or reloaded. The `lang` of the active i18n drives the few localized labels.
|
||||||
const markdown = buildChatMarkdown({
|
const handleCopy = useCallback(async () => {
|
||||||
title: activeChat?.title ?? null,
|
if (!activeChatId) return;
|
||||||
chatId: activeChatId,
|
try {
|
||||||
rows: messageRows,
|
const markdown = await exportAiChat(activeChatId, i18n.language);
|
||||||
t,
|
clipboard.copy(markdown);
|
||||||
});
|
notifications.show({ message: t("Copied") });
|
||||||
clipboard.copy(markdown);
|
} catch {
|
||||||
notifications.show({ message: t("Copied") });
|
notifications.show({ message: t("Failed to export chat"), color: "red" });
|
||||||
}, [activeChatId, messageRows, activeChat, clipboard, t]);
|
|
||||||
|
|
||||||
// When awaiting a new chat's id, adopt the most-recent chat (the list is
|
|
||||||
// ordered newest-first) once it appears.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!adoptNewChat.current) return;
|
|
||||||
const newest = chats?.items?.[0];
|
|
||||||
if (newest) {
|
|
||||||
adoptNewChat.current = false;
|
|
||||||
// In-place adoption: move the active chat AND the live-thread marker to the
|
|
||||||
// new id together, so the threadKey derivation below sees no "switch" and
|
|
||||||
// keeps the SAME mounted thread (its useChat already holds the finished
|
|
||||||
// turn) instead of remounting and re-seeding from not-yet-persisted history.
|
|
||||||
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
|
|
||||||
// render so the render-phase guard never observes the new activeChatId with
|
|
||||||
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
|
|
||||||
// batching inside this effect callback guarantees that; if the store/atom
|
|
||||||
// mechanism ever changes, gate adoption on an explicit flag instead.
|
|
||||||
setLiveThreadChatId(newest.id);
|
|
||||||
setActiveChatId(newest.id);
|
|
||||||
}
|
}
|
||||||
}, [chats, setActiveChatId]);
|
}, [activeChatId, clipboard, t, i18n.language]);
|
||||||
|
|
||||||
// Adjust the derived thread state during render when the active chat genuinely
|
|
||||||
// changes — the React-sanctioned alternative to an effect (it re-renders before
|
|
||||||
// paint, no extra commit, and converges since the next render finds them equal).
|
|
||||||
// In-place adoption of a new chat's id never reaches here because the adopt
|
|
||||||
// effect moves liveThreadChatId in lockstep with activeChatId.
|
|
||||||
if (activeChatId !== liveThreadChatId) {
|
|
||||||
setLiveThreadChatId(activeChatId);
|
|
||||||
setThreadKey(activeChatId ?? `new-${generateId()}`);
|
|
||||||
}
|
|
||||||
// Latch the active chat once its full history has loaded and its thread is
|
|
||||||
// mounted, so a later background refetch (the post-turn messages
|
|
||||||
// invalidation, which can transiently flip hasNextPage for a chat whose
|
|
||||||
// message count is an exact multiple of the server page size) does not tear
|
|
||||||
// the live thread down to a loader and lose its in-progress useChat state.
|
|
||||||
if (
|
|
||||||
activeChatId !== null &&
|
|
||||||
threadKey === activeChatId &&
|
|
||||||
!messagesLoading &&
|
|
||||||
historyLoadedKeyRef.current !== activeChatId
|
|
||||||
) {
|
|
||||||
historyLoadedKeyRef.current = activeChatId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the history loader only when freshly OPENING an existing chat (the key
|
|
||||||
// equals the chat id) whose history has not been fully loaded yet. For a live
|
|
||||||
// in-place thread that adopted its id, the key is still the "new-…" session
|
|
||||||
// key, so we keep showing the live thread instead of unmounting it behind a
|
|
||||||
// loader; and once a chat's history has loaded, a later background refetch no
|
|
||||||
// longer tears the thread back down (see the latch above).
|
|
||||||
const waitingForHistory =
|
|
||||||
activeChatId !== null &&
|
|
||||||
messagesLoading &&
|
|
||||||
threadKey === activeChatId &&
|
|
||||||
historyLoadedKeyRef.current !== activeChatId;
|
|
||||||
|
|
||||||
// Current context size for the active chat: how much the conversation now
|
// Current context size for the active chat: how much the conversation now
|
||||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||||
@@ -390,18 +345,23 @@ export default function AiChatWindow() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowOpen || minimized) return;
|
if (!windowOpen || minimized) return;
|
||||||
const el = winRef.current;
|
const el = winRef.current;
|
||||||
|
// `geom` is in the deps so this re-runs once geometry is settled and the
|
||||||
|
// window is actually rendered (on the first open `geom` is still null on the
|
||||||
|
// render that flips windowOpen, so winRef.current is null then — without the
|
||||||
|
// geom dep the observer would never attach and resizes would not persist).
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
const width = el.offsetWidth;
|
const width = el.offsetWidth;
|
||||||
const height = el.offsetHeight;
|
const height = el.offsetHeight;
|
||||||
setGeom((prev) => {
|
setGeom((prev) => {
|
||||||
if (!prev || (prev.width === width && prev.height === height)) return prev;
|
if (!prev || (prev.width === width && prev.height === height))
|
||||||
|
return prev;
|
||||||
return { ...prev, width, height };
|
return { ...prev, width, height };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [windowOpen, minimized]);
|
}, [windowOpen, minimized, geom !== null]);
|
||||||
|
|
||||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||||
// Ignore drags that originate on a button (minimize/close/new chat).
|
// Ignore drags that originate on a button (minimize/close/new chat).
|
||||||
@@ -535,11 +495,23 @@ export default function AiChatWindow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||||
{contextTokens > 0 && (
|
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||||
<Tooltip label={t("Current context size")} withArrow>
|
once it finishes, fall back to the persisted context size. Require
|
||||||
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
|
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||||
|
flash a "0" badge before any token streams in (#151 review). */}
|
||||||
|
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||||
|
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||||
|
<span className={classes.badge}>
|
||||||
|
{formatTokens(liveTurnTokens)}
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
) : contextTokens > 0 ? (
|
||||||
|
<Tooltip label={t("Current context size")} withArrow>
|
||||||
|
<span className={classes.badge}>
|
||||||
|
{formatTokens(contextTokens)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
@@ -551,7 +523,11 @@ export default function AiChatWindow() {
|
|||||||
aria-label={t("Copy chat")}
|
aria-label={t("Copy chat")}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
{clipboard.copied ? (
|
||||||
|
<IconCheck size={14} />
|
||||||
|
) : (
|
||||||
|
<IconCopy size={14} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -657,6 +633,8 @@ export default function AiChatWindow() {
|
|||||||
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
|
onServerChatId={onServerChatId}
|
||||||
|
onLiveTurnTokens={setLiveTurnTokens}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,45 @@
|
|||||||
padding-inline-start: 1.4em;
|
padding-inline-start: 1.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||||
|
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||||
|
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||||
|
layout) shrinks columns to a single glyph and wraps headers mid-word
|
||||||
|
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
|
||||||
|
give cells a readable minimum width, and restore word-boundary wrapping. */
|
||||||
|
.markdown table {
|
||||||
|
display: block;
|
||||||
|
/* lets the table scroll horizontally on its own */
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-block-end: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown th,
|
||||||
|
.markdown td {
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
padding: 3px 8px;
|
||||||
|
/* readable floor; the block scrolls when the row exceeds the panel */
|
||||||
|
min-width: 6em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
/* cancel the inherited break-word so words don't split mid-glyph */
|
||||||
|
word-break: normal;
|
||||||
|
/* still wrap genuinely long words / URLs at the cell edge */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown th {
|
||||||
|
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
|
||||||
|
.markdown table p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
||||||
has not yet produced any visible text/tool parts. */
|
has not yet produced any visible text/tool parts. */
|
||||||
.typingDots {
|
.typingDots {
|
||||||
@@ -111,6 +150,28 @@
|
|||||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible "Thinking" (reasoning) block: a subtle left rule, dimmer than the
|
||||||
|
answer so it reads as secondary thinking context above the real answer. */
|
||||||
|
.reasoningBlock {
|
||||||
|
border-left: 2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoningText {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||||
|
rendered markdown <div> it would turn the newlines between block tags
|
||||||
|
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||||
|
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
||||||
|
inline itself (see reasoning-block.tsx). */
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoningText p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: var(--mantine-spacing-xs);
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A classified AI chat error banner: a warning icon + bold heading on the first
|
||||||
|
* row, with the detail text spanning the full width below. Rendered for BOTH the
|
||||||
|
* live stream error (ChatThread) and a persisted assistant error (MessageItem),
|
||||||
|
* so this markup lives in one place. The detail is full-width (no hanging indent
|
||||||
|
* under the heading) so it wraps less and leaves no stranded icon / empty gap.
|
||||||
|
* The heading reuses Mantine's adaptive red "light" colour so it stays correct
|
||||||
|
* in dark mode. Layout-only props (mb/mt/...) are forwarded to the Alert root.
|
||||||
|
*/
|
||||||
|
interface ChatErrorAlertProps extends Omit<AlertProps, "title" | "children"> {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatErrorAlert({
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
style,
|
||||||
|
...alertProps
|
||||||
|
}: ChatErrorAlertProps) {
|
||||||
|
// Mantine's own "light" alert colour, adaptive across light/dark schemes.
|
||||||
|
const accent = "var(--mantine-color-red-light-color)";
|
||||||
|
return (
|
||||||
|
// flexShrink: 0 keeps the banner fully visible. Mantine's Alert root is
|
||||||
|
// `overflow: hidden`, so as a flex child of the chat panel it can otherwise
|
||||||
|
// be compressed below its content height and clip the detail text; the
|
||||||
|
// scrollable message list absorbs the height pressure instead.
|
||||||
|
<Alert
|
||||||
|
{...alertProps}
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
p="xs"
|
||||||
|
style={[{ flexShrink: 0 }, style]}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center" mb={4}>
|
||||||
|
<IconAlertTriangle size={18} style={{ flex: "none", color: accent }} />
|
||||||
|
<Text fw={700} size="sm" lh={1.2} style={{ color: accent }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" lh={1.4}>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ export default function ChatInput({
|
|||||||
const [value, setValue] = useAtom(aiChatDraftAtom);
|
const [value, setValue] = useAtom(aiChatDraftAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||||
|
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
|
||||||
|
// keeps the stable batch path.
|
||||||
|
const streamingDictation =
|
||||||
|
workspace?.settings?.ai?.dictationStreaming === true;
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
@@ -71,7 +75,7 @@ export default function ChatInput({
|
|||||||
{isDictationEnabled && (
|
{isDictationEnabled && (
|
||||||
<MicButton
|
<MicButton
|
||||||
size="lg"
|
size="lg"
|
||||||
streaming
|
streaming={streamingDictation}
|
||||||
disabled={isStreaming || disabled}
|
disabled={isStreaming || disabled}
|
||||||
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
|
||||||
|
import { IconPlayerStopFilled } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A neutral "turn was interrupted" notice (NOT an error). Rendered for an
|
||||||
|
* aborted turn — a manual Stop or a dropped connection — both live (ChatThread)
|
||||||
|
* and in reopened history (MessageItem). Deliberately gray/subtle so it reads as
|
||||||
|
* an informational marker, distinct from the red ChatErrorAlert. Layout-only
|
||||||
|
* props (mt/mb/...) are forwarded to the Alert root.
|
||||||
|
*/
|
||||||
|
interface ChatStoppedNoticeProps extends Omit<AlertProps, "title" | "children"> {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatStoppedNotice({
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
...alertProps
|
||||||
|
}: ChatStoppedNoticeProps) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
{...alertProps}
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
p="xs"
|
||||||
|
// flexShrink: 0 mirrors ChatErrorAlert so the notice is not compressed as a
|
||||||
|
// flex child of the chat panel.
|
||||||
|
style={[{ flexShrink: 0 }, style]}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<IconPlayerStopFilled
|
||||||
|
size={16}
|
||||||
|
style={{ flex: "none", color: "var(--mantine-color-dimmed)" }}
|
||||||
|
/>
|
||||||
|
<Text size="sm" lh={1.3} c="dimmed">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateId } from "ai";
|
import { generateId } from "ai";
|
||||||
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
|
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||||
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||||
|
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||||
|
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||||
import {
|
import {
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
roleLaunchMessage,
|
||||||
|
shouldResetRolePicked,
|
||||||
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -21,6 +29,14 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
|
// Throttle how often the streamed `messages` state triggers a re-render. Without
|
||||||
|
// it, useChat updates state on EVERY token, so the whole transcript's markdown
|
||||||
|
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
|
||||||
|
// into a quadratic CPU storm that pins the main thread and freezes the UI.
|
||||||
|
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
|
||||||
|
// from the token rate.
|
||||||
|
const STREAM_THROTTLE_MS = 50;
|
||||||
|
|
||||||
/** The page the user is currently viewing, sent as chat context. */
|
/** The page the user is currently viewing, sent as chat context. */
|
||||||
export interface OpenPageContext {
|
export interface OpenPageContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,9 +64,23 @@ interface ChatThreadProps {
|
|||||||
/** Display name for the assistant label / typing line (the role name);
|
/** Display name for the assistant label / typing line (the role name);
|
||||||
* forwarded to MessageList. Absent => the generic "AI agent". */
|
* forwarded to MessageList. Absent => the generic "AI agent". */
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
/** Called when a turn finishes; the parent refreshes the chat list and, for a
|
||||||
* a new chat, adopts the freshly created chat id. */
|
* new chat, adopts the freshly created chat id. `serverChatId` is the
|
||||||
onTurnFinished: () => void;
|
* authoritative id the server streamed on the assistant message metadata, or
|
||||||
|
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
|
||||||
|
onTurnFinished: (serverChatId?: string) => void;
|
||||||
|
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
|
||||||
|
* chat id streamed on the assistant message metadata, so a brand-new chat
|
||||||
|
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
|
||||||
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
|
* which fires only at the terminal outcome. */
|
||||||
|
onServerChatId?: (serverChatId?: string) => void;
|
||||||
|
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||||
|
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||||
|
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||||
|
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||||
|
* parent then reverts the badge to the persisted context size). */
|
||||||
|
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,13 +95,18 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
|||||||
? row.metadata.parts
|
? row.metadata.parts
|
||||||
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
||||||
const error = row.metadata?.error;
|
const error = row.metadata?.error;
|
||||||
|
const finishReason = row.metadata?.finishReason;
|
||||||
|
const metadata: Record<string, unknown> = {};
|
||||||
|
if (error) metadata.error = error;
|
||||||
|
if (finishReason) metadata.finishReason = finishReason;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
role,
|
role,
|
||||||
parts,
|
parts,
|
||||||
// Carry a persisted turn error so MessageItem can render it after a remount
|
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
|
||||||
// (e.g. when a new chat adopts its id) and in reopened chat history.
|
// can render the error banner / "stopped" marker after a remount and in
|
||||||
...(error ? { metadata: { error } } : {}),
|
// reopened history.
|
||||||
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||||
} as UIMessage;
|
} as UIMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +124,8 @@ export default function ChatThread({
|
|||||||
onRolePicked,
|
onRolePicked,
|
||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
|
onServerChatId,
|
||||||
|
onLiveTurnTokens,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -217,6 +254,8 @@ export default function ChatThread({
|
|||||||
id: chatStoreId,
|
id: chatStoreId,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
transport,
|
transport,
|
||||||
|
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
|
||||||
|
experimental_throttle: STREAM_THROTTLE_MS,
|
||||||
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
||||||
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
||||||
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
||||||
@@ -225,16 +264,27 @@ export default function ChatThread({
|
|||||||
// sending after the user hit Stop — or blindly retrying after a failure —
|
// sending after the user hit Stop — or blindly retrying after a failure —
|
||||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||||
// the user to decide.
|
// the user to decide.
|
||||||
onFinish: ({ isAbort, isDisconnect, isError }) => {
|
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||||
onTurnFinished();
|
// Forward the authoritative server chatId (streamed on the assistant
|
||||||
|
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||||
|
// chat — see adopt-chat-id.ts for the full #137 design.
|
||||||
|
onTurnFinished(extractServerChatId(message));
|
||||||
|
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||||
|
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||||
|
if (isError) setStopNotice(null);
|
||||||
|
else if (isAbort) setStopNotice("manual");
|
||||||
|
else if (isDisconnect) setStopNotice("disconnect");
|
||||||
|
else setStopNotice(null);
|
||||||
if (isAbort || isDisconnect || isError) return;
|
if (isAbort || isDisconnect || isError) return;
|
||||||
flushNext();
|
flushNext();
|
||||||
},
|
},
|
||||||
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
||||||
// Log the raw failure here for devtools; the UI shows a friendly classified
|
// Log the raw failure here for devtools; the UI shows a friendly classified
|
||||||
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
|
// banner via `error` below. We still call `onTurnFinished()` with NO server id
|
||||||
// the onFinish call) so a brand-new chat that fails its first turn is adopted
|
// (idempotent with the onFinish call): for a brand-new chat that ARMS the
|
||||||
// and the chat list refreshes immediately rather than after a manual refresh.
|
// bounded list-refetch fallback (adopt the single newly-appeared chat once the
|
||||||
|
// refetch lands); for an existing chat it just refreshes the chat list
|
||||||
|
// immediately rather than after a manual refresh.
|
||||||
onError: (streamError) => {
|
onError: (streamError) => {
|
||||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||||
// the UI separately shows a friendly classified banner (see errorView).
|
// the UI separately shows a friendly classified banner (see errorView).
|
||||||
@@ -246,23 +296,134 @@ export default function ChatThread({
|
|||||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||||
sendMessageRef.current = sendMessage;
|
sendMessageRef.current = sendMessage;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
|
||||||
|
// WHILE the first turn is still streaming and activeChatId-gated affordances
|
||||||
|
// (the Copy/export button) light up immediately, instead of only at onFinish.
|
||||||
|
// Keyed by the last-seen id so we forward each distinct id exactly once. The
|
||||||
|
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
|
||||||
|
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onServerChatId) return;
|
||||||
|
const tail = messages[messages.length - 1];
|
||||||
|
if (tail?.role !== "assistant") return;
|
||||||
|
const serverChatId = extractServerChatId(tail);
|
||||||
|
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
|
||||||
|
return;
|
||||||
|
lastForwardedChatIdRef.current = serverChatId;
|
||||||
|
onServerChatId(serverChatId);
|
||||||
|
}, [messages, onServerChatId]);
|
||||||
|
|
||||||
|
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||||
|
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||||
|
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
|
||||||
|
// (`isDisconnect`) — a distinction only available live (the server persists
|
||||||
|
// both as finishReason 'aborted'). Cleared when the next turn starts.
|
||||||
|
const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) setStopNotice(null);
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
// Classify the turn error into a heading + detail so the banner names the cause
|
// Classify the turn error into a heading + detail so the banner names the cause
|
||||||
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
||||||
// of a generic "Something went wrong".
|
// of a generic "Something went wrong". Computed here (not only in the JSX) so
|
||||||
|
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||||
|
|
||||||
// Clicking a role card both binds the role to THIS new chat and immediately
|
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||||
// starts the conversation. roleIdRef is set synchronously here because the
|
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||||
// parent's selectedRoleId state update would only reach roleIdRef on the next
|
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||||
// render — after this synchronous sendMessage has already read it.
|
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||||
|
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||||
|
// the badge to the persisted context size.
|
||||||
|
const lastEmitRef = useRef(0);
|
||||||
|
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLiveTurnTokens) return;
|
||||||
|
if (!isStreaming) {
|
||||||
|
// Turn ended (or never started): clear any pending throttle and revert.
|
||||||
|
if (emitTimerRef.current) {
|
||||||
|
clearTimeout(emitTimerRef.current);
|
||||||
|
emitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
lastEmitRef.current = 0;
|
||||||
|
onLiveTurnTokens(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tail = messages[messages.length - 1];
|
||||||
|
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||||
|
const total = live ? live.reasoning + live.output : 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||||
|
const elapsed = now - lastEmitRef.current;
|
||||||
|
if (elapsed >= MIN_INTERVAL) {
|
||||||
|
lastEmitRef.current = now;
|
||||||
|
onLiveTurnTokens(total);
|
||||||
|
} else if (!emitTimerRef.current) {
|
||||||
|
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||||
|
emitTimerRef.current = setTimeout(() => {
|
||||||
|
emitTimerRef.current = null;
|
||||||
|
lastEmitRef.current = Date.now();
|
||||||
|
onLiveTurnTokens(total);
|
||||||
|
}, MIN_INTERVAL - elapsed);
|
||||||
|
}
|
||||||
|
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||||
|
|
||||||
|
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||||
|
// trailing emit can't fire into a torn-down thread's parent.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||||
|
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||||
|
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||||
|
// so the user can type the first message themselves. roleIdRef is already set,
|
||||||
|
// so that first manual message carries the roleId.
|
||||||
|
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
|
||||||
|
|
||||||
|
// Clicking a role card always binds the role to THIS new chat. Whether it also
|
||||||
|
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
|
||||||
|
// synchronously here because the parent's selectedRoleId state update would
|
||||||
|
// only reach roleIdRef on the next render — after this synchronous sendMessage
|
||||||
|
// has already read it.
|
||||||
const handleRolePick = (role: IAiRole): void => {
|
const handleRolePick = (role: IAiRole): void => {
|
||||||
roleIdRef.current = role.id;
|
roleIdRef.current = role.id;
|
||||||
onRolePicked?.(role);
|
onRolePicked?.(role);
|
||||||
sendMessage({ text: t("Take a look at the current document") });
|
const launch = roleLaunchMessage(
|
||||||
|
role,
|
||||||
|
t("Take a look at the current document"),
|
||||||
|
);
|
||||||
|
if (launch !== null) {
|
||||||
|
sendMessage({ text: launch });
|
||||||
|
} else {
|
||||||
|
// autoStart=false -> bind only: hide the cards, show the composer.
|
||||||
|
setRolePickedNoSend(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
|
||||||
|
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
|
||||||
|
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
|
||||||
|
// chatId null, so the thread never remounts and the flag would stay set, hiding
|
||||||
|
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
|
||||||
|
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
|
||||||
|
// change"): one-shot — it re-renders with the flag false and the guard no longer
|
||||||
|
// matches, so it cannot loop. (Review of #149.)
|
||||||
|
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
|
||||||
|
setRolePickedNoSend(false);
|
||||||
|
}
|
||||||
|
const showRoleCards =
|
||||||
|
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
|
||||||
const roleCardsEmptyState = showRoleCards ? (
|
const roleCardsEmptyState = showRoleCards ? (
|
||||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||||
) : undefined;
|
) : undefined;
|
||||||
@@ -276,17 +437,22 @@ export default function ChatThread({
|
|||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errorView && (
|
{errorView ? (
|
||||||
<Alert
|
<ChatErrorAlert
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
mb="xs"
|
|
||||||
title={errorView.title}
|
title={errorView.title}
|
||||||
>
|
detail={errorView.detail}
|
||||||
{errorView.detail}
|
mb="xs"
|
||||||
</Alert>
|
/>
|
||||||
)}
|
) : stopNotice ? (
|
||||||
|
<ChatStoppedNotice
|
||||||
|
text={
|
||||||
|
stopNotice === "manual"
|
||||||
|
? t("Response stopped.")
|
||||||
|
: t("Connection lost — the answer was interrupted.")
|
||||||
|
}
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack gap={0} className={classes.inputWrapper}>
|
<Stack gap={0} className={classes.inputWrapper}>
|
||||||
{queued.length > 0 && (
|
{queued.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 (the component reads `useTranslation`). Mirrors the stub in
|
||||||
|
// reasoning-block.test.tsx.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
|
||||||
|
// OTHER named export of markdown.ts intact via `importActual`, and override only
|
||||||
|
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
|
||||||
|
// still renders. This is the seam that proves the MarkdownPart memo works: a
|
||||||
|
// finalized text part must NOT be re-parsed on a later streamed delta.
|
||||||
|
// `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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
import MessageItem from "./message-item";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
const renderRow = (message: UIMessage) =>
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={message} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
|
||||||
|
const callsFor = (text: string) =>
|
||||||
|
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
|
||||||
|
|
||||||
|
describe("MessageItem markdown memoization", () => {
|
||||||
|
it("does not re-parse finalized text parts when only a tail part grows", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
|
||||||
|
// Two finalized text parts.
|
||||||
|
const first = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "text", text: "beta" },
|
||||||
|
]);
|
||||||
|
const { rerender } = renderRow(first);
|
||||||
|
|
||||||
|
// Both finalized parts parsed exactly once on the initial render.
|
||||||
|
expect(callsFor("alpha")).toBe(1);
|
||||||
|
expect(callsFor("beta")).toBe(1);
|
||||||
|
|
||||||
|
// A streamed delta: a NEW message object where only a third tail part grows;
|
||||||
|
// the first two parts' text is byte-identical.
|
||||||
|
const next = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "text", text: "beta" },
|
||||||
|
{ type: "text", text: "gamm" },
|
||||||
|
]);
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={next} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The finalized parts hit the MarkdownPart memo: still parsed at most once
|
||||||
|
// each across BOTH renders (the resilient invariant). The only new parse is
|
||||||
|
// for the changed/added tail part.
|
||||||
|
expect(callsFor("alpha")).toBe(1);
|
||||||
|
expect(callsFor("beta")).toBe(1);
|
||||||
|
expect(callsFor("gamm")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
// Stub react-i18next: importing the component module pulls in `useTranslation`,
|
||||||
|
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
|
||||||
|
// minimal `t` that echoes the key is enough. Mirrors the stub in
|
||||||
|
// reasoning-block.test.tsx.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { arePropsEqual } from "./message-item";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
const props = (
|
||||||
|
message: UIMessage,
|
||||||
|
over: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
message,
|
||||||
|
showCitations: true,
|
||||||
|
neutralizeInternalLinks: false,
|
||||||
|
assistantName: "AI",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("arePropsEqual", () => {
|
||||||
|
it("returns false when showCitations differs", () => {
|
||||||
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
|
expect(
|
||||||
|
arePropsEqual(props(m), props(m, { showCitations: false })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when neutralizeInternalLinks differs", () => {
|
||||||
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
|
expect(
|
||||||
|
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when assistantName differs", () => {
|
||||||
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
|
expect(
|
||||||
|
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on the identity fast path (same message object, equal props)", () => {
|
||||||
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
|
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for the same content in a different message object", () => {
|
||||||
|
const a = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const b = msg([{ type: "text", text: "answer" }]);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(arePropsEqual(props(a), props(b))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when content changed in a different message object", () => {
|
||||||
|
const a = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||||
|
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Alert, Box, Text } from "@mantine/core";
|
import { memo } from "react";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||||
|
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||||
|
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||||
|
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
@@ -30,6 +36,39 @@ interface MessageItemProps {
|
|||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||||
|
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||||
|
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||||
|
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||||
|
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||||
|
* `text` string is compared by value).
|
||||||
|
*/
|
||||||
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
|
if (html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.markdown}
|
||||||
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback when markdown could not render synchronously: raw text.
|
||||||
|
return (
|
||||||
|
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single UIMessage by iterating its `parts`:
|
* Render a single UIMessage by iterating its `parts`:
|
||||||
* - `text` parts -> sanitized markdown.
|
* - `text` parts -> sanitized markdown.
|
||||||
@@ -37,12 +76,13 @@ interface MessageItemProps {
|
|||||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||||
* User messages render their text as a right-aligned plain bubble.
|
* User messages render their text as a right-aligned plain bubble.
|
||||||
*
|
*
|
||||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
|
||||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
* per-message content signature: the streaming TAIL message's signature changes
|
||||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
* on each delta so it still re-renders and streams in, while finalized rows are
|
||||||
* text parts on each delta is what makes the answer stream in progressively.
|
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
|
||||||
|
* long turn no longer re-parses the whole transcript on every token.
|
||||||
*/
|
*/
|
||||||
export default function MessageItem({
|
function MessageItem({
|
||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
@@ -65,35 +105,52 @@ export default function MessageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An assistant message with nothing visible to render yet (an empty streaming
|
||||||
|
// text part, or a reasoning/step-start part while the model is still thinking)
|
||||||
|
// renders nothing here. The standalone TypingIndicator stands in for the nascent
|
||||||
|
// bubble (name + dots) until real content arrives, so exactly one element owns
|
||||||
|
// the agent name during the pre-content gap and the layout never jumps. Persisted
|
||||||
|
// errored/aborted turns DO have visible content per the helper (metadata.error /
|
||||||
|
// finishReason === "aborted"), so their banners below still render — this early
|
||||||
|
// return won't fire for them.
|
||||||
|
if (!assistantMessageHasVisibleContent(message)) return null;
|
||||||
|
|
||||||
|
// Authoritative reasoning token count to attribute to a reasoning block, or
|
||||||
|
// undefined when the block must estimate on its own. See reasoningTokensForPart
|
||||||
|
// for the #151 anti-double-count rule (only a single reasoning part may carry
|
||||||
|
// the turn total). The authoritative turn total is still surfaced live in the
|
||||||
|
// header badge regardless.
|
||||||
|
const reasoningTokens = reasoningTokensForPart(message);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.messageRow}>
|
<Box className={classes.messageRow}>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
<Text size="xs" c="dimmed" mb={4}>
|
||||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||||
</Text>
|
</Text>
|
||||||
{message.parts.map((part, index) => {
|
{message.parts.map((part, index) => {
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||||
|
// count. Empty/whitespace reasoning with no authoritative count carries
|
||||||
|
// nothing to show, so skip it (avoids an empty 0-token block).
|
||||||
|
const text = (part as { text?: string }).text ?? "";
|
||||||
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
// Skip empty/whitespace-only text parts (a streaming message often
|
// Skip empty/whitespace-only text parts (a streaming message often
|
||||||
// starts with an empty text part before the first token arrives); the
|
// starts with an empty text part before the first token arrives); the
|
||||||
// typing indicator covers that gap until real content streams in.
|
// typing indicator covers that gap until real content streams in.
|
||||||
if (!part.text.trim()) return null;
|
if (!part.text.trim()) return null;
|
||||||
const html = renderChatMarkdown(part.text, {
|
|
||||||
neutralizeInternalLinks,
|
|
||||||
});
|
|
||||||
if (html) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={classes.markdown}
|
|
||||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Fallback when markdown could not render synchronously: raw text.
|
|
||||||
return (
|
return (
|
||||||
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
<MarkdownPart
|
||||||
{part.text}
|
key={index}
|
||||||
</Text>
|
text={part.text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,17 +175,52 @@ export default function MessageItem({
|
|||||||
// cause plus a one-line detail.
|
// cause plus a one-line detail.
|
||||||
const errorView = describeChatError(errorText, t);
|
const errorView = describeChatError(errorText, t);
|
||||||
return (
|
return (
|
||||||
<Alert
|
<ChatErrorAlert
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
mt={4}
|
|
||||||
title={errorView.title}
|
title={errorView.title}
|
||||||
>
|
detail={errorView.detail}
|
||||||
{errorView.detail}
|
mt={4}
|
||||||
</Alert>
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* A persisted turn that was aborted (manual Stop or a dropped connection)
|
||||||
|
with no error banner. The server cannot tell a manual Stop from a
|
||||||
|
connection drop (both persist as finishReason 'aborted'), so reopened
|
||||||
|
history uses a combined wording. */}
|
||||||
|
{(() => {
|
||||||
|
const meta = message.metadata as
|
||||||
|
| { error?: string; finishReason?: string }
|
||||||
|
| undefined;
|
||||||
|
if (meta?.error || meta?.finishReason !== "aborted") return null;
|
||||||
|
return (
|
||||||
|
<ChatStoppedNotice
|
||||||
|
text={t("Response stopped (manually or the connection dropped).")}
|
||||||
|
mt={4}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MessageItem, arePropsEqual);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { UIMessage } from "@ai-sdk/react";
|
|||||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
@@ -49,7 +50,9 @@ const BOTTOM_THRESHOLD = 40;
|
|||||||
* assistant message's LAST part is not live output:
|
* assistant message's LAST part is not live output:
|
||||||
* - the last message is still the user's (assistant hasn't started a row), or
|
* - the last message is still the user's (assistant hasn't started a row), or
|
||||||
* - the assistant row has no parts yet, or
|
* - the assistant row has no parts yet, or
|
||||||
* - its last part is an empty/whitespace text part, or
|
* - its last part is an empty/whitespace text part, or a finished ("done")
|
||||||
|
* text part while the turn continues (the model paused after some narration
|
||||||
|
* and is thinking about its next step), or
|
||||||
* - its last part is a finished/errored tool (the model is thinking about the
|
* - its last part is a finished/errored tool (the model is thinking about the
|
||||||
* next step between tool calls).
|
* next step between tool calls).
|
||||||
* It hides only while output is actively rendering: a non-empty streaming text
|
* It hides only while output is actively rendering: a non-empty streaming text
|
||||||
@@ -63,7 +66,19 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
|
|||||||
const lastPart = last.parts[last.parts.length - 1];
|
const lastPart = last.parts[last.parts.length - 1];
|
||||||
if (!lastPart) return true; // assistant row exists but has no parts yet.
|
if (!lastPart) return true; // assistant row exists but has no parts yet.
|
||||||
// The answer text is actively streaming in -> MessageItem renders it; no dots.
|
// The answer text is actively streaming in -> MessageItem renders it; no dots.
|
||||||
if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false;
|
// Only while it is STILL streaming, though: once a non-empty text part is
|
||||||
|
// finalized ("done") but the turn is still in flight, the model has paused
|
||||||
|
// after some narration and is working on its next step (e.g. about to call a
|
||||||
|
// tool) — nothing is visibly progressing, so the dots must show. A text part
|
||||||
|
// without a `state` is treated as still-rendering (kept suppressed); this
|
||||||
|
// branch only runs while streaming, where live parts always carry a state.
|
||||||
|
if (
|
||||||
|
lastPart.type === "text" &&
|
||||||
|
lastPart.text.trim().length > 0 &&
|
||||||
|
(lastPart as { state?: "streaming" | "done" }).state !== "done"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
|
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
|
||||||
if (
|
if (
|
||||||
isToolPart(lastPart.type) &&
|
isToolPart(lastPart.type) &&
|
||||||
@@ -77,6 +92,22 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the standalone typing indicator should render its own assistant-name
|
||||||
|
* label. The indicator OWNS the name while the tail assistant row has no visible
|
||||||
|
* content yet (an empty streaming text part, or reasoning/step-start while the
|
||||||
|
* model is still thinking): in that gap the assistant MessageItem renders nothing,
|
||||||
|
* so the indicator stands in for the nascent bubble (name + dots) at a constant
|
||||||
|
* gap. It hides the name only once that row shows visible content, because then
|
||||||
|
* MessageItem draws the same name — avoids a duplicate stacked label and the
|
||||||
|
* layout jump that switching owners mid-stream used to cause.
|
||||||
|
*/
|
||||||
|
export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
if (!last || last.role !== "assistant") return true;
|
||||||
|
return !assistantMessageHasVisibleContent(last);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
|
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
|
||||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||||
@@ -173,7 +204,12 @@ export default function MessageList({
|
|||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{typing && <TypingIndicator assistantName={assistantName} />}
|
{typing && (
|
||||||
|
<TypingIndicator
|
||||||
|
assistantName={assistantName}
|
||||||
|
showName={typingIndicatorShowsName(messages)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||||
|
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||||
|
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||||
|
// other component tests in the repo.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: { count?: number }) =>
|
||||||
|
opts && typeof opts.count === "number"
|
||||||
|
? key.replace("{{count}}", String(opts.count))
|
||||||
|
: key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ReasoningBlock from "./reasoning-block";
|
||||||
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
function renderBlock(props: { text: string; tokens?: number }) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReasoningBlock {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReasoningBlock", () => {
|
||||||
|
it("shows the authoritative count in the header when tokens > 0", () => {
|
||||||
|
// Text "thinking…" estimates to ceil(9/4) = 3, but the authoritative 42
|
||||||
|
// must win, so the header shows 42 (and NOT the 3-token estimate).
|
||||||
|
renderBlock({ text: "thinking…", tokens: 42 });
|
||||||
|
expect(screen.getByText("Thinking · 42 tokens")).toBeDefined();
|
||||||
|
expect(screen.queryByText("Thinking · 3 tokens")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the text-length estimate when no authoritative tokens", () => {
|
||||||
|
const text = "some reasoning prose that streams in";
|
||||||
|
const estimate = estimateTokens(text);
|
||||||
|
renderBlock({ text });
|
||||||
|
expect(estimate).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(new RegExp(`${estimate} tokens`))).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header-only when text is empty but an authoritative count is present", () => {
|
||||||
|
renderBlock({ text: "", tokens: 17 });
|
||||||
|
expect(screen.getByText(/17 tokens/)).toBeDefined();
|
||||||
|
// No disclosure body to expand: the toggle button is disabled.
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the reasoning body (markdown or raw-text fallback)", () => {
|
||||||
|
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||||
|
// The toggle is enabled because there IS body text to expand.
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
// The body prose renders (markdown -> sanitized html, or raw-text fallback);
|
||||||
|
// either way the text is present in the document.
|
||||||
|
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { memo, useMemo, useState } from "react";
|
||||||
|
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
|
interface ReasoningBlockProps {
|
||||||
|
/** The streamed/persisted reasoning (thinking) text. May be empty when the
|
||||||
|
* provider reports only a reasoning token COUNT without the text. */
|
||||||
|
text: string;
|
||||||
|
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
|
||||||
|
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||||
|
* text length so it ticks live as the reasoning streams in. */
|
||||||
|
tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
|
||||||
|
* Code's surfacing of the model's thinking: a header that shows the thinking
|
||||||
|
* token count (authoritative when the step has reported usage, else a live
|
||||||
|
* estimate from the streamed text) and an expandable body with the reasoning
|
||||||
|
* prose. Collapsed by default so it never crowds out the answer.
|
||||||
|
*
|
||||||
|
* Providers that don't stream reasoning TEXT still render this block from the
|
||||||
|
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||||
|
*/
|
||||||
|
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||||
|
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||||
|
const trimmed = text.trim();
|
||||||
|
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||||
|
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||||
|
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||||
|
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||||
|
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||||
|
// paragraphs) — ONLY here, not in the normal answer.
|
||||||
|
const html = useMemo(
|
||||||
|
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||||
|
[trimmed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.reasoningBlock} mb={6}>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
// No body to expand when the provider reported only a token count.
|
||||||
|
disabled={!trimmed}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap" align="center">
|
||||||
|
<IconChevronDown
|
||||||
|
size={12}
|
||||||
|
style={{
|
||||||
|
transform: open ? "none" : "rotate(-90deg)",
|
||||||
|
transition: "transform 150ms ease",
|
||||||
|
opacity: trimmed ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{count > 0
|
||||||
|
? t("Thinking · {{count}} tokens", { count })
|
||||||
|
: t("Thinking")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
{trimmed && (
|
||||||
|
<Collapse in={open}>
|
||||||
|
{html ? (
|
||||||
|
<div
|
||||||
|
className={classes.reasoningText}
|
||||||
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
className={classes.reasoningText}
|
||||||
|
style={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{trimmed}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||||
|
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||||
|
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||||
|
export default memo(ReasoningBlock);
|
||||||
@@ -1,26 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import RoleCards from "./role-cards";
|
import RoleCards from "./role-cards";
|
||||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
// does not implement. Provide a minimal stub so the provider can render.
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
|
||||||
writable: true,
|
|
||||||
value: (query: string) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const roles: IAiRole[] = [
|
const roles: IAiRole[] = [
|
||||||
{
|
{
|
||||||
@@ -29,6 +13,8 @@ const roles: IAiRole[] = [
|
|||||||
emoji: "🏴☠️",
|
emoji: "🏴☠️",
|
||||||
description: "Talks like a pirate",
|
description: "Talks like a pirate",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r2",
|
id: "r2",
|
||||||
@@ -36,6 +22,8 @@ const roles: IAiRole[] = [
|
|||||||
emoji: null,
|
emoji: null,
|
||||||
description: null,
|
description: null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -82,4 +82,14 @@ describe("showTypingIndicator", () => {
|
|||||||
showTypingIndicator([msg("assistant", [doneTool, text])], true),
|
showTypingIndicator([msg("assistant", [doneTool, text])], true),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows while streaming after a text part is finalized (paused before the next step)", () => {
|
||||||
|
const doneText = { type: "text", text: "Now creating the page in", state: "done" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(showTypingIndicator([msg("assistant", [doneText])], true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides while a text part is actively streaming (state: streaming)", () => {
|
||||||
|
const streamingText = { type: "text", text: "Now writ", state: "streaming" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(showTypingIndicator([msg("assistant", [streamingText])], true)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { typingIndicatorShowsName } from "@/features/ai-chat/components/message-list.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for whether the standalone "Thinking…" indicator renders its
|
||||||
|
* own dimmed assistant-name label. The indicator OWNS the name while the tail
|
||||||
|
* assistant row has no visible content yet (an empty streaming text part, or
|
||||||
|
* reasoning/step-start while the model is still thinking) — in that gap the
|
||||||
|
* assistant MessageItem renders nothing, so the indicator stands in for the
|
||||||
|
* nascent bubble (name + dots). It hides the name only once the tail assistant
|
||||||
|
* row shows visible content, because then MessageItem draws the same name — this
|
||||||
|
* avoids a duplicate stacked label and the layout jump that switching owners
|
||||||
|
* mid-stream used to cause.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
role: "user" | "assistant",
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||||
|
|
||||||
|
describe("typingIndicatorShowsName", () => {
|
||||||
|
it("shows the name with no messages yet (standalone, just submitted)", () => {
|
||||||
|
expect(typingIndicatorShowsName([])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the name when the last message is still the user's", () => {
|
||||||
|
expect(
|
||||||
|
typingIndicatorShowsName([msg("user", [{ type: "text", text: "q" }])]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the name when the tail assistant row has no visible content yet (empty text part)", () => {
|
||||||
|
// The empty streaming text part has no visible content, so MessageItem renders
|
||||||
|
// nothing and the indicator owns the name (the nascent bubble).
|
||||||
|
expect(
|
||||||
|
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "" }])]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the name once the tail assistant row shows content (a tool part)", () => {
|
||||||
|
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(
|
||||||
|
typingIndicatorShowsName([msg("assistant", [doneTool])]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the name once the tail assistant row shows content (non-empty text)", () => {
|
||||||
|
expect(
|
||||||
|
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "answer" }])]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,12 @@ interface TypingIndicatorProps {
|
|||||||
* (agent role) name.
|
* (agent role) name.
|
||||||
*/
|
*/
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
/**
|
||||||
|
* Whether to render the dimmed assistant-name label. Defaults to true
|
||||||
|
* (standalone behavior preserved). Set false between agent steps where the
|
||||||
|
* assistant row above already shows the same name, to avoid a duplicate label.
|
||||||
|
*/
|
||||||
|
showName?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,28 +26,29 @@ interface TypingIndicatorProps {
|
|||||||
*
|
*
|
||||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||||
* as the assistant's bubble taking shape. The dimmed label uses the configured
|
* as the assistant's bubble taking shape. The dimmed label uses the configured
|
||||||
* identity name when provided (otherwise the generic "AI agent"), while the
|
* identity name when provided (otherwise the generic "AI agent"); below it the
|
||||||
* typing line is always the generic "Thinking…" (it never includes the
|
* animated dots stand in for the nascent bubble until content arrives.
|
||||||
* role/identity name).
|
|
||||||
*/
|
*/
|
||||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const name = resolveAssistantName(assistantName);
|
const name = resolveAssistantName(assistantName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.messageRow}>
|
<Box className={classes.messageRow}>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
{showName !== false && (
|
||||||
{name ?? t("AI agent")}
|
// Extra bottom gap (vs MessageItem's mb={4}) gives the small bouncing
|
||||||
</Text>
|
// dots room below the name label; without it they crowd the label. Only
|
||||||
|
// applies when the name is shown — the nameless case spaces fine on its own.
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
{name ?? t("AI agent")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Group gap={8} align="center">
|
<Group gap={8} align="center">
|
||||||
<span className={classes.typingDots} aria-hidden="true">
|
<span className={classes.typingDots} aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</span>
|
</span>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Thinking…")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
246
apps/client/src/features/ai-chat/hooks/use-chat-session.test.tsx
Normal file
246
apps/client/src/features/ai-chat/hooks/use-chat-session.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useChatSession } from "./use-chat-session";
|
||||||
|
import type { UseChatSessionOptions } from "./use-chat-session";
|
||||||
|
|
||||||
|
// The props the test drives: the parent-owned subset of UseChatSessionOptions
|
||||||
|
// (the spies are injected by setup, not per-render). messagesLoading is optional
|
||||||
|
// here (defaulted to false in setup) for terser test call sites.
|
||||||
|
type DriverProps = Pick<UseChatSessionOptions, "activeChatId" | "chats"> & {
|
||||||
|
messagesLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive the hook the way the window does: the parent owns `activeChatId` and
|
||||||
|
// passes it back in. `setActiveChatId` is a spy so we can assert the EXACT id the
|
||||||
|
// hook adopts (the #137 regression: it must be the authoritative streamed id, not
|
||||||
|
// the newest chat in the list).
|
||||||
|
function setup(initial: DriverProps) {
|
||||||
|
const setActiveChatId = vi.fn();
|
||||||
|
const onInvalidateChatList = vi.fn();
|
||||||
|
const onInvalidateChatMessages = vi.fn();
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: DriverProps) =>
|
||||||
|
useChatSession({
|
||||||
|
activeChatId: props.activeChatId,
|
||||||
|
setActiveChatId,
|
||||||
|
chats: props.chats,
|
||||||
|
messagesLoading: props.messagesLoading ?? false,
|
||||||
|
onInvalidateChatList,
|
||||||
|
onInvalidateChatMessages,
|
||||||
|
}),
|
||||||
|
{ initialProps: initial },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
rerender,
|
||||||
|
setActiveChatId,
|
||||||
|
onInvalidateChatList,
|
||||||
|
onInvalidateChatMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useChatSession", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("#137 REGRESSION LOCK: adopts the authoritative streamed id, NOT items[0]", () => {
|
||||||
|
// Brand-new chat, list already holds a SIBLING chat B as items[0] (a second
|
||||||
|
// tab just created it). The server streams the real id "A" for THIS chat.
|
||||||
|
const { result, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "B" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished("A");
|
||||||
|
// Must adopt the authoritative id, not the newest-in-list guess.
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalledWith("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fallback adopt: arms on a server-id-less finish, adopts the single new id after refetch", () => {
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }] },
|
||||||
|
});
|
||||||
|
// No server id => arm the fallback (no adoption yet).
|
||||||
|
result.current.onTurnFinished(undefined);
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||||
|
// The refetch lands with the new row => adopt it.
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }, { id: "new" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fallback ambiguous: two new ids appear => no adoption", () => {
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished(undefined);
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }, { id: "n1" }, { id: "n2" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fallback add+delete in one window: adopts the new id (membership compare)", () => {
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "a" }, { id: "b" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished(undefined);
|
||||||
|
// a was deleted, new was added — same length, but membership changed.
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "b" }, { id: "new" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarm on reconcile: a fallback armed then switched away is NOT adopted by a late refetch", () => {
|
||||||
|
// Arm the error-path fallback on a brand-new chat (snapshot before=["x"]).
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished(undefined);
|
||||||
|
// The user switches to an existing chat C BEFORE the refetch lands; the
|
||||||
|
// render-phase reconciler must DISARM the pending fallback.
|
||||||
|
rerender({ activeChatId: "C", chats: { items: [{ id: "x" }] } });
|
||||||
|
// ...then starts a fresh new chat again (back to null), without re-arming.
|
||||||
|
rerender({ activeChatId: null, chats: { items: [{ id: "x" }] } });
|
||||||
|
// A late refetch now brings a new row. Because the earlier fallback was
|
||||||
|
// disarmed on the switch (not left armed with the stale ["x"] snapshot), it
|
||||||
|
// must NOT be adopted. (Without the disarm this would wrongly adopt "new".)
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }, { id: "new" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
|
||||||
|
// The Warning path the render-phase reconciler can't catch: pressing "New
|
||||||
|
// chat" while already in a new chat keeps activeChatId === null (a no-op for
|
||||||
|
// the atom), so only the explicit cancelPendingAdoption() disarms.
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
|
||||||
|
result.current.cancelPendingAdoption(); // window calls this from startNewChat
|
||||||
|
// The just-failed row lands in a late refetch; it must NOT be adopted.
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }, { id: "failed" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
|
||||||
|
const {
|
||||||
|
result,
|
||||||
|
setActiveChatId,
|
||||||
|
onInvalidateChatList,
|
||||||
|
onInvalidateChatMessages,
|
||||||
|
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
|
||||||
|
result.current.onTurnFinished("chat-1");
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
|
||||||
|
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||||
|
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double onTurnFinished on a failed-after-start turn: primary adopt, 2nd no-id call does NOT re-arm the fallback", () => {
|
||||||
|
// ai@6 fires onFinish AND onError on a failed turn. If the failure happened
|
||||||
|
// AFTER the `start` chunk, onFinish carries the streamed id and onError does
|
||||||
|
// not — so onTurnFinished runs twice in one turn (id, then no-id) before any
|
||||||
|
// re-render. The 2nd call must NOT re-arm the fallback off the still-null
|
||||||
|
// closure; otherwise a late refetch (parent hasn't reflected the adoption yet)
|
||||||
|
// would wrongly adopt a sibling row.
|
||||||
|
const { result, rerender, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }] },
|
||||||
|
});
|
||||||
|
result.current.onTurnFinished("A"); // onFinish: primary adoption
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||||
|
result.current.onTurnFinished(undefined); // onError: same turn, no id
|
||||||
|
// Even in the worst case (the parent has NOT yet reflected activeChatId="A"
|
||||||
|
// and a late refetch lands a new row), the just-failed sibling must NOT be
|
||||||
|
// adopted. Two layers guarantee this: the ref guard keeps the 2nd call from
|
||||||
|
// re-arming at the source, and the render-phase reconciler disarms anything
|
||||||
|
// stale once thread.chatId ("A") diverges from the still-null activeChatId.
|
||||||
|
rerender({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [{ id: "x" }, { id: "late" }] },
|
||||||
|
});
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
|
||||||
|
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
|
||||||
|
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
|
||||||
|
// fires at the terminal outcome). The hook must adopt it immediately so the
|
||||||
|
// window's activeChatId-gated Copy/export button lights up during the stream.
|
||||||
|
const { result, setActiveChatId } = setup({
|
||||||
|
activeChatId: null,
|
||||||
|
chats: { items: [] },
|
||||||
|
});
|
||||||
|
result.current.onServerChatId("A");
|
||||||
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
|
||||||
|
const chats = { items: [] };
|
||||||
|
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||||
|
const keyBefore = result.current.threadKey;
|
||||||
|
result.current.onServerChatId("A");
|
||||||
|
// Parent reflects the adopted id back in; the SAME mount key is kept so the
|
||||||
|
// in-flight useChat store (the streaming turn) is preserved.
|
||||||
|
rerender({ activeChatId: "A", chats });
|
||||||
|
expect(result.current.threadKey).toBe(keyBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
|
||||||
|
const { result, setActiveChatId } = setup({
|
||||||
|
activeChatId: "chat-1",
|
||||||
|
chats: { items: [{ id: "chat-1" }] },
|
||||||
|
});
|
||||||
|
result.current.onServerChatId("chat-1"); // already has an id
|
||||||
|
result.current.onServerChatId(undefined); // no streamed id
|
||||||
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
|
||||||
|
const chats = { items: [{ id: "B" }] };
|
||||||
|
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||||
|
const keyBefore = result.current.threadKey;
|
||||||
|
// Adopt the streamed id; the PARENT then reflects activeChatId="A" back in.
|
||||||
|
result.current.onTurnFinished("A");
|
||||||
|
rerender({ activeChatId: "A", chats });
|
||||||
|
// In-place adoption: SAME mount key (the live useChat store is preserved).
|
||||||
|
expect(result.current.threadKey).toBe(keyBefore);
|
||||||
|
|
||||||
|
// An EXTERNAL switch (not via adopt) to a different chat must remount: the
|
||||||
|
// key becomes the chat id.
|
||||||
|
rerender({ activeChatId: "C", chats });
|
||||||
|
expect(result.current.threadKey).toBe("C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
||||||
|
// Open an existing chat whose history is still loading => loader on.
|
||||||
|
const { result, rerender } = setup({
|
||||||
|
activeChatId: "chat-1",
|
||||||
|
chats: { items: [{ id: "chat-1" }] },
|
||||||
|
messagesLoading: true,
|
||||||
|
});
|
||||||
|
expect(result.current.waitingForHistory).toBe(true);
|
||||||
|
// Once loading finishes, the latch flips and the loader is off.
|
||||||
|
rerender({
|
||||||
|
activeChatId: "chat-1",
|
||||||
|
chats: { items: [{ id: "chat-1" }] },
|
||||||
|
messagesLoading: false,
|
||||||
|
});
|
||||||
|
expect(result.current.waitingForHistory).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
268
apps/client/src/features/ai-chat/hooks/use-chat-session.ts
Normal file
268
apps/client/src/features/ai-chat/hooks/use-chat-session.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
|
import { generateId } from "ai";
|
||||||
|
import {
|
||||||
|
resolveAdoptedChatId,
|
||||||
|
newlyAddedChatIds,
|
||||||
|
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import {
|
||||||
|
newThread,
|
||||||
|
switchThread,
|
||||||
|
threadSessionReducer,
|
||||||
|
} from "@/features/ai-chat/utils/thread-identity.ts";
|
||||||
|
|
||||||
|
/** Inputs to {@link useChatSession}. `activeChatId`/`setActiveChatId` are the
|
||||||
|
* public selection atom (also written from outside the window, e.g. page
|
||||||
|
* history); the rest is read-only context the hook needs. */
|
||||||
|
export interface UseChatSessionOptions {
|
||||||
|
activeChatId: string | null;
|
||||||
|
setActiveChatId: (id: string | null) => void;
|
||||||
|
chats: { items?: { id: string }[] } | undefined;
|
||||||
|
messagesLoading: boolean;
|
||||||
|
/** Wraps queryClient.invalidateQueries(AI_CHATS_RQ_KEY). */
|
||||||
|
onInvalidateChatList: () => void;
|
||||||
|
/** Wraps the per-chat messages invalidation. */
|
||||||
|
onInvalidateChatMessages: (chatId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the window needs from a chat session: the ChatThread mount key, the
|
||||||
|
* history-loader gate, and the turn-finished callback. */
|
||||||
|
export interface UseChatSessionResult {
|
||||||
|
/** ChatThread mount key (was `thread.key`). */
|
||||||
|
threadKey: string;
|
||||||
|
/** Show the history loader instead of the live thread. */
|
||||||
|
waitingForHistory: boolean;
|
||||||
|
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
||||||
|
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
|
||||||
|
onTurnFinished: (serverChatId?: string) => void;
|
||||||
|
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
||||||
|
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
||||||
|
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
||||||
|
* button, #174) available immediately. In-place adoption only (same mount key,
|
||||||
|
* no list/messages invalidation — that is left to onTurnFinished at the end).
|
||||||
|
* Idempotent and a no-op once the chat already has an id. */
|
||||||
|
onServerChatId: (serverChatId?: string) => void;
|
||||||
|
/** Disarm any pending error-path new-chat fallback. The window calls this from
|
||||||
|
* startNewChat/selectChat so a late refetch can't yank the user back into a
|
||||||
|
* just-failed chat after they explicitly moved on. */
|
||||||
|
cancelPendingAdoption: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a chat list to its id array (the before/after snapshot for the
|
||||||
|
* error-path fallback). */
|
||||||
|
function chatIdSnapshot(
|
||||||
|
chats: { items?: { id: string }[] } | undefined,
|
||||||
|
): string[] {
|
||||||
|
return chats?.items?.map((c) => c.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the AI-chat thread-identity lifecycle: the single atomic thread identity,
|
||||||
|
* both new-chat id adoption paths (primary streamed-metadata + bounded error-path
|
||||||
|
* fallback), the history-loaded latch, and the render-phase reconciler that keeps
|
||||||
|
* the thread's mount key in sync with the public `activeChatId` atom.
|
||||||
|
*
|
||||||
|
* This is the twice-bugged area for the #137 two-tab adoption race; the canonical
|
||||||
|
* explanation of the adoption design lives in adopt-chat-id.ts.
|
||||||
|
*/
|
||||||
|
export function useChatSession(
|
||||||
|
params: UseChatSessionOptions,
|
||||||
|
): UseChatSessionResult {
|
||||||
|
const {
|
||||||
|
activeChatId,
|
||||||
|
setActiveChatId,
|
||||||
|
chats,
|
||||||
|
messagesLoading,
|
||||||
|
onInvalidateChatList,
|
||||||
|
onInvalidateChatMessages,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// Live mirror of `activeChatId`, read by onTurnFinished. ai@6 fires both
|
||||||
|
// onFinish AND onError on a failed turn, so onTurnFinished can run twice in one
|
||||||
|
// turn (once with the streamed id, once without) BEFORE a re-render. Reading
|
||||||
|
// the ref — which the primary-adoption branch updates imperatively — makes that
|
||||||
|
// second call see the just-adopted id, so it cannot re-arm the fallback. (A
|
||||||
|
// plain closure over `activeChatId` would still read null on the second call.)
|
||||||
|
const activeChatIdRef = useRef(activeChatId);
|
||||||
|
activeChatIdRef.current = activeChatId;
|
||||||
|
|
||||||
|
// The mounted thread's identity: ONE atomic value tying ChatThread's mount key
|
||||||
|
// (`thread.key`) to the chat id that mounted thread holds (`thread.chatId`).
|
||||||
|
// Consolidating these makes the "key vs chat id diverged" state unrepresentable
|
||||||
|
// — every change goes through an explicit transition (see thread-identity.ts):
|
||||||
|
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
|
||||||
|
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
|
||||||
|
// session key with no chat id yet.
|
||||||
|
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
|
||||||
|
activeChatId === null
|
||||||
|
? newThread(`new-${generateId()}`)
|
||||||
|
: switchThread(activeChatId),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
||||||
|
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
||||||
|
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
||||||
|
// this ref with a snapshot of the currently-known chat ids; once the list
|
||||||
|
// refetch lands with the just-created row, the fallback effect below adopts the
|
||||||
|
// SINGLE newly-appeared id. `null` = not armed. See adopt-chat-id.ts (#137).
|
||||||
|
const pendingNewChatRef = useRef<string[] | null>(null);
|
||||||
|
|
||||||
|
// Latch: the chat id whose full persisted history has finished loading while
|
||||||
|
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
|
||||||
|
// messages invalidation) never tears the live thread back down to the loader.
|
||||||
|
const historyLoadedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
||||||
|
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
||||||
|
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
||||||
|
const onTurnFinished = useCallback(
|
||||||
|
(serverChatId?: string) => {
|
||||||
|
// Read the live id from the ref, not the closure: on a failed turn this can
|
||||||
|
// run twice in one turn (onFinish + onError) before any re-render, and the
|
||||||
|
// primary branch below updates the ref so the second call sees the adopted id.
|
||||||
|
const current = activeChatIdRef.current;
|
||||||
|
const adopted = resolveAdoptedChatId(current, serverChatId);
|
||||||
|
if (adopted) {
|
||||||
|
// PRIMARY path. In-place adoption: set the public selection and the
|
||||||
|
// thread identity to the real id together. `adopt` keeps the SAME mount
|
||||||
|
// key, so the render-phase reconciler sees `activeChatId === thread.chatId`
|
||||||
|
// and keeps the SAME mounted thread (its useChat already holds the
|
||||||
|
// just-finished turn) instead of remounting + re-seeding from
|
||||||
|
// not-yet-persisted history.
|
||||||
|
activeChatIdRef.current = adopted; // a same-turn 2nd call now sees the id
|
||||||
|
setActiveChatId(adopted);
|
||||||
|
dispatch({ type: "adopt", chatId: adopted });
|
||||||
|
// Primary adoption won — disarm any previously-armed fallback.
|
||||||
|
pendingNewChatRef.current = null;
|
||||||
|
} else if (current === null) {
|
||||||
|
// FALLBACK path: a brand-new chat finished with NO server id (the first
|
||||||
|
// turn errored before the `start` chunk). Arm the bounded list-refetch
|
||||||
|
// fallback by snapshotting the currently-known chat ids. `chats` is still
|
||||||
|
// the pre-refetch list here, so the just-created row is NOT yet in it; the
|
||||||
|
// effect below adopts the single id that newly appears after the refetch.
|
||||||
|
pendingNewChatRef.current = chatIdSnapshot(chats);
|
||||||
|
}
|
||||||
|
onInvalidateChatList();
|
||||||
|
// Re-sync the persisted message rows for the active chat so the Markdown
|
||||||
|
// export and token counters reflect the just-finished turn. The live thread
|
||||||
|
// renders from its own useChat store (stable thread.key), so this never
|
||||||
|
// re-seeds or tears down the open thread. For a brand-new chat `current` is
|
||||||
|
// still null here; later turns hit this with the adopted id.
|
||||||
|
if (current) {
|
||||||
|
onInvalidateChatMessages(current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
|
||||||
|
// the server emits it on the `start` chunk, so a brand-new chat gets its real
|
||||||
|
// `activeChatId` WHILE its first turn streams — not only at terminal
|
||||||
|
// onTurnFinished. This makes the activeChatId-gated Copy/export button
|
||||||
|
// available during the first turn. Pure in-place adoption (same mount key, like
|
||||||
|
// the primary path) with NO invalidation: the list/messages refresh stays on
|
||||||
|
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
|
||||||
|
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
|
||||||
|
// still-new chat).
|
||||||
|
const onServerChatId = useCallback(
|
||||||
|
(serverChatId?: string) => {
|
||||||
|
const adopted = resolveAdoptedChatId(
|
||||||
|
activeChatIdRef.current,
|
||||||
|
serverChatId,
|
||||||
|
);
|
||||||
|
if (!adopted) return;
|
||||||
|
activeChatIdRef.current = adopted;
|
||||||
|
setActiveChatId(adopted);
|
||||||
|
dispatch({ type: "adopt", chatId: adopted });
|
||||||
|
// Early adoption beat the error-path fallback to it — disarm.
|
||||||
|
pendingNewChatRef.current = null;
|
||||||
|
},
|
||||||
|
[setActiveChatId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
|
||||||
|
// turn errored before the `start` chunk (no authoritative id streamed). Once
|
||||||
|
// the per-user list refetch lands with the just-created row, adopt the SINGLE
|
||||||
|
// id that newly appeared relative to the pre-refetch snapshot. Adoption is IN
|
||||||
|
// PLACE (set activeChatId + `adopt` together) like the primary path, so the
|
||||||
|
// render-phase reconciler does not remount.
|
||||||
|
useEffect(() => {
|
||||||
|
const before = pendingNewChatRef.current;
|
||||||
|
if (before === null || activeChatId !== null) return; // not armed / already adopted
|
||||||
|
const after = chatIdSnapshot(chats);
|
||||||
|
const added = newlyAddedChatIds(before, after);
|
||||||
|
// Keep waiting until a genuinely-new id appears. Set-based, so it is robust
|
||||||
|
// to an add+delete in the same window (a length compare would miss it), and
|
||||||
|
// it deliberately keeps waiting through an unrelated deletion (no new id yet)
|
||||||
|
// until the just-created row actually lands, rather than giving up early.
|
||||||
|
if (added.size === 0) return; // list not refetched yet — keep waiting
|
||||||
|
pendingNewChatRef.current = null; // resolved — disarm
|
||||||
|
if (added.size === 1) {
|
||||||
|
// single unambiguous new id; >1 = ambiguous → give up
|
||||||
|
const adopted = [...added][0];
|
||||||
|
setActiveChatId(adopted);
|
||||||
|
dispatch({ type: "adopt", chatId: adopted });
|
||||||
|
}
|
||||||
|
}, [chats, activeChatId, setActiveChatId]);
|
||||||
|
|
||||||
|
// Reconcile the thread identity against the active-chat atom during render when
|
||||||
|
// they diverge — the React-sanctioned alternative to an effect (re-renders
|
||||||
|
// before paint, no extra commit, and converges since the next render finds them
|
||||||
|
// equal). This reconciliation MUST remain: `activeChatId` is the public
|
||||||
|
// selection and is ALSO set from OUTSIDE this component (e.g. page-history opens
|
||||||
|
// a referenced chat via setActiveChatId). A divergence here is a genuine SWITCH
|
||||||
|
// (external atom change OR user switch via selectChat/startNewChat), so
|
||||||
|
// `reconcile` remounts + reseeds. In-place adoption never reaches this branch:
|
||||||
|
// it set activeChatId and thread.chatId to the same value.
|
||||||
|
if (activeChatId !== thread.chatId) {
|
||||||
|
// A genuine switch makes any pending error-path new-chat fallback moot.
|
||||||
|
pendingNewChatRef.current = null;
|
||||||
|
dispatch({
|
||||||
|
type: "reconcile",
|
||||||
|
chatId: activeChatId,
|
||||||
|
newKey: `new-${generateId()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latch the active chat once its full history has loaded and its thread is
|
||||||
|
// mounted, so a later background refetch (the post-turn messages invalidation,
|
||||||
|
// which can transiently flip hasNextPage for a chat whose message count is an
|
||||||
|
// exact multiple of the server page size) does not tear the live thread down to
|
||||||
|
// a loader and lose its in-progress useChat state.
|
||||||
|
if (
|
||||||
|
activeChatId !== null &&
|
||||||
|
thread.key === activeChatId &&
|
||||||
|
!messagesLoading &&
|
||||||
|
historyLoadedKeyRef.current !== activeChatId
|
||||||
|
) {
|
||||||
|
historyLoadedKeyRef.current = activeChatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the history loader only when freshly OPENING an existing chat (the key
|
||||||
|
// equals the chat id) whose history has not been fully loaded yet. For a live
|
||||||
|
// in-place thread that adopted its id, the key is still the "new-…" session
|
||||||
|
// key, so the live thread keeps rendering; and once a chat's history has loaded,
|
||||||
|
// a later background refetch no longer tears it down (see the latch above).
|
||||||
|
const waitingForHistory =
|
||||||
|
activeChatId !== null &&
|
||||||
|
messagesLoading &&
|
||||||
|
thread.key === activeChatId &&
|
||||||
|
historyLoadedKeyRef.current !== activeChatId;
|
||||||
|
|
||||||
|
// Explicit disarm for startNewChat/selectChat. The render-phase reconciler only
|
||||||
|
// disarms when activeChatId actually changes, but "New chat" pressed while the
|
||||||
|
// user is ALREADY in a new chat is a no-op for the atom (activeChatId stays
|
||||||
|
// null), so the reconciler never fires — without this an armed fallback could
|
||||||
|
// adopt the just-failed chat from a late refetch and yank the user out of their
|
||||||
|
// fresh chat. Stable identity (writes a ref).
|
||||||
|
const cancelPendingAdoption = useCallback(() => {
|
||||||
|
pendingNewChatRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadKey: thread.key,
|
||||||
|
waitingForHistory,
|
||||||
|
onTurnFinished,
|
||||||
|
onServerChatId,
|
||||||
|
cancelPendingAdoption,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,6 +50,24 @@ export async function deleteAiChat(chatId: string): Promise<void> {
|
|||||||
await api.post("/ai-chat/delete", { chatId });
|
await api.post("/ai-chat/delete", { chatId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a chat to Markdown (#183). The server renders the transcript from the
|
||||||
|
* persisted rows (the DB is the single source of truth — including an
|
||||||
|
* interrupted turn's in-progress row, persisted upfront + per step), so the
|
||||||
|
* client just copies the returned string. `lang` localizes the few fixed
|
||||||
|
* role/tool labels; defaults to English server-side when omitted.
|
||||||
|
*/
|
||||||
|
export async function exportAiChat(
|
||||||
|
chatId: string,
|
||||||
|
lang?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
|
||||||
|
chatId,
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
return req.data.markdown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||||
@@ -76,6 +94,8 @@ export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
|||||||
|
|
||||||
/** Soft-delete a role (admin). */
|
/** Soft-delete a role (admin). */
|
||||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
|
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
|
||||||
|
id,
|
||||||
|
});
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export interface IAiRole {
|
|||||||
instructions?: string;
|
instructions?: string;
|
||||||
modelConfig?: IAiRoleModelConfig | null;
|
modelConfig?: IAiRoleModelConfig | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
// Whether picking the role auto-sends a launch message and starts the chat.
|
||||||
|
autoStart: boolean;
|
||||||
|
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||||
|
launchMessage: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
|
|||||||
instructions: string;
|
instructions: string;
|
||||||
modelConfig?: IAiRoleModelConfig | null;
|
modelConfig?: IAiRoleModelConfig | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
autoStart?: boolean;
|
||||||
|
launchMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin update payload for a role (partial). */
|
/** Admin update payload for a role (partial). */
|
||||||
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
|
|||||||
instructions?: string;
|
instructions?: string;
|
||||||
modelConfig?: IAiRoleModelConfig | null;
|
modelConfig?: IAiRoleModelConfig | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
autoStart?: boolean;
|
||||||
|
launchMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +106,10 @@ export interface IAiChatMessageRow {
|
|||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
|
// Reasoning (thinking) tokens, when the provider reports them. Optional so
|
||||||
|
// old history rows (recorded before this shipped) stay valid. Included in
|
||||||
|
// `outputTokens` per the AI SDK usage shape.
|
||||||
|
reasoningTokens?: number;
|
||||||
};
|
};
|
||||||
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
||||||
// how much the conversation occupies in the model's context window after this
|
// how much the conversation occupies in the model's context window after this
|
||||||
@@ -107,6 +119,11 @@ export interface IAiChatMessageRow {
|
|||||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// Terminal outcome of the assistant turn: 'error' (provider/stream error,
|
||||||
|
// paired with `error`), 'aborted' (client disconnect — a manual Stop or a
|
||||||
|
// dropped connection), or the SDK's finish reason on a clean turn. The UI
|
||||||
|
// renders a "stopped" marker on interrupted turns.
|
||||||
|
finishReason?: string;
|
||||||
} | null;
|
} | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveAdoptedChatId,
|
||||||
|
newlyAddedChatIds,
|
||||||
|
extractServerChatId,
|
||||||
|
} from "./adopt-chat-id";
|
||||||
|
|
||||||
|
describe("resolveAdoptedChatId", () => {
|
||||||
|
it("adopts the server id for a brand-new chat (activeChatId null + id)", () => {
|
||||||
|
expect(resolveAdoptedChatId(null, "chat-1")).toBe("chat-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an existing chat even with a server id", () => {
|
||||||
|
expect(resolveAdoptedChatId("chat-existing", "chat-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a new chat with no server id", () => {
|
||||||
|
expect(resolveAdoptedChatId(null, undefined)).toBeNull();
|
||||||
|
expect(resolveAdoptedChatId(null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("newlyAddedChatIds", () => {
|
||||||
|
it("returns the single new id", () => {
|
||||||
|
expect([...newlyAddedChatIds(["a", "b"], ["a", "b", "c"])]).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty set when nothing was added", () => {
|
||||||
|
expect(newlyAddedChatIds(["a", "b"], ["b", "a"]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns both new ids when two were added", () => {
|
||||||
|
expect(newlyAddedChatIds(["a"], ["a", "b", "c"])).toEqual(
|
||||||
|
new Set(["b", "c"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the new id across an add+delete in the same window", () => {
|
||||||
|
// before [a,b] -> after [b,new]: a was deleted, new was added.
|
||||||
|
expect([...newlyAddedChatIds(["a", "b"], ["b", "new"])]).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes a repeated new id to a single entry", () => {
|
||||||
|
expect(newlyAddedChatIds(["a"], ["a", "new", "new"])).toEqual(
|
||||||
|
new Set(["new"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractServerChatId", () => {
|
||||||
|
it("returns the chatId when present on metadata", () => {
|
||||||
|
expect(extractServerChatId({ metadata: { chatId: "chat-1" } })).toBe(
|
||||||
|
"chat-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the message has no metadata", () => {
|
||||||
|
expect(extractServerChatId({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when metadata lacks chatId", () => {
|
||||||
|
expect(extractServerChatId({ metadata: { other: 1 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a non-string chatId", () => {
|
||||||
|
expect(extractServerChatId({ metadata: { chatId: 42 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an undefined message", () => {
|
||||||
|
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/client/src/features/ai-chat/utils/adopt-chat-id.ts
Normal file
70
apps/client/src/features/ai-chat/utils/adopt-chat-id.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for adopting a brand-new chat's authoritative server id.
|
||||||
|
*
|
||||||
|
* ============================ CANONICAL #137 NOTE ============================
|
||||||
|
* This docblock is the single authoritative explanation of the new-chat id
|
||||||
|
* adoption design and the #137 two-tab race it fixes. Other call sites
|
||||||
|
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
|
||||||
|
* rather than restating it.
|
||||||
|
*
|
||||||
|
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
|
||||||
|
* id yet (`activeChatId === null`). The server creates the row and the client
|
||||||
|
* must "adopt" that row's real id so the SECOND turn targets the same chat.
|
||||||
|
*
|
||||||
|
* The OLD heuristic adopted `items[0]` — the newest chat in the refetched list.
|
||||||
|
* That races a second tab: if another tab created a chat in the same moment,
|
||||||
|
* its row could be `items[0]`, so this tab would adopt the SIBLING chat and
|
||||||
|
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
|
||||||
|
*
|
||||||
|
* PRIMARY path: the server streams the real chat id on the assistant message
|
||||||
|
* metadata's `start` part (see `chatStreamMetadata` server-side);
|
||||||
|
* `extractServerChatId` reads it off the finished message and
|
||||||
|
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
|
||||||
|
* authoritative and immune to the race.
|
||||||
|
*
|
||||||
|
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
|
||||||
|
* chunk, so no metadata id ever reached the client): adopt the single chat that
|
||||||
|
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
|
||||||
|
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
|
||||||
|
* new). This is unambiguous and does not race a second tab the way the old
|
||||||
|
* "newest chat in the list" guess did.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the id to adopt from the server-streamed metadata. Returns
|
||||||
|
* `serverChatId` only for a brand-new chat (`activeChatId === null`) that
|
||||||
|
* received a truthy id; otherwise null (existing chat, or no id streamed).
|
||||||
|
*/
|
||||||
|
export function resolveAdoptedChatId(
|
||||||
|
activeChatId: string | null,
|
||||||
|
serverChatId: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
return activeChatId === null && serverChatId ? serverChatId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the authoritative server chat id off a finished assistant message. The
|
||||||
|
* server attaches it as `message.metadata.chatId` on the `start` part (see
|
||||||
|
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
|
||||||
|
* a missing message, missing metadata, or a non-string `chatId`.
|
||||||
|
*/
|
||||||
|
export function extractServerChatId(
|
||||||
|
message: { metadata?: unknown } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const m = message?.metadata as { chatId?: string } | undefined;
|
||||||
|
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||||
|
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||||
|
* chat must not read as multiple from a duplicate.
|
||||||
|
*/
|
||||||
|
export function newlyAddedChatIds(
|
||||||
|
beforeIds: readonly string[],
|
||||||
|
afterIds: readonly string[],
|
||||||
|
): Set<string> {
|
||||||
|
const before = new Set(beforeIds);
|
||||||
|
return new Set(afterIds.filter((id) => !before.has(id)));
|
||||||
|
}
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for the client-only Markdown export builder. The output embeds a live
|
|
||||||
* `new Date().toISOString()` export timestamp; we never assert that value, only
|
|
||||||
* the deterministic structure (headings, numbering, fenced blocks, totals).
|
|
||||||
*
|
|
||||||
* A pass-through translator keeps role/tool labels predictable so the
|
|
||||||
* structural assertions are stable without an i18n runtime.
|
|
||||||
*/
|
|
||||||
const t = (key: string, values?: Record<string, unknown>): string => {
|
|
||||||
if (values && typeof values.name === "string") {
|
|
||||||
return key.replace("{{name}}", values.name);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
|
|
||||||
return {
|
|
||||||
id: partial.id ?? "id",
|
|
||||||
role: partial.role ?? "user",
|
|
||||||
content: partial.content ?? null,
|
|
||||||
metadata: partial.metadata ?? null,
|
|
||||||
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildChatMarkdown — structure", () => {
|
|
||||||
it("emits the title heading, chat id and message count", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "My chat",
|
|
||||||
chatId: "chat-123",
|
|
||||||
rows: [],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("# My chat");
|
|
||||||
expect(md).toContain("- Chat ID: `chat-123`");
|
|
||||||
expect(md).toContain("- Messages: 0");
|
|
||||||
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
|
|
||||||
expect(
|
|
||||||
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
|
|
||||||
).toContain("# Untitled chat");
|
|
||||||
expect(
|
|
||||||
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
|
|
||||||
).toContain("# Untitled chat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("numbers rows sequentially with role headings", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({ role: "user", content: "hi" }),
|
|
||||||
row({ role: "assistant", content: "hello" }),
|
|
||||||
row({ role: "user", content: "again" }),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("## 1. You");
|
|
||||||
expect(md).toContain("## 2. AI agent");
|
|
||||||
expect(md).toContain("## 3. You");
|
|
||||||
// Heading numbering is strictly index+1, not e.g. role-relative.
|
|
||||||
expect(md).not.toContain("## 0.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the per-row text content from `content` when no metadata.parts", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [row({ role: "user", content: "plain body" })],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("plain body");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildChatMarkdown — text parts", () => {
|
|
||||||
it("skips empty / whitespace-only text parts", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "ignored-content",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: " " },
|
|
||||||
{ type: "text", text: "" },
|
|
||||||
{ type: "text", text: "kept line" },
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
] as any,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("kept line");
|
|
||||||
// Whitespace-only part contributed no block of its own.
|
|
||||||
expect(md).not.toContain(" \n\n");
|
|
||||||
// When metadata.parts exists, the plain `content` fallback is NOT used.
|
|
||||||
expect(md).not.toContain("ignored-content");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildChatMarkdown — tool parts", () => {
|
|
||||||
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "tool-getPage",
|
|
||||||
state: "output-available",
|
|
||||||
input: { pageId: "p1" },
|
|
||||||
output: { id: "p1", title: "Home" },
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
// Known tool name maps to its label key; raw name in backticks; done state.
|
|
||||||
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
|
|
||||||
expect(md).toContain("Input:");
|
|
||||||
expect(md).toContain("Output:");
|
|
||||||
// Fenced JSON blocks contain the stringified payloads.
|
|
||||||
expect(md).toContain('"pageId": "p1"');
|
|
||||||
expect(md).toContain('"title": "Home"');
|
|
||||||
expect(md).toContain("```json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the generic label for an unknown tool and surfaces errorText", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "tool-mysteryTool",
|
|
||||||
state: "output-error",
|
|
||||||
input: { a: 1 },
|
|
||||||
errorText: "boom",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
|
|
||||||
expect(md).toContain("**Error:** boom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw on a circular tool input (falls back to String)", () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const circular: any = {};
|
|
||||||
circular.self = circular;
|
|
||||||
expect(() =>
|
|
||||||
buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "tool-getPage",
|
|
||||||
state: "input-available",
|
|
||||||
input: circular,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
}),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildChatMarkdown — fence anti-breakout", () => {
|
|
||||||
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
|
|
||||||
// Tool input whose stringified string form contains a literal ``` run.
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "tool-getPage",
|
|
||||||
state: "output-available",
|
|
||||||
// A bare string passes through stringify() verbatim.
|
|
||||||
input: "before ``` after",
|
|
||||||
output: "x",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
// The fence around the 3-backtick content must use at least 4 backticks so
|
|
||||||
// the embedded ``` run cannot terminate the block.
|
|
||||||
expect(md).toContain("````json\nbefore ``` after\n````");
|
|
||||||
// Robust anti-breakout check: the opening fence delimiter is strictly
|
|
||||||
// longer than the longest backtick run inside the wrapped content. (A naive
|
|
||||||
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
|
|
||||||
// textually contains the 3-backtick substring.)
|
|
||||||
const open = md.match(/(`{3,})json\nbefore/);
|
|
||||||
expect(open).not.toBeNull();
|
|
||||||
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
metadata: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "tool-getPage",
|
|
||||||
state: "output-available",
|
|
||||||
input: "a ```` b",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("`````json\na ```` b\n`````");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildChatMarkdown — token totals", () => {
|
|
||||||
it("prints the total-tokens line only when the summed usage is > 0", () => {
|
|
||||||
const withTokens = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "x",
|
|
||||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(withTokens).toContain("- Total tokens: 15");
|
|
||||||
// Per-row usage footer too.
|
|
||||||
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
|
|
||||||
const noTokens = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({ role: "user", content: "hi" }),
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "x",
|
|
||||||
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(noTokens).not.toContain("- Total tokens:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses totalTokens when present rather than summing in/out", () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: "assistant",
|
|
||||||
content: "x",
|
|
||||||
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
expect(md).toContain("- Total tokens: 99");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-only Markdown builder for an AI agent chat. Serializes the already
|
|
||||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
|
||||||
* Markdown string suitable for copying to the clipboard. NO network call is
|
|
||||||
* made and NO server/DB code is touched — this reuses the rich "request
|
|
||||||
* internals" (tool calls with input/output, per-message token usage,
|
|
||||||
* finish/error info) that the chat already holds client-side.
|
|
||||||
*
|
|
||||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
|
||||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
|
||||||
* plain English constants because the output is a technical artifact.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
import {
|
|
||||||
ToolUiPart,
|
|
||||||
getToolName,
|
|
||||||
toolRunState,
|
|
||||||
toolLabelKey,
|
|
||||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
|
||||||
|
|
||||||
// Minimal translator signature compatible with react-i18next's `t`.
|
|
||||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|
||||||
|
|
||||||
interface BuildChatMarkdownArgs {
|
|
||||||
title: string | null;
|
|
||||||
chatId: string;
|
|
||||||
rows: IAiChatMessageRow[];
|
|
||||||
t: Translate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single AI SDK UIMessage part (text part or other). */
|
|
||||||
interface TextLikePart {
|
|
||||||
type: string;
|
|
||||||
text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
|
||||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
|
||||||
* `String(value)` if serialization throws (e.g. a circular structure).
|
|
||||||
*/
|
|
||||||
function stringify(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
|
|
||||||
* the longest backtick run inside the content, so embedded backticks (or even
|
|
||||||
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
|
|
||||||
*/
|
|
||||||
function fence(code: string, lang = ""): string {
|
|
||||||
const runs: string[] = code.match(/`+/g) ?? [];
|
|
||||||
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
|
|
||||||
const delim = "`".repeat(Math.max(3, longest + 1));
|
|
||||||
return `${delim}${lang}\n${code}\n${delim}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
|
|
||||||
function rowTokens(usage: {
|
|
||||||
inputTokens?: number;
|
|
||||||
outputTokens?: number;
|
|
||||||
totalTokens?: number;
|
|
||||||
}): number {
|
|
||||||
return (
|
|
||||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
|
||||||
* export timestamp), so it is straightforward to unit-test.
|
|
||||||
*/
|
|
||||||
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
|
||||||
const { title, chatId, rows, t } = args;
|
|
||||||
const blocks: string[] = [];
|
|
||||||
|
|
||||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
|
||||||
blocks.push(`# ${heading}`);
|
|
||||||
|
|
||||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
|
||||||
const totalTokens = rows.reduce((sum, row) => {
|
|
||||||
const usage = row.metadata?.usage;
|
|
||||||
return usage ? sum + rowTokens(usage) : sum;
|
|
||||||
}, 0);
|
|
||||||
const meta = [
|
|
||||||
`- Chat ID: \`${chatId}\``,
|
|
||||||
`- Exported: ${new Date().toISOString()}`,
|
|
||||||
`- Messages: ${rows.length}`,
|
|
||||||
];
|
|
||||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
|
||||||
blocks.push(meta.join("\n"));
|
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
|
||||||
blocks.push("---");
|
|
||||||
|
|
||||||
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
|
|
||||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
|
||||||
|
|
||||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
|
||||||
blocks.push(`<!-- ${row.createdAt} -->`);
|
|
||||||
|
|
||||||
// Resolve parts: prefer the rich persisted parts, else a single text part
|
|
||||||
// built from the plain-text content (mirrors `rowToUiMessage`).
|
|
||||||
const parts: TextLikePart[] =
|
|
||||||
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
|
||||||
? (row.metadata.parts as TextLikePart[])
|
|
||||||
: [{ type: "text", text: row.content ?? "" }];
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === "text") {
|
|
||||||
const text = (part.text ?? "").trim();
|
|
||||||
// Skip empty/whitespace-only text parts (matches MessageItem).
|
|
||||||
if (text.length > 0) blocks.push(text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isToolPart =
|
|
||||||
part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
|
||||||
if (!isToolPart) continue;
|
|
||||||
|
|
||||||
const tp = part as unknown as ToolUiPart;
|
|
||||||
const name = getToolName(tp);
|
|
||||||
const { key, values } = toolLabelKey(name);
|
|
||||||
const label = t(key, values);
|
|
||||||
const state = toolRunState(tp.state);
|
|
||||||
|
|
||||||
const toolLines: string[] = [
|
|
||||||
`**Tool: ${label}** (\`${name}\`) — ${state}`,
|
|
||||||
];
|
|
||||||
if (tp.input !== undefined) {
|
|
||||||
toolLines.push("Input:");
|
|
||||||
toolLines.push(fence(stringify(tp.input), "json"));
|
|
||||||
}
|
|
||||||
if (tp.output !== undefined) {
|
|
||||||
toolLines.push("Output:");
|
|
||||||
toolLines.push(fence(stringify(tp.output), "json"));
|
|
||||||
}
|
|
||||||
if (tp.errorText) {
|
|
||||||
toolLines.push(`**Error:** ${tp.errorText}`);
|
|
||||||
}
|
|
||||||
blocks.push(toolLines.join("\n\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.metadata?.error) {
|
|
||||||
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = row.metadata?.usage;
|
|
||||||
if (usage) {
|
|
||||||
const total = usage.totalTokens ?? rowTokens(usage);
|
|
||||||
blocks.push(
|
|
||||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Blank line between blocks so the Markdown renders cleanly.
|
|
||||||
return blocks.join("\n\n");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
|
||||||
|
describe("collapseBlankLines", () => {
|
||||||
|
it("collapses a run of 2+ newlines to a single newline", () => {
|
||||||
|
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
|
||||||
|
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps single newlines untouched", () => {
|
||||||
|
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blank lines INSIDE a fenced code block", () => {
|
||||||
|
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
|
||||||
|
// Prose blanks collapse; the blank lines between the ``` fences survive.
|
||||||
|
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a tilde fence and preserves its interior blanks", () => {
|
||||||
|
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
|
||||||
|
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unclosed fence's remaining lines verbatim", () => {
|
||||||
|
const src = "intro\n\n```\nstill\n\nopen";
|
||||||
|
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for text with no blank lines", () => {
|
||||||
|
expect(collapseBlankLines("just one line")).toBe("just one line");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
||||||
|
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
||||||
|
const loose =
|
||||||
|
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||||
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
|
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
||||||
|
expect(html).toContain("<li>item one</li>");
|
||||||
|
expect(html).not.toContain("<li><p>");
|
||||||
|
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
||||||
|
expect(html).toContain("<ul>");
|
||||||
|
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
||||||
|
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||||
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
|
expect(html).toContain("<ol>");
|
||||||
|
expect(html).toContain("<li>first</li>");
|
||||||
|
expect(html).not.toContain("<li><p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
||||||
|
const loose = "- a\n\n- b";
|
||||||
|
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
|
||||||
|
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
|
||||||
|
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
|
||||||
|
*
|
||||||
|
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
|
||||||
|
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
|
||||||
|
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
|
||||||
|
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
|
||||||
|
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
|
||||||
|
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
|
||||||
|
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
|
||||||
|
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
|
||||||
|
* normal assistant answer (where paragraph spacing is intentional).
|
||||||
|
*
|
||||||
|
* Fenced code is preserved verbatim: a fence opens on a line whose first
|
||||||
|
* non-space characters are ``` or ~~~ and closes on the next line that starts
|
||||||
|
* with the same fence character. Blank lines between fences (significant for
|
||||||
|
* code formatting) are never collapsed.
|
||||||
|
*/
|
||||||
|
export function collapseBlankLines(text: string): string {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const out: string[] = [];
|
||||||
|
let inFence = false;
|
||||||
|
let fenceChar = "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const ch = fenceMatch[1][0];
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
fenceChar = ch;
|
||||||
|
} else if (ch === fenceChar) {
|
||||||
|
inFence = false;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a fenced block every line (including blanks) is significant.
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
|
||||||
|
// `\n` between the surrounding content lines.
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import {
|
||||||
|
estimateTokens,
|
||||||
|
liveTurnTokens,
|
||||||
|
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||||
|
|
||||||
|
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||||
|
({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("estimateTokens", () => {
|
||||||
|
it("returns 0 for the empty string", () => {
|
||||||
|
expect(estimateTokens("")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||||
|
expect(estimateTokens("a")).toBe(1);
|
||||||
|
expect(estimateTokens("abcd")).toBe(1);
|
||||||
|
expect(estimateTokens("abcde")).toBe(2);
|
||||||
|
expect(estimateTokens("12345678")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liveTurnTokens — estimate path", () => {
|
||||||
|
it("is all zeros for an undefined message", () => {
|
||||||
|
expect(liveTurnTokens(undefined)).toEqual({
|
||||||
|
reasoning: 0,
|
||||||
|
output: 0,
|
||||||
|
authoritative: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is all zeros for a parts-less message", () => {
|
||||||
|
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||||
|
reasoning: 0,
|
||||||
|
output: 0,
|
||||||
|
authoritative: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("estimates output from text parts", () => {
|
||||||
|
// 8 chars -> 2 tokens.
|
||||||
|
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||||
|
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([
|
||||||
|
{ type: "reasoning", text: "12345678" },
|
||||||
|
{ type: "text", text: "abcd" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([
|
||||||
|
{ type: "reasoning", text: "abcd" }, // 1
|
||||||
|
{ type: "text", text: "abcd" }, // 1
|
||||||
|
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||||
|
{ type: "reasoning", text: "abcd" }, // 1
|
||||||
|
{ type: "text", text: "abcdefgh" }, // 2
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([
|
||||||
|
{ type: "step-start" },
|
||||||
|
{ type: "tool-getPage", state: "input-available" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liveTurnTokens — authoritative path", () => {
|
||||||
|
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||||
|
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||||
|
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: "x" }], {
|
||||||
|
usage: { inputTokens: 10, outputTokens: 42 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the estimate when metadata has no usage object", () => {
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
|
||||||
|
it("ticks the in-flight step above the completed-steps authoritative base", () => {
|
||||||
|
// The authoritative usage is the sum over COMPLETED steps (step 1). The
|
||||||
|
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
|
||||||
|
// the parts -> the running estimate must push the live figure above the base
|
||||||
|
// so the badge keeps growing between step boundaries.
|
||||||
|
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: longText }], {
|
||||||
|
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
|
||||||
|
expect(r.output).toBe(200);
|
||||||
|
expect(r.authoritative).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
|
||||||
|
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "reasoning", text: longReasoning }], {
|
||||||
|
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
|
||||||
|
expect(r.reasoning).toBe(100);
|
||||||
|
expect(r.output).toBe(0);
|
||||||
|
expect(r.authoritative).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
|
||||||
|
// Short on-screen text (estimate tiny) but a large authoritative output:
|
||||||
|
// the exact figure wins at the boundary (the counter never under-reports).
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: "abcd" }], {
|
||||||
|
usage: { inputTokens: 10, outputTokens: 5000 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.output).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
|
||||||
|
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
|
||||||
|
const r = liveTurnTokens(
|
||||||
|
msg([{ type: "text", text: "tiny" }], {
|
||||||
|
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
113
apps/client/src/features/ai-chat/utils/count-stream-tokens.ts
Normal file
113
apps/client/src/features/ai-chat/utils/count-stream-tokens.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||||
|
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||||
|
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||||
|
*
|
||||||
|
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||||
|
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||||
|
* once the server attaches it on a step/turn boundary (see the server's
|
||||||
|
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||||
|
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||||
|
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
|
||||||
|
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||||
|
* bundle, and be wrong for Gemini/Ollama anyway).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||||
|
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||||
|
* non-empty text counts as at least one token.
|
||||||
|
*/
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||||
|
export interface AuthoritativeUsage {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
reasoningTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||||
|
export interface LiveTurnTokens {
|
||||||
|
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||||
|
reasoning: number;
|
||||||
|
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||||
|
output: number;
|
||||||
|
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||||
|
authoritative: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||||
|
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||||
|
const meta = message?.metadata as
|
||||||
|
| { usage?: AuthoritativeUsage }
|
||||||
|
| undefined;
|
||||||
|
const usage = meta?.usage;
|
||||||
|
if (!usage || typeof usage !== "object") return undefined;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token split for the given (streaming) assistant message.
|
||||||
|
*
|
||||||
|
* COMBINES the authoritative server usage with the running text estimate so the
|
||||||
|
* counter ticks in real time AND lands exact. The server only attaches
|
||||||
|
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
|
||||||
|
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
|
||||||
|
* So a multi-step turn that returned the authoritative figure verbatim would
|
||||||
|
* FREEZE between boundaries and jump in steps (issue #163).
|
||||||
|
*
|
||||||
|
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
|
||||||
|
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
|
||||||
|
* per-component MAX of the authoritative base and the estimate:
|
||||||
|
* - between boundaries the estimate of the in-flight step ticks the number up;
|
||||||
|
* - at a boundary the authoritative figure snaps it to exact;
|
||||||
|
* - because the server's usage is cumulative and we only ever take the max, the
|
||||||
|
* number is MONOTONIC — it never drops.
|
||||||
|
*
|
||||||
|
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||||
|
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
|
||||||
|
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
|
||||||
|
*/
|
||||||
|
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||||
|
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||||
|
|
||||||
|
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
|
||||||
|
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
|
||||||
|
let estReasoning = 0;
|
||||||
|
let estOutput = 0;
|
||||||
|
for (const part of message.parts ?? []) {
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||||
|
} else if (part.type === "text") {
|
||||||
|
estOutput += estimateTokens((part as { text?: string }).text ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = metadataUsage(message);
|
||||||
|
if (!usage) {
|
||||||
|
// No authoritative usage streamed yet: the estimate IS the live figure.
|
||||||
|
return { reasoning: estReasoning, output: estOutput, authoritative: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
|
||||||
|
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
|
||||||
|
// figure (never go negative if a provider reports them inconsistently).
|
||||||
|
const authReasoning = usage.reasoningTokens ?? 0;
|
||||||
|
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
|
||||||
|
|
||||||
|
// Per-component max: the in-flight step's estimate ticks above the completed-
|
||||||
|
// steps base between boundaries, and the authoritative figure wins once it
|
||||||
|
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
|
||||||
|
return {
|
||||||
|
reasoning: Math.max(authReasoning, estReasoning),
|
||||||
|
output: Math.max(authOutput, estOutput),
|
||||||
|
authoritative: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for `assistantMessageHasVisibleContent`, the single source of
|
||||||
|
* truth shared by MessageItem (whether to render the bubble) and
|
||||||
|
* typingIndicatorShowsName (whether the standalone indicator owns the name). It
|
||||||
|
* must mirror MessageItem's render decisions exactly so exactly one element owns
|
||||||
|
* the agent name during the pre-content "thinking" gap.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
metadata?: unknown,
|
||||||
|
): UIMessage =>
|
||||||
|
({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("assistantMessageHasVisibleContent", () => {
|
||||||
|
it("is false for an empty text part", () => {
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a whitespace-only text part", () => {
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: " " }]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for a non-empty text part", () => {
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "answer" }]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for a tool part", () => {
|
||||||
|
const toolPart = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([toolPart]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when metadata.error is set (persisted error banner)", () => {
|
||||||
|
expect(
|
||||||
|
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { error: "boom" })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when metadata.finishReason is 'aborted' (persisted stopped notice)", () => {
|
||||||
|
expect(
|
||||||
|
assistantMessageHasVisibleContent(msg([], { finishReason: "aborted" })),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a message with no parts and no metadata", () => {
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for an unsupported part kind (reasoning)", () => {
|
||||||
|
const reasoning = { type: "reasoning", text: "let me think" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([reasoning]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for a running tool part (input-available)", () => {
|
||||||
|
// Tool visibility does not depend on tool state: MessageItem renders a
|
||||||
|
// ToolCallCard for any tool part, so a still-running tool is visible.
|
||||||
|
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
|
||||||
|
expect(assistantMessageHasVisibleContent(msg([runningTool]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for an empty leading text part followed by a non-empty one", () => {
|
||||||
|
// An empty leading text part followed by a non-empty one is still visible
|
||||||
|
// (mirrors the real streaming sequence where text arrives incrementally).
|
||||||
|
expect(
|
||||||
|
assistantMessageHasVisibleContent(
|
||||||
|
msg([{ type: "text", text: "" }, { type: "text", text: "answer" }]),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for an empty completed turn (finishReason 'stop')", () => {
|
||||||
|
// A completed turn with no text/tools and a non-aborted finishReason renders
|
||||||
|
// nothing — this is intentional (hiding a dangling name-only row), distinct
|
||||||
|
// from the `aborted`/`error` cases which DO render.
|
||||||
|
expect(
|
||||||
|
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { finishReason: "stop" })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a parts-less message (the `?? []` guard makes it safe)", () => {
|
||||||
|
// The `?? []` guard makes a parts-less object safe instead of throwing.
|
||||||
|
expect(
|
||||||
|
assistantMessageHasVisibleContent({ id: "x", role: "assistant" } as unknown as UIMessage),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/client/src/features/ai-chat/utils/message-content.ts
Normal file
39
apps/client/src/features/ai-chat/utils/message-content.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an assistant `UIMessage` has anything visible to render in its bubble.
|
||||||
|
*
|
||||||
|
* This mirrors MessageItem's render decisions EXACTLY and is the single source of
|
||||||
|
* truth shared by both MessageItem (to decide whether to render the bubble at all)
|
||||||
|
* and typingIndicatorShowsName (to decide whether the standalone "Thinking…"
|
||||||
|
* indicator owns the dimmed agent-name label). Keeping one helper guarantees the
|
||||||
|
* two stay in lockstep, so exactly one element owns the name during the pre-content
|
||||||
|
* "thinking" gap and the layout never reflows mid-stream.
|
||||||
|
*
|
||||||
|
* An assistant message has visible content iff ANY of:
|
||||||
|
* - a `text` part whose trimmed length > 0 (non-empty markdown), OR
|
||||||
|
* - ANY tool part (`isToolPart(part.type)`), OR
|
||||||
|
* - `metadata.error` is truthy (a persisted error banner renders), OR
|
||||||
|
* - `metadata.finishReason === "aborted"` (a persisted "response stopped" notice).
|
||||||
|
* Empty/whitespace-only text parts and unsupported part kinds (reasoning, sources,
|
||||||
|
* files, step-start) are NOT visible.
|
||||||
|
*/
|
||||||
|
export function assistantMessageHasVisibleContent(message: UIMessage): boolean {
|
||||||
|
const meta = message.metadata as
|
||||||
|
| { error?: string; finishReason?: string }
|
||||||
|
| undefined;
|
||||||
|
// Persisted errored/aborted turns always render their banner/notice.
|
||||||
|
if (meta?.error) return true;
|
||||||
|
if (meta?.finishReason === "aborted") return true;
|
||||||
|
|
||||||
|
// `parts` may be empty (a nascent streaming message has no parts yet).
|
||||||
|
// `?? []` also guards a sparse/partial message object (metadata-only, no
|
||||||
|
// `parts`) so iterating cannot throw — it does not change behavior for any
|
||||||
|
// current input.
|
||||||
|
for (const part of message.parts ?? []) {
|
||||||
|
if (part.type === "text" && part.text.trim().length > 0) return true;
|
||||||
|
if (isToolPart(part.type)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for `messageSignature`, the cheap per-message content
|
||||||
|
* signature that drives MessageItem's memo (a streaming row's signature must
|
||||||
|
* change on every delta so it re-renders, while a finalized row's stays stable
|
||||||
|
* so it is skipped). Each test exercises ONE change signal and asserts it flips
|
||||||
|
* the signature; a content-identical clone must keep an EQUAL signature.
|
||||||
|
*
|
||||||
|
* The signature embeds `message.id` and `message.role`, so the `msg` factory
|
||||||
|
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
|
||||||
|
* identical content would get different signatures and the negative case would
|
||||||
|
* be impossible to express.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
metadata?: unknown,
|
||||||
|
): UIMessage =>
|
||||||
|
({
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("messageSignature", () => {
|
||||||
|
it("changes when a text part grows", () => {
|
||||||
|
const before = msg([{ type: "text", text: "alpha" }]);
|
||||||
|
const after = msg([{ type: "text", text: "alpha beta" }]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a new part is appended", () => {
|
||||||
|
const before = msg([{ type: "text", text: "alpha" }]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "text", text: "beta" },
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a part's state flips", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "input-streaming" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a tool part gains an output", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
output: { ok: true },
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a part gains an errorText", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-error" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "boom",
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
|
||||||
|
// The specifically-commented edge case: the authoritative turn total lands on
|
||||||
|
// the final finish-step AFTER the reasoning text length and state are frozen.
|
||||||
|
// Only the token count appears between these two snapshots, so the signature
|
||||||
|
// MUST still flip — otherwise the "Thinking · N tokens" header would never
|
||||||
|
// snap from the live estimate to the exact figure.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "thinking", state: "done" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg(
|
||||||
|
[{ type: "reasoning", text: "thinking", state: "done" } as never],
|
||||||
|
{ usage: { reasoningTokens: 42 } },
|
||||||
|
);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when metadata.error appears", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "stop",
|
||||||
|
});
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "aborted",
|
||||||
|
});
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
|
||||||
|
// A finalized row that is re-created as a fresh object (different parts array
|
||||||
|
// by reference, same parts by value) must keep an EQUAL signature, so the
|
||||||
|
// memo skips re-rendering it.
|
||||||
|
const a = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||||
|
]);
|
||||||
|
const b = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||||
|
]);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(messageSignature(a)).toBe(messageSignature(b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-part-kind coupling guard for the load-bearing invariant documented at the
|
||||||
|
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
|
||||||
|
* MessageItem render body draws, or the memo freezes a stale row. This is an
|
||||||
|
* executable lock for the part kinds rendered TODAY — read alongside
|
||||||
|
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
|
||||||
|
* helper (message-content.ts), which "mirrors MessageItem's render decisions
|
||||||
|
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
|
||||||
|
* signature. If a new visible field is rendered without being added here AND to
|
||||||
|
* the signature, the corresponding assertion below should fail — that is the
|
||||||
|
* guard. (This intentionally stops short of the render-descriptor refactor:
|
||||||
|
* adding a part kind or a visible field still requires a human to extend both
|
||||||
|
* the signature and this block.)
|
||||||
|
*/
|
||||||
|
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
|
||||||
|
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
|
||||||
|
it("flips when the visible text changes", () => {
|
||||||
|
// Streaming is append-only, so the visible text only grows; the signature
|
||||||
|
// samples its length, so the growth is the change signal.
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer extended" }]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
|
||||||
|
it("flips when the visible reasoning text changes", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "think", state: "streaming" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
|
||||||
|
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
|
||||||
|
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
|
||||||
|
// finish-step after text length and state are frozen.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "think", state: "done" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg(
|
||||||
|
[{ type: "reasoning", text: "think", state: "done" } as never],
|
||||||
|
{ usage: { reasoningTokens: 99 } },
|
||||||
|
);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
|
||||||
|
it("flips when the run state changes (running ↔ done icon + label)", () => {
|
||||||
|
// toolRunState(part.state) selects the spinner/check/error icon.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "input-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when output arrives (drives the rendered citation links)", () => {
|
||||||
|
// toolCitations reads part.output to render the "/p/{id}" anchors.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
output: { id: "page-1", title: "Doc" },
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when errorText appears (the visible red error detail line)", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-error" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "permission denied",
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata banners — render draws error / aborted notices", () => {
|
||||||
|
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "stop",
|
||||||
|
});
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "aborted",
|
||||||
|
});
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
/** Cheap content signature for one message: changes iff something VISIBLE in the
|
||||||
|
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
|
||||||
|
* appended, a tool/text part flips state once), so a per-part [type, text
|
||||||
|
* length, state, error/output presence] tuple + the persisted metadata
|
||||||
|
* (error/finishReason) is a sufficient change signal without comparing full
|
||||||
|
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
|
||||||
|
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
|
||||||
|
* text length, state, error/output presence] (e.g. a tool that streams
|
||||||
|
* `preliminary` output, or a client-side regenerate that edits a finalized
|
||||||
|
* row in place), extend this signature or the memo will freeze a stale row. */
|
||||||
|
export function messageSignature(message: UIMessage): string {
|
||||||
|
const parts = message.parts
|
||||||
|
.map((p) => {
|
||||||
|
const any = p as {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
state?: string;
|
||||||
|
errorText?: string;
|
||||||
|
output?: unknown;
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
any.type,
|
||||||
|
any.text?.length ?? 0,
|
||||||
|
any.state ?? "",
|
||||||
|
any.errorText ? 1 : 0,
|
||||||
|
any.output !== undefined ? 1 : 0,
|
||||||
|
].join(":");
|
||||||
|
})
|
||||||
|
.join("|");
|
||||||
|
const meta = message.metadata as
|
||||||
|
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
|
||||||
|
| undefined;
|
||||||
|
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
|
||||||
|
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
|
||||||
|
// state are already frozen. Without it in the signature the row's signature would be
|
||||||
|
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
|
||||||
|
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
|
||||||
|
// to the exact figure.
|
||||||
|
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
|
||||||
|
meta?.finishReason ?? ""
|
||||||
|
}#${meta?.usage?.reasoningTokens ?? ""}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for `reasoningTokensForPart`, the #151 anti-double-count
|
||||||
|
* rule: the authoritative `usage.reasoningTokens` is the TURN TOTAL, so it may
|
||||||
|
* only be attributed when the turn has exactly one reasoning part. With multiple
|
||||||
|
* reasoning parts (or no authoritative usage) every part falls back to its own
|
||||||
|
* per-part estimate, signalled here by `undefined`.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
metadata?: unknown,
|
||||||
|
): UIMessage =>
|
||||||
|
({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("reasoningTokensForPart", () => {
|
||||||
|
it("single reasoning part -> the authoritative turn total", () => {
|
||||||
|
const m = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: "thinking…" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
],
|
||||||
|
{ usage: { reasoningTokens: 42 } },
|
||||||
|
);
|
||||||
|
expect(reasoningTokensForPart(m)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple reasoning parts -> undefined (each estimates on its own)", () => {
|
||||||
|
const m = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: "step one" } as never,
|
||||||
|
{ type: "reasoning", text: "step two" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
],
|
||||||
|
{ usage: { reasoningTokens: 99 } },
|
||||||
|
);
|
||||||
|
// Even with an authoritative total, two reasoning parts must each estimate
|
||||||
|
// (attributing the total to one would double-count against the other).
|
||||||
|
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no authoritative usage -> undefined even for a single reasoning part", () => {
|
||||||
|
const m = msg([
|
||||||
|
{ type: "reasoning", text: "thinking…" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
]);
|
||||||
|
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the authoritative reasoning token count to attribute to a single
|
||||||
|
* `reasoning` part of an assistant message — or `undefined` when the part should
|
||||||
|
* fall back to its own per-part estimate.
|
||||||
|
*
|
||||||
|
* `usage.reasoningTokens` is the TURN TOTAL, so it may only be attributed to a
|
||||||
|
* block when the turn has exactly ONE reasoning part (the common one-step turn):
|
||||||
|
* then that block can show the exact figure. With MULTIPLE reasoning parts (a
|
||||||
|
* multi-step agent turn) every block must fall back to its own estimate —
|
||||||
|
* attributing the turn total to one of them would double-count against the
|
||||||
|
* others' estimates (#151 review anti-double-count rule). When there is no
|
||||||
|
* authoritative usage at all, every part estimates.
|
||||||
|
*
|
||||||
|
* Returns the authoritative `reasoningTokens` only for the single-reasoning-part
|
||||||
|
* case; `undefined` otherwise (the caller estimates from the part text).
|
||||||
|
*/
|
||||||
|
export function reasoningTokensForPart(
|
||||||
|
message: UIMessage,
|
||||||
|
): number | undefined {
|
||||||
|
const reasoningTokens = (
|
||||||
|
message.metadata as { usage?: { reasoningTokens?: number } } | undefined
|
||||||
|
)?.usage?.reasoningTokens;
|
||||||
|
|
||||||
|
const reasoningPartCount = (message.parts ?? []).reduce(
|
||||||
|
(acc, p) => (p.type === "reasoning" ? acc + 1 : acc),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly one reasoning part -> attribute the authoritative turn total to it.
|
||||||
|
// Otherwise (zero or multiple) each part estimates on its own.
|
||||||
|
return reasoningPartCount === 1 ? reasoningTokens : undefined;
|
||||||
|
}
|
||||||
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
|
||||||
|
|
||||||
|
const DEFAULT = "Take a look at the current document";
|
||||||
|
|
||||||
|
// Covers the three-way handleRolePick behavior (issue #149) without mounting the
|
||||||
|
// chat-thread component — the logic lives in these pure helpers.
|
||||||
|
describe("roleLaunchMessage", () => {
|
||||||
|
it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage(
|
||||||
|
{ autoStart: true, launchMessage: " Draft a plan " },
|
||||||
|
DEFAULT,
|
||||||
|
),
|
||||||
|
).toBe("Draft a plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + empty launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + null launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage(
|
||||||
|
{ autoStart: false, launchMessage: "ignored" },
|
||||||
|
DEFAULT,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression guard for #149: the "picked, not sent" flag must reset when the
|
||||||
|
// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
|
||||||
|
// was no reset, so the flag stayed stuck and the role cards never returned —
|
||||||
|
// this is exactly the `true` case below (which the old code never acted on).
|
||||||
|
describe("shouldResetRolePicked", () => {
|
||||||
|
it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
|
||||||
|
// chatId still null, roleId cleared by the parent, flag stuck -> reset.
|
||||||
|
expect(shouldResetRolePicked(null, null, true)).toBe(true);
|
||||||
|
expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
|
||||||
|
// Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
|
||||||
|
expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
|
||||||
|
expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the flag is already false", () => {
|
||||||
|
expect(shouldResetRolePicked(null, null, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what (if anything) to auto-send when an agent role card is picked
|
||||||
|
* (issue #149). Extracted as a pure function so the three-way behavior is
|
||||||
|
* unit-testable without mounting the chat-thread component:
|
||||||
|
* - autoStart=false -> null (bind the role only, send nothing)
|
||||||
|
* - autoStart=true + message -> the trimmed custom launchMessage
|
||||||
|
* - autoStart=true + empty/null -> the default fallback text
|
||||||
|
*/
|
||||||
|
export function roleLaunchMessage(
|
||||||
|
role: Pick<IAiRole, "autoStart" | "launchMessage">,
|
||||||
|
defaultText: string,
|
||||||
|
): string | null {
|
||||||
|
if (!role.autoStart) return null;
|
||||||
|
return role.launchMessage?.trim() || defaultText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
|
||||||
|
* should reset to false. After an autoStart=false pick the thread shows the
|
||||||
|
* composer with chatId still null; when the user then starts a fresh chat the
|
||||||
|
* parent clears the bound role (roleId -> null) but chatId stays null, so the
|
||||||
|
* thread never remounts and the flag would otherwise stay set — hiding the role
|
||||||
|
* cards forever. Reset exactly in that state; a still-bound role (roleId set)
|
||||||
|
* keeps the cards hidden. (Regression guard for #149.)
|
||||||
|
*/
|
||||||
|
export function shouldResetRolePicked(
|
||||||
|
chatId: string | null,
|
||||||
|
roleId: string | null | undefined,
|
||||||
|
rolePickedNoSend: boolean,
|
||||||
|
): boolean {
|
||||||
|
return chatId === null && roleId == null && rolePickedNoSend;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
newThread,
|
||||||
|
switchThread,
|
||||||
|
adoptThread,
|
||||||
|
threadSessionReducer,
|
||||||
|
} from "./thread-identity";
|
||||||
|
|
||||||
|
describe("newThread", () => {
|
||||||
|
it("uses the supplied key and has no chat id yet", () => {
|
||||||
|
expect(newThread("new-abc")).toEqual({ key: "new-abc", chatId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("switchThread", () => {
|
||||||
|
it("switches to an existing chat: key becomes the chat id", () => {
|
||||||
|
expect(switchThread("chat-1")).toEqual({
|
||||||
|
key: "chat-1",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adoptThread", () => {
|
||||||
|
// Key UNCHANGED (no remount) + chatId moved null->realId. The unchanged key is
|
||||||
|
// what keeps the live useChat store alive; the matching chatId is what makes the
|
||||||
|
// window's render-phase reconciler (activeChatId !== thread.chatId) treat the
|
||||||
|
// adopted thread as already-in-sync rather than a switch.
|
||||||
|
it("adopts in place for a new chat: keeps the key, sets the chat id", () => {
|
||||||
|
const prev = newThread("new-abc");
|
||||||
|
expect(adoptThread(prev, "chat-1")).toEqual({
|
||||||
|
key: "new-abc",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an already-persisted chat", () => {
|
||||||
|
const prev: { key: string; chatId: string | null } = {
|
||||||
|
key: "chat-1",
|
||||||
|
chatId: "chat-1",
|
||||||
|
};
|
||||||
|
expect(adoptThread(prev, "chat-2")).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("threadSessionReducer", () => {
|
||||||
|
it("reconcile to an existing id switches (key becomes the id)", () => {
|
||||||
|
const next = threadSessionReducer(newThread("new-abc"), {
|
||||||
|
type: "reconcile",
|
||||||
|
chatId: "chat-1",
|
||||||
|
newKey: "new-xyz",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "chat-1", chatId: "chat-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconcile to null starts a fresh new thread with the supplied key", () => {
|
||||||
|
const next = threadSessionReducer(switchThread("chat-1"), {
|
||||||
|
type: "reconcile",
|
||||||
|
chatId: null,
|
||||||
|
newKey: "new-xyz",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "new-xyz", chatId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopt on a new thread keeps the key and sets the id", () => {
|
||||||
|
const next = threadSessionReducer(newThread("new-abc"), {
|
||||||
|
type: "adopt",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "new-abc", chatId: "chat-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopt on a persisted thread is a no-op", () => {
|
||||||
|
const prev = switchThread("chat-1");
|
||||||
|
expect(threadSessionReducer(prev, { type: "adopt", chatId: "chat-2" })).toBe(
|
||||||
|
prev,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/client/src/features/ai-chat/utils/thread-identity.ts
Normal file
73
apps/client/src/features/ai-chat/utils/thread-identity.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Pure transitions for the AI-chat thread's identity: the single source of
|
||||||
|
* truth tying ChatThread's mount key to the chat id that mounted thread holds.
|
||||||
|
*
|
||||||
|
* The window keeps exactly ONE of these in state. Consolidating the mount key
|
||||||
|
* and the live thread's chat id into one atomic value makes the "stale chat id
|
||||||
|
* vs key" state unrepresentable: every change goes through one of the explicit
|
||||||
|
* transitions below, so the key and chatId can never silently diverge.
|
||||||
|
*
|
||||||
|
* - `newThread`/`switchThread` produce a key that forces a remount (+ reseed):
|
||||||
|
* `newThread` for a brand-new (id-less) chat, `switchThread` for an existing
|
||||||
|
* one. The caller picks which based on whether there is a chat id.
|
||||||
|
* - `adoptThread` keeps the SAME key so a brand-new chat learns its real id
|
||||||
|
* WITHOUT remounting (the live useChat store, holding the just-finished turn,
|
||||||
|
* is preserved and the next turn sends the real chatId).
|
||||||
|
*
|
||||||
|
* `newThread` takes the session key from the impure `generateId()` at the call
|
||||||
|
* site so these stay pure and unit-testable.
|
||||||
|
*/
|
||||||
|
export type ThreadIdentity = { key: string; chatId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A brand-new chat: a fresh session key and no chat id yet. `newKey` is
|
||||||
|
* supplied by the caller (generateId() is impure) so this stays pure/testable.
|
||||||
|
*/
|
||||||
|
export function newThread(newKey: string): ThreadIdentity {
|
||||||
|
return { key: newKey, chatId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to an EXISTING chat: the mount key becomes the chat id, forcing a
|
||||||
|
* remount + reseed from the persisted history. (A switch to a brand-new chat
|
||||||
|
* goes through `newThread` instead — there is no id to key on.)
|
||||||
|
*/
|
||||||
|
export function switchThread(chatId: string): ThreadIdentity {
|
||||||
|
return { key: chatId, chatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place adoption: a brand-new chat (`prev.chatId === null`) learns its real
|
||||||
|
* id WITHOUT remounting — keep the SAME key, set the chat id. If `prev` already
|
||||||
|
* has a chatId (not a new chat), this is a no-op (returns `prev`): adoption only
|
||||||
|
* applies to an as-yet-unadopted new thread.
|
||||||
|
*/
|
||||||
|
export function adoptThread(prev: ThreadIdentity, chatId: string): ThreadIdentity {
|
||||||
|
return prev.chatId === null ? { key: prev.key, chatId } : prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-identity transitions as a reducer action. See `threadSessionReducer`.
|
||||||
|
*/
|
||||||
|
export type ThreadSessionAction =
|
||||||
|
| { type: "reconcile"; chatId: string | null; newKey: string }
|
||||||
|
| { type: "adopt"; chatId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for thread-identity transitions. `reconcile` handles a
|
||||||
|
* genuine switch (user OR external atom write) -> remount; `adopt` moves a brand-
|
||||||
|
* new chat to its real id in place (no remount).
|
||||||
|
*/
|
||||||
|
export function threadSessionReducer(
|
||||||
|
state: ThreadIdentity,
|
||||||
|
action: ThreadSessionAction,
|
||||||
|
): ThreadIdentity {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reconcile":
|
||||||
|
return action.chatId === null
|
||||||
|
? newThread(action.newKey)
|
||||||
|
: switchThread(action.chatId);
|
||||||
|
case "adopt":
|
||||||
|
return adoptThread(state, action.chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div>{t("invalid invitation link")}</div>;
|
// Styled error with a CTA to login, mirroring the password-reset
|
||||||
|
// error page and the 404 page (issue #133)
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<Container my={40}>
|
||||||
|
<Text size="lg" ta="center">
|
||||||
|
{t("Invalid invitation link")}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.AUTH.LOGIN}
|
||||||
|
variant="subtle"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{t("Go to login page")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CommentListItem from "./comment-list-item";
|
||||||
|
|
||||||
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
content: JSON.stringify({ type: "doc", content: [] }),
|
||||||
|
creatorId: "user-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
creator: { id: "user-1", name: "Service Bot", avatarUrl: null } as any,
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
function renderItem(comment: IComment) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentListItem — AI badge", () => {
|
||||||
|
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||||
|
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||||
|
renderItem(baseComment({ createdSource: "user" }));
|
||||||
|
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||||
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||||
|
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||||
|
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
@@ -126,9 +127,18 @@ function CommentListItem({
|
|||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{comment.creator.name}
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||||
</Text>
|
{comment.creator.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{comment.createdSource === "agent" && (
|
||||||
|
<AiAgentBadge
|
||||||
|
authorName={comment.creator?.name}
|
||||||
|
aiChatId={comment.aiChatId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{!comment.parentCommentId && canComment && (
|
{!comment.parentCommentId && canComment && (
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export interface IComment {
|
|||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
resolvedBy?: IUser;
|
||||||
|
// Agent-edit provenance (returned by the backend via selectAll('comments')).
|
||||||
|
// createdSource === "agent" marks a comment authored via an AI agent (MCP /
|
||||||
|
// internal AI chat); aiChatId deep-links to the internal chat when present
|
||||||
|
// (null for an external MCP agent); resolvedSource marks an AI-resolved thread.
|
||||||
|
createdSource?: string;
|
||||||
|
aiChatId?: string | null;
|
||||||
|
resolvedSource?: string | null;
|
||||||
yjsSelection?: {
|
yjsSelection?: {
|
||||||
anchor: any;
|
anchor: any;
|
||||||
head: any;
|
head: any;
|
||||||
|
|||||||
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { encodeWavPcm16 } from "./encode-wav";
|
||||||
|
|
||||||
|
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
|
||||||
|
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
|
||||||
|
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
|
||||||
|
// produce audio the server cannot decode (silence / garbled transcripts), so we
|
||||||
|
// assert the canonical 44-byte header layout and the sample quantisation rails.
|
||||||
|
|
||||||
|
// Read a DataView back out of a Blob. jsdom's Blob does not implement
|
||||||
|
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
|
||||||
|
function readView(blob: Blob): Promise<DataView> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStr(view: DataView, offset: number, length: number): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("encodeWavPcm16", () => {
|
||||||
|
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
|
||||||
|
const view = await readView(encodeWavPcm16(new Float32Array(4)));
|
||||||
|
expect(readStr(view, 0, 4)).toBe("RIFF");
|
||||||
|
expect(readStr(view, 8, 4)).toBe("WAVE");
|
||||||
|
expect(readStr(view, 12, 4)).toBe("fmt ");
|
||||||
|
expect(readStr(view, 36, 4)).toBe("data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
|
||||||
|
const samples = new Float32Array(10);
|
||||||
|
const view = await readView(encodeWavPcm16(samples));
|
||||||
|
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
|
||||||
|
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
|
||||||
|
expect(view.getUint16(22, true)).toBe(1); // channels = mono
|
||||||
|
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
|
||||||
|
const sampleRate = 16000;
|
||||||
|
const samples = new Float32Array(10);
|
||||||
|
const view = await readView(encodeWavPcm16(samples, sampleRate));
|
||||||
|
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
|
||||||
|
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
|
||||||
|
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
|
||||||
|
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the sample rate to 16000 at offset 24", async () => {
|
||||||
|
const view = await readView(encodeWavPcm16(new Float32Array(2)));
|
||||||
|
expect(view.getUint32(24, true)).toBe(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
|
||||||
|
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
|
||||||
|
expect(view8.getUint32(24, true)).toBe(8000);
|
||||||
|
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
|
||||||
|
|
||||||
|
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
|
||||||
|
expect(view48.getUint32(24, true)).toBe(48000);
|
||||||
|
expect(view48.getUint32(28, true)).toBe(48000 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
|
||||||
|
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
|
||||||
|
// 0 -> 0, and out-of-range values are clamped to the rails first.
|
||||||
|
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
|
||||||
|
const view = await readView(encodeWavPcm16(samples));
|
||||||
|
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
|
||||||
|
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
|
||||||
|
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
|
||||||
|
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
|
||||||
|
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a mono blob of length 44 + samples.length * 2", () => {
|
||||||
|
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
|
||||||
|
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
|
||||||
|
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Loader, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconDownload,
|
||||||
|
IconFileText,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
// STT-accepted audio MIME types (mirror of the server whitelist). If the
|
||||||
|
// fetched blob's type is not one of these, we infer it from the file
|
||||||
|
// extension so the upload's content-type is something the endpoint accepts.
|
||||||
|
const RECOGNIZED_AUDIO_MIME = new Set([
|
||||||
|
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
|
||||||
|
"audio/wav", "audio/x-wav", "audio/wave", "audio/m4a", "audio/x-m4a",
|
||||||
|
]);
|
||||||
|
const AUDIO_MIME_BY_EXT: Record<string, string> = {
|
||||||
|
mp3: "audio/mpeg", m4a: "audio/mp4", mp4: "audio/mp4",
|
||||||
|
wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg", webm: "audio/webm",
|
||||||
|
};
|
||||||
|
|
||||||
export function AudioMenu({ editor }: EditorMenuProps) {
|
export function AudioMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const dictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -68,6 +88,100 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleTranscribe = useCallback(async () => {
|
||||||
|
const src = editorState?.src;
|
||||||
|
if (!src || isTranscribing) return;
|
||||||
|
|
||||||
|
// The bubble menu shows for the selected audio node, so selection.from is
|
||||||
|
// that node's start position. Capture it now to disambiguate duplicate-src
|
||||||
|
// blocks after the async transcription completes.
|
||||||
|
const selectedPos = editor.state.selection.from;
|
||||||
|
|
||||||
|
setIsTranscribing(true);
|
||||||
|
try {
|
||||||
|
const fileUrl = getFileUrl(src);
|
||||||
|
// Derive a filename from the internal src for the multipart part name and
|
||||||
|
// for MIME inference when the fetched blob has no usable type.
|
||||||
|
const filename = decodeURIComponent(
|
||||||
|
src.split("?")[0].split("/").pop() || "audio",
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(fileUrl, { credentials: "include" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch audio file (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
|
||||||
|
// Ensure the upload's content-type is one the STT endpoint accepts; the
|
||||||
|
// server keys off the blob's MIME type.
|
||||||
|
let uploadBlob = blob;
|
||||||
|
const baseType = (blob.type || "").split(";")[0].trim().toLowerCase();
|
||||||
|
if (!RECOGNIZED_AUDIO_MIME.has(baseType)) {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const inferred = AUDIO_MIME_BY_EXT[ext];
|
||||||
|
if (inferred) {
|
||||||
|
// Rebuild the blob with an accepted content-type; the server keys off it.
|
||||||
|
uploadBlob = new Blob([blob], { type: inferred });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (await transcribeAudio(uploadBlob, filename)).trim();
|
||||||
|
if (text.length === 0) {
|
||||||
|
notifications.show({ message: t("No speech detected") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-scan the doc at insert time so a collaborative edit during the async
|
||||||
|
// transcription can't misplace the text. Among audio nodes with this src
|
||||||
|
// (the same file may be embedded more than once), pick the occurrence
|
||||||
|
// closest to the originally-selected block.
|
||||||
|
let insertPos: number | null = null;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === "audio" && node.attrs.src === src) {
|
||||||
|
const delta = Math.abs(pos - selectedPos);
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
insertPos = pos + node.nodeSize; // position just after the audio block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // visit all nodes to find the closest match
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraph = { type: "paragraph", content: [{ type: "text", text }] };
|
||||||
|
try {
|
||||||
|
if (insertPos !== null) {
|
||||||
|
editor.chain().focus().insertContentAt(insertPos, paragraph).run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().insertContent(paragraph).run();
|
||||||
|
}
|
||||||
|
} catch (insertErr) {
|
||||||
|
// A destroyed editor or out-of-bounds position must not throw; log and
|
||||||
|
// ignore so the transcription itself is not reported as a failure.
|
||||||
|
console.error("[audio-transcribe] insert failed", insertErr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[audio-transcribe] failed", err);
|
||||||
|
const resp = (
|
||||||
|
err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
)?.response;
|
||||||
|
const serverMsg = resp?.data?.message;
|
||||||
|
let message: string;
|
||||||
|
if (serverMsg && serverMsg.trim().length > 0) {
|
||||||
|
// The server already explains the cause (e.g. provider error, bad
|
||||||
|
// format, STT not configured) — show it verbatim.
|
||||||
|
message = serverMsg;
|
||||||
|
} else if (resp?.status === 503 || resp?.status === 403) {
|
||||||
|
message = t("Voice dictation is not configured");
|
||||||
|
} else {
|
||||||
|
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||||
|
}
|
||||||
|
notifications.show({ color: "red", message });
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false);
|
||||||
|
}
|
||||||
|
}, [editor, editorState?.src, isTranscribing, t]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -95,6 +209,20 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
|
{dictationEnabled && (
|
||||||
|
<Tooltip position="top" label={isTranscribing ? t("Transcribing…") : t("Transcribe")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Transcribe")}
|
||||||
|
variant="subtle"
|
||||||
|
disabled={isTranscribing}
|
||||||
|
>
|
||||||
|
{isTranscribing ? <Loader size={18} /> : <IconFileText size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|||||||
@@ -47,6 +47,26 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="codeBlock">
|
<NodeViewWrapper className="codeBlock">
|
||||||
|
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
|
||||||
|
With the non-editable menu rendered before it, the browser's click
|
||||||
|
hit-testing snapped the caret up one line. Render content first; the
|
||||||
|
menu is rendered after it and lifted back above visually via flex
|
||||||
|
`order: -1` (the `.codeBlock` wrapper is a flex column — see
|
||||||
|
code-block.module.css). It stays fully in flow as a full-width row
|
||||||
|
above the code: no overlay/absolute positioning. The second #146
|
||||||
|
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||||
|
<pre
|
||||||
|
spellCheck="false"
|
||||||
|
hidden={
|
||||||
|
((language === "mermaid" && !editor.isEditable) ||
|
||||||
|
(language === "mermaid" && !isSelected)) &&
|
||||||
|
node.textContent.length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
|
</pre>
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
justify="flex-end"
|
justify="flex-end"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
@@ -83,18 +103,6 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
</CopyButton>
|
</CopyButton>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<pre
|
|
||||||
spellCheck="false"
|
|
||||||
hidden={
|
|
||||||
((language === "mermaid" && !editor.isEditable) ||
|
|
||||||
(language === "mermaid" && !isSelected)) &&
|
|
||||||
node.textContent.length > 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<NodeViewContent as="code" className={`language-${language}`} />
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
{language === "mermaid" && (
|
{language === "mermaid" && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MermaidView props={props} />
|
<MermaidView props={props} />
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
|
||||||
|
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
|
||||||
|
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
|
||||||
|
so the menu still reads as a row above the code, exactly as before, without
|
||||||
|
sitting in-flow before the contentDOM. */
|
||||||
.menuGroup {
|
.menuGroup {
|
||||||
|
order: -1;
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
collectScrollAncestors,
|
||||||
|
reflowAfterPaste,
|
||||||
|
} from "./editor-paste-handler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the #146 post-paste reflow helpers. jsdom does not compute
|
||||||
|
* styles or layout, so we stub getComputedStyle (per element via a Map) and the
|
||||||
|
* scroll/overflow geometry properties (per element via Object.defineProperty).
|
||||||
|
* Element trees are built DETACHED from `document`, so the ancestor walk only
|
||||||
|
* traverses the elements we create. collectScrollAncestors always appends
|
||||||
|
* document.scrollingElement, so we assert on specific ancestors with
|
||||||
|
* toContain/not.toContain rather than exact-array equality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Overflow = { overflowX: string; overflowY: string };
|
||||||
|
const styleMap = new Map<Element, Overflow>();
|
||||||
|
|
||||||
|
function makeScrollable(
|
||||||
|
overflowY: string,
|
||||||
|
{
|
||||||
|
sh = 0,
|
||||||
|
ch = 0,
|
||||||
|
sw = 0,
|
||||||
|
cw = 0,
|
||||||
|
left = 0,
|
||||||
|
top = 0,
|
||||||
|
overflowX = "visible",
|
||||||
|
}: {
|
||||||
|
sh?: number;
|
||||||
|
ch?: number;
|
||||||
|
sw?: number;
|
||||||
|
cw?: number;
|
||||||
|
left?: number;
|
||||||
|
top?: number;
|
||||||
|
overflowX?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
Object.defineProperty(el, "scrollHeight", { configurable: true, value: sh });
|
||||||
|
Object.defineProperty(el, "clientHeight", { configurable: true, value: ch });
|
||||||
|
Object.defineProperty(el, "scrollWidth", { configurable: true, value: sw });
|
||||||
|
Object.defineProperty(el, "clientWidth", { configurable: true, value: cw });
|
||||||
|
Object.defineProperty(el, "scrollLeft", { configurable: true, value: left });
|
||||||
|
Object.defineProperty(el, "scrollTop", { configurable: true, value: top });
|
||||||
|
styleMap.set(el, { overflowX, overflowY });
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leaf node whose parentElement is `parent`. The walk starts from
|
||||||
|
// node.parentElement, so the parent is the first candidate ancestor.
|
||||||
|
function makeNodeUnder(parent: HTMLElement) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
parent.appendChild(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override `document.scrollingElement` as an instance own-property (the native
|
||||||
|
// implementation is a getter on Document.prototype, which we never touch).
|
||||||
|
function setScrollingElement(value: Element | null) {
|
||||||
|
Object.defineProperty(document, "scrollingElement", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
styleMap.clear();
|
||||||
|
vi.stubGlobal("getComputedStyle", (el: Element) => {
|
||||||
|
return styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
// Drop the per-test instance override so the native prototype getter shows
|
||||||
|
// through again (it was never modified, so no further restore is needed).
|
||||||
|
delete (document as any).scrollingElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectScrollAncestors", () => {
|
||||||
|
it("includes an overflow:overlay ancestor that overflows (macOS case)", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("overlay", { sh: 200, ch: 100 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
expect(collectScrollAncestors(node)).toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes an overflow:auto ancestor that does NOT overflow (gate fails)", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("auto", { sh: 100, ch: 100 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
expect(collectScrollAncestors(node)).not.toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an overflow:auto ancestor that overflows", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("auto", { sh: 200, ch: 100 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
expect(collectScrollAncestors(node)).toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes a non-scrollable overflow even when it overflows", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("hidden", { sh: 200, ch: 100 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
expect(collectScrollAncestors(node)).not.toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an X-axis overflow:scroll ancestor that overflows horizontally", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("visible", {
|
||||||
|
overflowX: "scroll",
|
||||||
|
sw: 200,
|
||||||
|
cw: 100,
|
||||||
|
});
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
expect(collectScrollAncestors(node)).toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedups: scrollingElement already in the walk is added exactly once", () => {
|
||||||
|
const a = makeScrollable("auto", { sh: 200, ch: 100 });
|
||||||
|
setScrollingElement(a);
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
const result = collectScrollAncestors(node);
|
||||||
|
expect(result.filter((x) => x === a).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw and appends nothing when scrollingElement is null", () => {
|
||||||
|
setScrollingElement(null);
|
||||||
|
const a = makeScrollable("auto", { sh: 200, ch: 100 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
const result = collectScrollAncestors(node);
|
||||||
|
// Only the qualifying ancestor we built — no trailing scrollingElement.
|
||||||
|
expect(result).toEqual([a]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reflowAfterPaste", () => {
|
||||||
|
it("runs the double rAF and nudges each ancestor with scrollTo(scrollLeft, scrollTop)", () => {
|
||||||
|
// Run the double-nested requestAnimationFrame synchronously.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"requestAnimationFrame",
|
||||||
|
(cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setScrollingElement(null);
|
||||||
|
|
||||||
|
const a = makeScrollable("auto", { sh: 200, ch: 100, left: 5, top: 10 });
|
||||||
|
const node = makeNodeUnder(a);
|
||||||
|
(a as any).scrollTo = vi.fn();
|
||||||
|
|
||||||
|
reflowAfterPaste({ view: { dom: node } } as any);
|
||||||
|
|
||||||
|
expect((a as any).scrollTo).toHaveBeenCalledWith(5, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,12 +22,81 @@ const ATTACHMENT_NODE_TYPES = [
|
|||||||
|
|
||||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||||
|
|
||||||
|
const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "overlay"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every scrollable ancestor of the editor DOM whose hit-test layer
|
||||||
|
* could be stale after a paste, plus the document scrolling element. We nudge
|
||||||
|
* ALL of them (a zero-delta nudge is harmless) because the real scroll container
|
||||||
|
* varies — a styled overflow ancestor on most pages, the document itself on
|
||||||
|
* others — and `overflow: overlay` (common on macOS, where #146 reproduces)
|
||||||
|
* must count as scrollable too. Called only AFTER the paste has committed, so
|
||||||
|
* `scrollHeight > clientHeight` reflects the inserted content.
|
||||||
|
*/
|
||||||
|
export function collectScrollAncestors(node: HTMLElement): HTMLElement[] {
|
||||||
|
const targets: HTMLElement[] = [];
|
||||||
|
// Walk every ancestor (incl. body/html) — on some layouts the scroll lives on
|
||||||
|
// body rather than the documentElement that scrollingElement points at.
|
||||||
|
let el: HTMLElement | null = node.parentElement;
|
||||||
|
while (el) {
|
||||||
|
const { overflowX, overflowY } = getComputedStyle(el);
|
||||||
|
const scrollsY =
|
||||||
|
SCROLLABLE_OVERFLOW.has(overflowY) && el.scrollHeight > el.clientHeight;
|
||||||
|
const scrollsX =
|
||||||
|
SCROLLABLE_OVERFLOW.has(overflowX) && el.scrollWidth > el.clientWidth;
|
||||||
|
if (scrollsY || scrollsX) targets.push(el);
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
const docEl = document.scrollingElement as HTMLElement | null;
|
||||||
|
if (docEl && !targets.includes(docEl)) targets.push(docEl);
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-flow the editor's scroll containers after a paste so the browser refreshes
|
||||||
|
* its click hit-testing geometry (#146). Pasting markdown/code inserts React
|
||||||
|
* NodeViews that mount ASYNCHRONOUSLY; until the next reflow, ProseMirror's
|
||||||
|
* posAtCoords/caretRangeFromPoint can map a click to a stale (offset) line —
|
||||||
|
* which users observed clears itself on any scroll. We reproduce that scroll's
|
||||||
|
* side effect with a ZERO-delta nudge (re-assign scrollTop/Left to their current
|
||||||
|
* value), invalidating the hit-test layer WITHOUT moving the viewport. The
|
||||||
|
* container lookup AND the nudge run across two animation frames so they happen
|
||||||
|
* AFTER the pasted content + NodeViews commit (only then is the real scroll
|
||||||
|
* container measurable).
|
||||||
|
*
|
||||||
|
* This is the SECOND of two #146 mitigations; the FIRST is the content-first DOM
|
||||||
|
* order in the NodeViews (code-block-view.tsx, footnotes-list-view.tsx,
|
||||||
|
* footnote-definition-view.tsx). Editing one, check the other.
|
||||||
|
*/
|
||||||
|
export function reflowAfterPaste(editor: Editor) {
|
||||||
|
const dom = editor.view.dom as HTMLElement;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const el of collectScrollAncestors(dom)) {
|
||||||
|
// Zero-delta nudge: re-set the scroll position to its current value to
|
||||||
|
// invalidate the browser's hit-test layer WITHOUT moving the viewport.
|
||||||
|
// `scrollTo(x, y)` is the repo idiom and avoids a lint-flagged
|
||||||
|
// self-assignment.
|
||||||
|
el.scrollTo(el.scrollLeft, el.scrollTop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
creatorId?: string,
|
creatorId?: string,
|
||||||
) => {
|
) => {
|
||||||
|
// Schedule a post-paste reflow on EVERY paste path — intentionally. handlePaste
|
||||||
|
// returns BEFORE the markdown/code-insertion plugin runs, so it cannot know here
|
||||||
|
// whether async NodeViews will be inserted; the nudge is a cheap layout read on
|
||||||
|
// the next frames and a no-op for the viewport, so scheduling it unconditionally
|
||||||
|
// is simpler and harmless. Pairs with the content-first DOM order in the
|
||||||
|
// NodeViews — both address #146 from different angles.
|
||||||
|
reflowAfterPaste(editor);
|
||||||
|
|
||||||
const clipboardData = event.clipboardData.getData("text/plain");
|
const clipboardData = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
||||||
|
|||||||
@@ -73,3 +73,18 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Float image (#145): on narrow screens a floated image would crowd the text to
|
||||||
|
an unreadable column, so collapse it to full width and drop the float.
|
||||||
|
`!important` is required because applyAlignment sets `float`/`padding` inline,
|
||||||
|
which a normal rule cannot override. Keys off the `data-image-align` attribute
|
||||||
|
the image node view mirrors onto its container. This module is the one actually
|
||||||
|
imported by the resize node views (node-resize-handles.ts), so the rule loads. */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container:global([data-image-align="floatLeft"]),
|
||||||
|
.container:global([data-image-align="floatRight"]) {
|
||||||
|
float: none !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FC, useRef } from "react";
|
import { FC, useRef } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,6 +11,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||||
|
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
|
||||||
|
// keeps the stable batch path.
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const streamingDictation =
|
||||||
|
workspace?.settings?.ai?.dictationStreaming === true;
|
||||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||||
// Running insertion point: after each inserted segment we remember the caret
|
// Running insertion point: after each inserted segment we remember the caret
|
||||||
@@ -70,7 +77,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
|||||||
return (
|
return (
|
||||||
<MicButton
|
<MicButton
|
||||||
size="md"
|
size="md"
|
||||||
streaming
|
streaming={streamingDictation}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
onText={handleText}
|
onText={handleText}
|
||||||
disabled={!editor.isEditable}
|
disabled={!editor.isEditable}
|
||||||
|
|||||||
@@ -1,25 +1,45 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
|
||||||
import classes from "./footnote.module.css";
|
import classes from "./footnote.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
|
||||||
|
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
|
||||||
|
*/
|
||||||
|
export function backlinkLabel(index: number): string {
|
||||||
|
let out = "";
|
||||||
|
let x = index;
|
||||||
|
while (x >= 0) {
|
||||||
|
out = String.fromCharCode(97 + (x % 26)) + out;
|
||||||
|
x = Math.floor(x / 26) - 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NodeView for a single footnote definition: a decorative number marker, the
|
* NodeView for a single footnote definition: a decorative number marker, the
|
||||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||||
* The number is derived from the document (not stored).
|
* The number is derived from the document (not stored).
|
||||||
|
*
|
||||||
|
* After #166 a footnote can be referenced more than once (one number, one
|
||||||
|
* definition, N forward links). When it is, the back-link becomes a row of
|
||||||
|
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
|
||||||
|
* a single-reference footnote keeps the plain ↩.
|
||||||
*/
|
*/
|
||||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||||
const { node, editor } = props;
|
const { node, editor } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const id = node.attrs.id as string;
|
const id = node.attrs.id as string;
|
||||||
|
|
||||||
// Read the cached number from the numbering plugin (computed once per doc
|
// Read the cached number/ref-count from the numbering plugin (computed once
|
||||||
// change) rather than recomputing the whole map on every render.
|
// per doc change) rather than recomputing the whole map on every render.
|
||||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||||
|
const refCount = getFootnoteRefCount(editor.state, id);
|
||||||
|
|
||||||
const handleBack = (e: React.MouseEvent) => {
|
const jumpTo = (e: React.MouseEvent, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor.commands.scrollToReference(id);
|
editor.commands.scrollToReference(id, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,20 +49,60 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
|||||||
className={classes.definition}
|
className={classes.definition}
|
||||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||||
>
|
>
|
||||||
<span className={classes.definitionMarker} contentEditable={false}>
|
{/* #146: contentDOM MUST be the first child — a non-editable marker before
|
||||||
{number}.
|
it makes click hit-testing snap the caret above. Content first; the
|
||||||
</span>
|
marker + back-link follow in DOM and are placed left/right via CSS
|
||||||
|
flex `order`. The second #146 mitigation lives in
|
||||||
|
editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||||
<NodeViewContent className={classes.definitionContent} />
|
<NodeViewContent className={classes.definitionContent} />
|
||||||
<span
|
<span
|
||||||
className={classes.backLink}
|
className={classes.definitionMarker}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={handleBack}
|
aria-hidden="true"
|
||||||
role="button"
|
|
||||||
aria-label={t("Back to reference")}
|
|
||||||
title={t("Back to reference")}
|
|
||||||
>
|
>
|
||||||
↩
|
{number}.
|
||||||
</span>
|
</span>
|
||||||
|
{refCount > 1 ? (
|
||||||
|
// Multiple references -> ↩ followed by one lettered link per occurrence.
|
||||||
|
<span
|
||||||
|
className={classes.backLinks}
|
||||||
|
contentEditable={false}
|
||||||
|
role="group"
|
||||||
|
aria-label={t("Back to references")}
|
||||||
|
>
|
||||||
|
<span className={classes.backLinkArrow} aria-hidden="true">
|
||||||
|
↩
|
||||||
|
</span>
|
||||||
|
{Array.from({ length: refCount }, (_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={classes.backLink}
|
||||||
|
onClick={(e) => jumpTo(e, i)}
|
||||||
|
role="button"
|
||||||
|
aria-label={t("Back to reference {{label}}", {
|
||||||
|
label: backlinkLabel(i),
|
||||||
|
})}
|
||||||
|
title={t("Back to reference {{label}}", {
|
||||||
|
label: backlinkLabel(i),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{backlinkLabel(i)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
// Single reference -> the plain ↩ (unchanged behavior).
|
||||||
|
<span
|
||||||
|
className={classes.backLink}
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={(e) => jumpTo(e, 0)}
|
||||||
|
role="button"
|
||||||
|
aria-label={t("Back to reference")}
|
||||||
|
title={t("Back to reference")}
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural regression guard for #146 (PR #147).
|
||||||
|
*
|
||||||
|
* Guards ALL THREE editable NodeViews touched by the fix: the two footnote views
|
||||||
|
* (FootnotesListView, FootnoteDefinitionView) AND the code block (CodeBlockView).
|
||||||
|
*
|
||||||
|
* The caret/click-offset fix rests entirely on ONE invariant: in every editable
|
||||||
|
* NodeView the editable `NodeViewContent` (contentDOM) must come FIRST in the
|
||||||
|
* wrapper, with no non-editable (`contenteditable="false"`) element before it.
|
||||||
|
* If a future edit reinserts chrome (separator, heading, marker, back-link,
|
||||||
|
* language menu) ahead of the content, the macOS hit-testing bug returns
|
||||||
|
* silently — and the symptom needs a real browser to see. This test pins the
|
||||||
|
* DOM ORDER (the proxy that IS the fix) in the existing jsdom harness.
|
||||||
|
*
|
||||||
|
* We stub `@tiptap/react` so the views render as plain DOM and we can inspect
|
||||||
|
* the child order our JSX produces — that order is exactly what regresses, and
|
||||||
|
* it does not depend on a live editor. The stubbed `NodeViewContent` carries the
|
||||||
|
* real `data-node-view-content` marker tiptap uses, so the assertion mirrors
|
||||||
|
* production. This test passes on the fixed order and FAILS on the pre-fix order
|
||||||
|
* (chrome-before-content).
|
||||||
|
*/
|
||||||
|
vi.mock("@tiptap/react", () => ({
|
||||||
|
NodeViewWrapper: ({ children, ...props }: any) => (
|
||||||
|
<div data-testid="nvw" {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
// Mirror the real contentDOM marker so the guard matches production output.
|
||||||
|
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// footnote-definition-view reads a cached number + reference count from the
|
||||||
|
// numbering plugin; stub them so we don't need a live ProseMirror state. The
|
||||||
|
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
|
||||||
|
// backlink branch (#168). Default 1 = single reference (the #146 cases).
|
||||||
|
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
|
||||||
|
vi.mock("@docmost/editor-ext", () => ({
|
||||||
|
getFootnoteNumber: () => 1,
|
||||||
|
getFootnoteRefCount: () => mockRefCount.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
|
||||||
|
// The Group mock MUST forward contentEditable: React serializes
|
||||||
|
// contentEditable={false} to the DOM attribute contenteditable="false", which
|
||||||
|
// the structural guard selects on to identify non-editable chrome.
|
||||||
|
vi.mock("@mantine/core", () => ({
|
||||||
|
Group: ({ children, className, contentEditable }: any) => (
|
||||||
|
<div className={className} contentEditable={contentEditable}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
Select: () => null,
|
||||||
|
Tooltip: ({ children }: any) => <>{children}</>,
|
||||||
|
ActionIcon: ({ children, onClick }: any) => (
|
||||||
|
<button onClick={onClick}>{children}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/common/copy-button", () => ({
|
||||||
|
CopyButton: ({ children }: any) =>
|
||||||
|
children({ copied: false, copy: () => {} }),
|
||||||
|
}));
|
||||||
|
vi.mock("@tabler/icons-react", () => ({
|
||||||
|
IconCheck: () => null,
|
||||||
|
IconCopy: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import FootnotesListView from "./footnotes-list-view";
|
||||||
|
import FootnoteDefinitionView, {
|
||||||
|
backlinkLabel,
|
||||||
|
} from "./footnote-definition-view";
|
||||||
|
import CodeBlockView from "../code-block/code-block-view";
|
||||||
|
|
||||||
|
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
|
||||||
|
// editor.state (the latter unused once getFootnoteNumber is mocked).
|
||||||
|
const props = {
|
||||||
|
node: { attrs: { id: "fn-1" }, textContent: "" },
|
||||||
|
editor: { state: {}, isEditable: true, commands: {} },
|
||||||
|
getPos: () => 0,
|
||||||
|
updateAttributes: () => {},
|
||||||
|
deleteNode: () => {},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// CodeBlockView needs more than the footnote stub: a language attr (non-mermaid
|
||||||
|
// so MermaidView never renders), an editor with selection/on/off, and an
|
||||||
|
// extension exposing lowlight.listLanguages.
|
||||||
|
const codeBlockProps = {
|
||||||
|
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
|
||||||
|
editor: {
|
||||||
|
state: { selection: { from: 0, to: 0 } },
|
||||||
|
isEditable: true,
|
||||||
|
commands: {},
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
},
|
||||||
|
extension: {
|
||||||
|
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
|
||||||
|
},
|
||||||
|
getPos: () => 0,
|
||||||
|
updateAttributes: () => {},
|
||||||
|
deleteNode: () => {},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const cases: Array<{ name: string; ui: React.ReactElement }> = [
|
||||||
|
{ name: "FootnotesListView", ui: <FootnotesListView {...props} /> },
|
||||||
|
{ name: "FootnoteDefinitionView", ui: <FootnoteDefinitionView {...props} /> },
|
||||||
|
{ name: "CodeBlockView", ui: <CodeBlockView {...codeBlockProps} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("#146 editable NodeView contentDOM-first invariant", () => {
|
||||||
|
it.each(cases)(
|
||||||
|
"$name renders the editable contentDOM ahead of all non-editable chrome",
|
||||||
|
({ ui }) => {
|
||||||
|
const { getByTestId } = render(ui);
|
||||||
|
const wrapper = getByTestId("nvw");
|
||||||
|
|
||||||
|
const content = wrapper.querySelector("[data-node-view-content]");
|
||||||
|
expect(content).not.toBeNull();
|
||||||
|
|
||||||
|
// The contentDOM sits at the FRONT of the wrapper: it is either the
|
||||||
|
// wrapper's first child (footnote views) or nested in the first child
|
||||||
|
// (code-block wraps it in <pre>). Either way the first element child
|
||||||
|
// must contain it. (compareDocumentPosition below is NOT redundant here:
|
||||||
|
// for code-block the content is not the literal first child, so we keep
|
||||||
|
// the document-order check to prove no chrome precedes the content.)
|
||||||
|
const firstEl = wrapper.firstElementChild!;
|
||||||
|
expect(firstEl === content || firstEl.contains(content!)).toBe(true);
|
||||||
|
|
||||||
|
// Chrome exists (separator/heading/marker/back-link/menu)...
|
||||||
|
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
|
||||||
|
expect(nonEditable.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// ...and every non-editable element comes AFTER the contentDOM, so the
|
||||||
|
// browser's click hit-testing reaches the editable content first (#146).
|
||||||
|
for (const el of Array.from(nonEditable)) {
|
||||||
|
const pos = content!.compareDocumentPosition(el);
|
||||||
|
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #168: a footnote referenced more than once shows one lettered backlink per
|
||||||
|
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
|
||||||
|
// footnote keeps the plain ↩.
|
||||||
|
describe("#168 footnote definition multi-backlinks", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset the shared ref-count mock so other tests see a single reference.
|
||||||
|
mockRefCount.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeProps = () =>
|
||||||
|
({
|
||||||
|
node: { attrs: { id: "fn-1" }, textContent: "" },
|
||||||
|
editor: {
|
||||||
|
state: {},
|
||||||
|
isEditable: true,
|
||||||
|
commands: { scrollToReference: vi.fn() },
|
||||||
|
},
|
||||||
|
getPos: () => 0,
|
||||||
|
updateAttributes: () => {},
|
||||||
|
deleteNode: () => {},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
|
||||||
|
mockRefCount.value = 3;
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
|
||||||
|
const wrapper = getByTestId("nvw");
|
||||||
|
|
||||||
|
const links = wrapper.querySelectorAll('[role="button"]');
|
||||||
|
expect(Array.from(links).map((l) => l.textContent)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
// The ↩ arrow is present (as decorative chrome, not a button).
|
||||||
|
expect(wrapper.textContent).toContain("↩");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
|
||||||
|
mockRefCount.value = 3;
|
||||||
|
const props = makeProps();
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||||
|
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
|
||||||
|
|
||||||
|
fireEvent.click(links[1]); // "b"
|
||||||
|
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||||
|
"fn-1",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a single-reference footnote renders just one ↩ (no letters)", () => {
|
||||||
|
mockRefCount.value = 1;
|
||||||
|
const props = makeProps();
|
||||||
|
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||||
|
const wrapper = getByTestId("nvw");
|
||||||
|
|
||||||
|
const links = wrapper.querySelectorAll('[role="button"]');
|
||||||
|
expect(links.length).toBe(1);
|
||||||
|
expect(links[0].textContent).toBe("↩");
|
||||||
|
|
||||||
|
fireEvent.click(links[0]);
|
||||||
|
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||||
|
"fn-1",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
|
||||||
|
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
|
||||||
|
describe("backlinkLabel base-26 boundary (#168)", () => {
|
||||||
|
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
|
||||||
|
expect(backlinkLabel(0)).toBe("a");
|
||||||
|
expect(backlinkLabel(25)).toBe("z");
|
||||||
|
expect(backlinkLabel(26)).toBe("aa");
|
||||||
|
expect(backlinkLabel(27)).toBe("ab");
|
||||||
|
expect(backlinkLabel(51)).toBe("az");
|
||||||
|
expect(backlinkLabel(52)).toBe("ba");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,14 +57,19 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom footnotes container. */
|
/* Bottom footnotes container. Flex column so the heading (rendered AFTER the
|
||||||
|
editable NodeViewContent in the DOM for #146) is lifted back above the list
|
||||||
|
visually via `order`, instead of sitting in-flow before the contentDOM. */
|
||||||
.list {
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
margin-top: var(--mantine-spacing-lg);
|
margin-top: var(--mantine-spacing-lg);
|
||||||
padding-top: var(--mantine-spacing-md);
|
padding-top: var(--mantine-spacing-md);
|
||||||
border-top: 1px solid var(--mantine-color-default-border);
|
border-top: 1px solid var(--mantine-color-default-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.listHeading {
|
.listHeading {
|
||||||
|
order: -1; /* visually above the list, though it follows it in the DOM (#146) */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--mantine-font-size-sm);
|
font-size: var(--mantine-font-size-sm);
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
@@ -83,6 +88,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.definitionMarker {
|
.definitionMarker {
|
||||||
|
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 1.5em;
|
min-width: 1.5em;
|
||||||
/* Right-align within the narrow column so the period sits next to the text
|
/* Right-align within the narrow column so the period sits next to the text
|
||||||
@@ -109,3 +115,18 @@
|
|||||||
.backLink:hover {
|
.backLink:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
|
||||||
|
occurrence. Sits on the right, after the content, like the single ↩. */
|
||||||
|
.backLinks {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLinkArrow {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,39 @@ import { useTranslation } from "react-i18next";
|
|||||||
import classes from "./footnote.module.css";
|
import classes from "./footnote.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
* NodeView for the bottom footnotes container: the editable list of definitions
|
||||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
* (NodeViewContent) plus a visual separator + localized heading.
|
||||||
|
*
|
||||||
|
* #146: the editable NodeViewContent MUST be the FIRST child in the DOM. A
|
||||||
|
* non-editable block rendered before it (the old separator + heading) makes the
|
||||||
|
* browser's click hit-testing (posAtCoords → caretRangeFromPoint) miss the
|
||||||
|
* contentDOM and snap the caret to the previous node (several lines above, into
|
||||||
|
* the body). So content goes first; the heading is rendered AFTER it and lifted
|
||||||
|
* back above visually with CSS flex `order` (the separator border lives on the
|
||||||
|
* flex container itself).
|
||||||
|
*
|
||||||
|
* The second #146 mitigation lives in editor-paste-handler.tsx (reflowAfterPaste).
|
||||||
*/
|
*/
|
||||||
export default function FootnotesListView(_props: NodeViewProps) {
|
export default function FootnotesListView(_props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
// role/aria-label preserve the section label for AT: the visible heading
|
||||||
<div className={classes.list} contentEditable={false}>
|
// below is now aria-hidden, so without these the "Footnotes" label would be
|
||||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
// lost to a screen reader (WCAG 1.3.2 — DOM order has heading after content).
|
||||||
</div>
|
<NodeViewWrapper
|
||||||
|
className={classes.list}
|
||||||
|
role="group"
|
||||||
|
aria-label={t("Footnotes")}
|
||||||
|
>
|
||||||
<NodeViewContent />
|
<NodeViewContent />
|
||||||
|
<div
|
||||||
|
className={classes.listHeading}
|
||||||
|
contentEditable={false}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{t("Footnotes")}
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconFloatLeft,
|
||||||
|
IconFloatRight,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
|
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
||||||
|
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||||
src: imageAttrs?.src || null,
|
src: imageAttrs?.src || null,
|
||||||
alt: imageAttrs?.alt || "",
|
alt: imageAttrs?.alt || "",
|
||||||
};
|
};
|
||||||
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignImageFloatLeft = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setImageAlign("floatLeft")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignImageFloatRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setImageAlign("floatRight")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Float left (wrap text)")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignImageFloatLeft}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Float left (wrap text)")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isFloatLeft })}
|
||||||
|
>
|
||||||
|
<IconFloatLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Float right (wrap text)")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignImageFloatRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Float right (wrap text)")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isFloatRight })}
|
||||||
|
>
|
||||||
|
<IconFloatRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
{altTextButton}
|
{altTextButton}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { getSuggestionItems } from "./menu-items";
|
||||||
|
|
||||||
|
// The slash-command `allow` callback (slash-command.ts) keeps the popup active
|
||||||
|
// only while at least one item matches the current query:
|
||||||
|
// const groups = getSuggestionItems({ query });
|
||||||
|
// const hasMatches = Object.values(groups).some((items) => items.length > 0);
|
||||||
|
// return hasMatches;
|
||||||
|
// With `allowSpaces: true`, a non-empty query that matches nothing must collapse
|
||||||
|
// to an empty result so `allow` returns false and the menu closes (instead of
|
||||||
|
// leaving literal "/todo abc" text behind). These tests pin that contract at the
|
||||||
|
// `getSuggestionItems` boundary, which is the unit-testable half of `allow`.
|
||||||
|
|
||||||
|
const KEY = "currentUser";
|
||||||
|
|
||||||
|
function hasMatches(query: string): boolean {
|
||||||
|
// Mirror the exact predicate used by slash-command.ts `allow`.
|
||||||
|
const groups = getSuggestionItems({ query });
|
||||||
|
return Object.values(groups).some((items) => items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Default workspace state: HTML-embed feature OFF (matches production default).
|
||||||
|
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => {
|
||||||
|
it("keeps the menu allowed for a query that matches items", () => {
|
||||||
|
expect(hasMatches("h1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the menu allowed for a multi-word matching query", () => {
|
||||||
|
// "Heading 1" is a multi-word title kept alive by allowSpaces.
|
||||||
|
expect(hasMatches("Heading 1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the menu (no matches) for a non-empty query that matches nothing", () => {
|
||||||
|
expect(hasMatches("zzzznomatch")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the menu for a space-bearing non-matching query", () => {
|
||||||
|
// The exact case the allowSpaces fix targets: "/todo abc" matches nothing.
|
||||||
|
expect(hasMatches("todo abc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty result object for a no-match query", () => {
|
||||||
|
expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a non-empty result for the 'Heading 1' query", () => {
|
||||||
|
const groups = getSuggestionItems({ query: "Heading 1" });
|
||||||
|
const titles = Object.values(groups)
|
||||||
|
.flat()
|
||||||
|
.map((item) => item.title);
|
||||||
|
expect(titles).toContain("Heading 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Page tree (child pages, recursive)",
|
||||||
|
description: "Render the full nested tree of all descendant pages",
|
||||||
|
searchTerms: [
|
||||||
|
"subpages",
|
||||||
|
"child",
|
||||||
|
"children",
|
||||||
|
"nested",
|
||||||
|
"hierarchy",
|
||||||
|
"tree",
|
||||||
|
"recursive",
|
||||||
|
"toc",
|
||||||
|
],
|
||||||
|
icon: IconSitemap,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertSubpages({ recursive: true })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Synced block",
|
title: "Synced block",
|
||||||
description: "Create a block that stays in sync across pages.",
|
description: "Create a block that stays in sync across pages.",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
|
|||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const toggleRecursive = useCallback(() => {
|
||||||
|
const current = editor.getAttributes("subpages")?.recursive ?? false;
|
||||||
|
editor.commands.updateAttributes("subpages", {
|
||||||
|
recursive: !current,
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const deleteNode = useCallback(() => {
|
const deleteNode = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
editor
|
editor
|
||||||
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
// Subscribe to the live `recursive` attribute the standard way (as the
|
||||||
|
// sibling bubble menus do): useEditorState re-renders only when the selected
|
||||||
|
// value actually changes, so the mode icon/tooltip stay current after a
|
||||||
|
// toggle without re-rendering on every keystroke.
|
||||||
|
const isRecursive = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
|
|||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Group gap={4} wrap="nowrap">
|
||||||
<ActionIcon
|
<Tooltip
|
||||||
onClick={deleteNode}
|
position="top"
|
||||||
variant="default"
|
label={
|
||||||
size="lg"
|
isRecursive
|
||||||
color="red"
|
? t("Switch to flat list")
|
||||||
aria-label={t("Delete")}
|
: t("Switch to tree")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconTrash size={18} />
|
<ActionIcon
|
||||||
</ActionIcon>
|
onClick={toggleRecursive}
|
||||||
</Tooltip>
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Toggle subpages display mode")}
|
||||||
|
>
|
||||||
|
{isRecursive ? (
|
||||||
|
<IconList size={18} />
|
||||||
|
) : (
|
||||||
|
<IconSitemap size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={deleteNode}
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
color="red"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
import {
|
||||||
|
useGetSidebarPagesQuery,
|
||||||
|
useGetPageTreeQuery,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import classes from "./subpages.module.css";
|
import classes from "./subpages.module.css";
|
||||||
@@ -12,16 +15,130 @@ import {
|
|||||||
} from "@/features/page/page.utils.ts";
|
} from "@/features/page/page.utils.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
import {
|
||||||
|
useSharedPageSubpages,
|
||||||
|
useSharedPageSubtree,
|
||||||
|
} from "@/features/share/hooks/use-shared-page-subpages";
|
||||||
|
import {
|
||||||
|
SubpageNode,
|
||||||
|
buildSubtree,
|
||||||
|
mapSharedNodes,
|
||||||
|
countNodes,
|
||||||
|
} from "./subpages-view.utils";
|
||||||
|
|
||||||
|
// Threshold above which the recursive tree shows a small count note. We never
|
||||||
|
// cap the data — this is only an informational hint for very large trees.
|
||||||
|
const LARGE_TREE_THRESHOLD = 300;
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: SubpageNode;
|
||||||
|
depth: number;
|
||||||
|
shareId?: string;
|
||||||
|
spaceSlug?: string;
|
||||||
|
// Threaded down from the variant component so a large tree does not create one
|
||||||
|
// i18n subscription (useTranslation) per rendered node.
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive renderer for a single node and its descendants. Indents each level
|
||||||
|
// by depth * 16px and reuses the same link/icon markup as the flat list.
|
||||||
|
function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={
|
||||||
|
shareId
|
||||||
|
? buildSharedPageUrl({
|
||||||
|
shareId,
|
||||||
|
pageSlugId: node.slugId,
|
||||||
|
pageTitle: node.title,
|
||||||
|
})
|
||||||
|
: buildPageUrl(spaceSlug, node.slugId, node.title)
|
||||||
|
}
|
||||||
|
underline="never"
|
||||||
|
className={styles.pageMentionLink}
|
||||||
|
draggable={false}
|
||||||
|
style={{ paddingLeft: depth * 16 }}
|
||||||
|
>
|
||||||
|
{node?.icon ? (
|
||||||
|
<span style={{ marginRight: "4px" }}>{node.icon}</span>
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
component="span"
|
||||||
|
size={18}
|
||||||
|
style={{ verticalAlign: "text-bottom" }}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={styles.pageMentionText}>
|
||||||
|
{node?.title || t("untitled")}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SubpagesView(props: NodeViewProps) {
|
export default function SubpagesView(props: NodeViewProps) {
|
||||||
const { editor } = props;
|
const { editor } = props;
|
||||||
const { spaceSlug, shareId } = useParams();
|
const { spaceSlug, shareId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const recursive: boolean = props.node.attrs.recursive ?? false;
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const currentPageId = editor.storage.pageId;
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
|
if (recursive) {
|
||||||
|
return (
|
||||||
|
<RecursiveSubpages
|
||||||
|
currentPageId={currentPageId}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatSubpages
|
||||||
|
currentPageId={currentPageId}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubpagesVariantProps {
|
||||||
|
currentPageId: string;
|
||||||
|
shareId?: string;
|
||||||
|
spaceSlug?: string;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlatSubpages({
|
||||||
|
currentPageId,
|
||||||
|
shareId,
|
||||||
|
spaceSlug,
|
||||||
|
t,
|
||||||
|
}: SubpagesVariantProps) {
|
||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||||
|
|
||||||
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecursiveSubpages({
|
||||||
|
currentPageId,
|
||||||
|
shareId,
|
||||||
|
spaceSlug,
|
||||||
|
t,
|
||||||
|
}: SubpagesVariantProps) {
|
||||||
|
// In a shared/public context reuse the already-loaded nested shared tree
|
||||||
|
// instead of issuing a /pages/tree request.
|
||||||
|
const sharedSubtree = useSharedPageSubtree(currentPageId);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGetPageTreeQuery(
|
||||||
|
shareId ? "" : currentPageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = useMemo<SubpageNode[]>(() => {
|
||||||
|
if (shareId) {
|
||||||
|
return mapSharedNodes(sharedSubtree);
|
||||||
|
}
|
||||||
|
if (!data) return [];
|
||||||
|
return buildSubtree(data, currentPageId);
|
||||||
|
}, [data, shareId, sharedSubtree, currentPageId]);
|
||||||
|
|
||||||
|
const total = useMemo(() => countNodes(tree), [tree]);
|
||||||
|
|
||||||
|
if (isLoading && !shareId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !shareId) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("Failed to load subpages")}
|
||||||
|
</Text>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("No subpages")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Stack gap={5}>
|
||||||
|
{tree.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{total > LARGE_TREE_THRESHOLD && (
|
||||||
|
<Text c="dimmed" size="xs" pt="xs">
|
||||||
|
{t("Showing {{count}} subpages", { count: total })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSubtree,
|
||||||
|
countNodes,
|
||||||
|
mapSharedNodes,
|
||||||
|
SubpageNode,
|
||||||
|
} from "./subpages-view.utils";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
|
||||||
|
// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
|
||||||
|
const page = (p: Partial<IPage> & { id: string }): IPage =>
|
||||||
|
({
|
||||||
|
slugId: `slug-${p.id}`,
|
||||||
|
title: `Title ${p.id}`,
|
||||||
|
icon: undefined,
|
||||||
|
position: "a0",
|
||||||
|
parentPageId: null,
|
||||||
|
...p,
|
||||||
|
}) as IPage;
|
||||||
|
|
||||||
|
const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
|
||||||
|
|
||||||
|
describe("buildSubtree", () => {
|
||||||
|
it("nests children under the root and excludes the root itself", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "a", parentPageId: "root", position: "a0" }),
|
||||||
|
page({ id: "b", parentPageId: "root", position: "a1" }),
|
||||||
|
page({ id: "a1", parentPageId: "a", position: "a0" }),
|
||||||
|
];
|
||||||
|
const tree = buildSubtree(pages, "root");
|
||||||
|
// Root is not rendered; only its descendants.
|
||||||
|
expect(ids(tree)).toEqual(["a", "b"]);
|
||||||
|
expect(ids(tree[0].children)).toEqual(["a1"]);
|
||||||
|
expect(tree[1].children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts each level by position", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "z", parentPageId: "root", position: "a2" }),
|
||||||
|
page({ id: "x", parentPageId: "root", position: "a0" }),
|
||||||
|
page({ id: "y", parentPageId: "root", position: "a1" }),
|
||||||
|
];
|
||||||
|
expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when the root is absent from the page set", () => {
|
||||||
|
const pages = [page({ id: "a", parentPageId: "missing-root" })];
|
||||||
|
expect(buildSubtree(pages, "missing-root")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently drops a node whose parent is absent (unreachable parent)", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "ok", parentPageId: "root" }),
|
||||||
|
page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
|
||||||
|
];
|
||||||
|
expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against self-parenting / attaching the root", () => {
|
||||||
|
const pages = [
|
||||||
|
// A (defensive) self-parented root must not attach to itself.
|
||||||
|
page({ id: "root", parentPageId: "root" }),
|
||||||
|
page({ id: "a", parentPageId: "root" }),
|
||||||
|
];
|
||||||
|
const tree = buildSubtree(pages, "root");
|
||||||
|
expect(ids(tree)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for empty input", () => {
|
||||||
|
expect(buildSubtree([], "root")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countNodes", () => {
|
||||||
|
it("counts every descendant across all levels", () => {
|
||||||
|
const tree: SubpageNode[] = [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
slugId: "s",
|
||||||
|
title: "A",
|
||||||
|
children: [
|
||||||
|
{ id: "a1", slugId: "s", title: "A1", children: [] },
|
||||||
|
{ id: "a2", slugId: "s", title: "A2", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "b", slugId: "s", title: "B", children: [] },
|
||||||
|
];
|
||||||
|
expect(countNodes(tree)).toBe(4);
|
||||||
|
expect(countNodes([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapSharedNodes", () => {
|
||||||
|
it("remaps value->id / name->title and keeps nested children", () => {
|
||||||
|
const shared = [
|
||||||
|
{
|
||||||
|
value: "p1",
|
||||||
|
slugId: "s1",
|
||||||
|
name: "Parent",
|
||||||
|
icon: "📁",
|
||||||
|
children: [
|
||||||
|
{ value: "c1", slugId: "sc1", name: "Child", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
const mapped = mapSharedNodes(shared);
|
||||||
|
expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
|
||||||
|
expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
// Normalized node shared by the flat and recursive subpages renderers so the
|
||||||
|
// same link/icon markup works for both API pages and shared-tree nodes.
|
||||||
|
export interface SubpageNode {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
children: SubpageNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subpage node carrying `position` so each level can be sorted in place.
|
||||||
|
export type SubpageNodeWithPos = SubpageNode & {
|
||||||
|
position: string;
|
||||||
|
children: SubpageNodeWithPos[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nested subtree (the current page's descendants) from the flat `IPage[]`
|
||||||
|
* the `/pages/tree` endpoint returns. Attaches each node to its parent by
|
||||||
|
* `parentPageId`, drops the root itself, and sorts every level by `position`.
|
||||||
|
*
|
||||||
|
* Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
|
||||||
|
* NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
|
||||||
|
* server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
|
||||||
|
* produces this list would itself loop before reaching the client, so the flat
|
||||||
|
* input is acyclic by construction. A node whose `parentPageId` points outside
|
||||||
|
* the result set (an unreachable parent) is silently dropped — it is, by
|
||||||
|
* definition, not a descendant of the root being rendered.
|
||||||
|
*/
|
||||||
|
export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
|
||||||
|
const byId = new Map<string, SubpageNodeWithPos>(
|
||||||
|
pages.map((p) => [
|
||||||
|
p.id,
|
||||||
|
{
|
||||||
|
id: p.id,
|
||||||
|
slugId: p.slugId,
|
||||||
|
title: p.title,
|
||||||
|
icon: p.icon,
|
||||||
|
position: p.position,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const p of pages) {
|
||||||
|
const node = byId.get(p.id);
|
||||||
|
const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
|
||||||
|
if (node && parent && p.id !== rootId) {
|
||||||
|
parent.children.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortRecursive = (
|
||||||
|
nodes: SubpageNodeWithPos[],
|
||||||
|
): SubpageNodeWithPos[] => {
|
||||||
|
const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
|
||||||
|
sorted.forEach((n) => sortRecursive(n.children));
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = byId.get(rootId);
|
||||||
|
return root ? sortRecursive(root.children) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
|
||||||
|
export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
id: node.value,
|
||||||
|
slugId: node.slugId,
|
||||||
|
title: node.name,
|
||||||
|
icon: node.icon,
|
||||||
|
children: node.children ? mapSharedNodes(node.children) : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count every descendant in a normalized subtree.
|
||||||
|
export function countNodes(nodes: SubpageNode[]): number {
|
||||||
|
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const Command = Extension.create({
|
|||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: '/',
|
char: '/',
|
||||||
|
// Keep the query alive through spaces so multi-word item labels
|
||||||
|
// (e.g. "Heading 1", "Math block") match instead of terminating the
|
||||||
|
// query and leaving literal "/Heading 1" text in the document.
|
||||||
|
allowSpaces: true,
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }) => {
|
||||||
props.command({ editor, range, props });
|
props.command({ editor, range, props });
|
||||||
},
|
},
|
||||||
@@ -23,7 +27,22 @@ const Command = Extension.create({
|
|||||||
if ($from.parent.type.name === 'codeBlock') {
|
if ($from.parent.type.name === 'codeBlock') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
// With `allowSpaces: true` a query that contains a space no longer
|
||||||
|
// terminates the suggestion on its own, so a space-bearing query that
|
||||||
|
// matches nothing (e.g. "/todo abc") would otherwise keep an empty
|
||||||
|
// popup logically active and leave the literal "/todo abc" text in the
|
||||||
|
// document, only dismissable via Escape. Deactivate the suggestion when
|
||||||
|
// no item matches the current query: returning false here removes the
|
||||||
|
// decoration, fires the popup's `onExit`, and lets subsequent keystrokes
|
||||||
|
// pass through normally — restoring the pre-`allowSpaces` behavior for
|
||||||
|
// non-matching queries while keeping multi-word matches (e.g.
|
||||||
|
// "/Heading 1") working.
|
||||||
|
const query = state.doc.textBetween(range.from + 1, range.to);
|
||||||
|
const groups = getSuggestionItems({ query });
|
||||||
|
const hasMatches = Object.values(groups).some(
|
||||||
|
(items) => items.length > 0,
|
||||||
|
);
|
||||||
|
return hasMatches;
|
||||||
},
|
},
|
||||||
} as Partial<SuggestionOptions>,
|
} as Partial<SuggestionOptions>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import {
|
||||||
|
getSpaceById,
|
||||||
|
getSpaces,
|
||||||
|
} from "@/features/space/services/space-service.ts";
|
||||||
|
import {
|
||||||
|
createPage,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service.ts";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
import {
|
||||||
|
GitmostBridge,
|
||||||
|
GitmostCreatePagePayload,
|
||||||
|
GitmostCreatePageResult,
|
||||||
|
GitmostListPagesPayload,
|
||||||
|
GitmostListPagesResult,
|
||||||
|
GitmostListSpacesResult,
|
||||||
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostUploadFileToEditor,
|
||||||
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
|
// editable, and connect its Yjs provider before giving up.
|
||||||
|
const GITMOST_EDITOR_READY_TIMEOUT_MS = 20000;
|
||||||
|
const GITMOST_EDITOR_POLL_INTERVAL_MS = 120;
|
||||||
|
|
||||||
|
// Poll the (default) jotai store until the editor for `pageId` is mounted,
|
||||||
|
// editable and its Yjs provider is connected. Resolves the live editor, or null
|
||||||
|
// on timeout. Reuses pageEditorAtom + yjsConnectionStatusAtom — the same signals
|
||||||
|
// PageEditor maintains. The storage.pageId check guards against matching a stale
|
||||||
|
// editor left over from the previously-open page.
|
||||||
|
function gitmostWaitForEditor(
|
||||||
|
pageId: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Editor | null> {
|
||||||
|
const store = getDefaultStore();
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
const editor = store.get(pageEditorAtom) as Editor | null;
|
||||||
|
const yjsStatus = store.get(yjsConnectionStatusAtom);
|
||||||
|
// `storage.pageId` is a custom field PageEditor.onCreate sets; it is not
|
||||||
|
// part of Tiptap's Storage type, so read it through an indexed cast.
|
||||||
|
const editorPageId = (
|
||||||
|
editor?.storage as unknown as Record<string, unknown> | undefined
|
||||||
|
)?.pageId;
|
||||||
|
const ready =
|
||||||
|
!!editor &&
|
||||||
|
!editor.isDestroyed &&
|
||||||
|
editor.isEditable &&
|
||||||
|
editorPageId === pageId &&
|
||||||
|
yjsStatus === WebSocketStatus.Connected;
|
||||||
|
if (ready) {
|
||||||
|
resolve(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(check, GITMOST_EDITOR_POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registers the global gitmost bridge methods that work WITHOUT an open page
|
||||||
|
// (listSpaces / listPages / createPageWithRecording). Mounted once at the
|
||||||
|
// app-shell level so the react-router navigate fn and the api-client are
|
||||||
|
// available even when no page editor is mounted. insertRecording stays in
|
||||||
|
// PageEditor (tied to the live editable editor). Renders nothing.
|
||||||
|
export default function GitmostGlobalBridge() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// The effect registers the bridge once; reading the latest navigate via a ref
|
||||||
|
// avoids a stale closure if react-router hands back a new function identity.
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
useEffect(() => {
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
|
||||||
|
w.gitmost = w.gitmost || {};
|
||||||
|
// Advertise the bridge version even before any page editor mounts; do not
|
||||||
|
// clobber a value already set by an active PageEditor.
|
||||||
|
if (typeof w.gitmost.version !== "number") w.gitmost.version = 1;
|
||||||
|
|
||||||
|
const listSpaces = async (): Promise<GitmostListSpacesResult> => {
|
||||||
|
try {
|
||||||
|
const res = await getSpaces({ limit: 100 });
|
||||||
|
const spaces = (res?.items ?? []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
}));
|
||||||
|
// v1 returns only the first page; flag truncation so the host knows
|
||||||
|
// more spaces exist.
|
||||||
|
const truncated = Boolean(res?.meta?.hasNextPage);
|
||||||
|
return { ok: true, spaces, truncated };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[gitmost] listSpaces failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "list-failed",
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Failed to list spaces",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPages = async (
|
||||||
|
payload: GitmostListPagesPayload,
|
||||||
|
): Promise<GitmostListPagesResult> => {
|
||||||
|
try {
|
||||||
|
const spaceId = payload?.spaceId;
|
||||||
|
if (!spaceId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "bad-args",
|
||||||
|
message: "spaceId is required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const res = await getSidebarPages({
|
||||||
|
spaceId,
|
||||||
|
pageId: payload?.parentPageId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
const pages = (res?.items ?? []).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
hasChildren: Boolean(p.hasChildren),
|
||||||
|
}));
|
||||||
|
// v1 returns only the first page of children; flag truncation so the
|
||||||
|
// host knows more exist.
|
||||||
|
const truncated = Boolean(res?.meta?.hasNextPage);
|
||||||
|
return { ok: true, pages, truncated };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[gitmost] listPages failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "list-failed",
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Failed to list pages",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPageWithRecording = async (
|
||||||
|
payload: GitmostCreatePagePayload,
|
||||||
|
): Promise<GitmostCreatePageResult> => {
|
||||||
|
try {
|
||||||
|
const { spaceId, parentPageId, title, base64, filename, mimeType } =
|
||||||
|
payload || ({} as GitmostCreatePagePayload);
|
||||||
|
|
||||||
|
if (!spaceId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no-space",
|
||||||
|
message: "spaceId is required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
// real reason is kept in `message`).
|
||||||
|
const decoded = gitmostDecodePayloadToFile({
|
||||||
|
base64,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
if ("error" in decoded) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message: decoded.error.message ?? "Invalid recording payload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the space slug (needed for router navigation); also a
|
||||||
|
// permission/existence probe -> no-space on failure.
|
||||||
|
let spaceSlug: string | undefined;
|
||||||
|
try {
|
||||||
|
const space = await getSpaceById(spaceId);
|
||||||
|
spaceSlug = space?.slug;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[gitmost] resolve space failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no-space",
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Space not found or no access",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!spaceSlug) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no-space",
|
||||||
|
message: "Space not found or no access",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the page (REST). Default title when none is provided.
|
||||||
|
const defaultTitle = `Recording ${new Date().toLocaleString()}`;
|
||||||
|
let page;
|
||||||
|
try {
|
||||||
|
// `spaceId` is accepted by the create-page endpoint but is not part of
|
||||||
|
// the shared IPage type; cast to satisfy the createPage signature.
|
||||||
|
page = await createPage({
|
||||||
|
spaceId,
|
||||||
|
parentPageId: parentPageId ?? undefined,
|
||||||
|
title: title ?? defaultTitle,
|
||||||
|
} as any);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[gitmost] createPage failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "create-failed",
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Failed to create page",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!page?.id || !page?.slugId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "create-failed",
|
||||||
|
message: "Failed to create page",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the shared Yjs status before navigating. The atom is global and
|
||||||
|
// is NOT reset when a PageEditor unmounts, so it can still hold
|
||||||
|
// "connected" from a previously-open page; clearing it ensures the
|
||||||
|
// readiness gate below waits for the NEW page's provider to connect.
|
||||||
|
getDefaultStore().set(yjsConnectionStatusAtom, "");
|
||||||
|
|
||||||
|
// Navigate via the router (no full reload).
|
||||||
|
navigateRef.current(buildPageUrl(spaceSlug, page.slugId, page.title));
|
||||||
|
|
||||||
|
// Wait for the new page's editor: mounted, editable, Yjs connected.
|
||||||
|
const editor = await gitmostWaitForEditor(
|
||||||
|
page.id,
|
||||||
|
GITMOST_EDITOR_READY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!editor) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "editor-timeout",
|
||||||
|
message: "Editor was not ready in time",
|
||||||
|
// Return pageId so the host can still surface the created page.
|
||||||
|
pageId: page.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same insert path as insertRecording.
|
||||||
|
const result = await gitmostUploadFileToEditor(
|
||||||
|
editor,
|
||||||
|
page.id,
|
||||||
|
decoded.file,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message: result.message ?? "Failed to insert recording",
|
||||||
|
pageId: page.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, pageId: page.id };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
err?.message ??
|
||||||
|
"Failed to create page with recording",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
w.gitmost.listSpaces = listSpaces;
|
||||||
|
w.gitmost.listPages = listPages;
|
||||||
|
w.gitmost.createPageWithRecording = createPageWithRecording;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Only remove our own registrations (defensive against a future second
|
||||||
|
// mount having replaced them).
|
||||||
|
if (w.gitmost) {
|
||||||
|
if (w.gitmost.listSpaces === listSpaces) delete w.gitmost.listSpaces;
|
||||||
|
if (w.gitmost.listPages === listPages) delete w.gitmost.listPages;
|
||||||
|
if (w.gitmost.createPageWithRecording === createPageWithRecording) {
|
||||||
|
delete w.gitmost.createPageWithRecording;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
263
apps/client/src/features/editor/gitmost/gitmost-recording.ts
Normal file
263
apps/client/src/features/editor/gitmost/gitmost-recording.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
|
import { formatBytes } from "@/lib";
|
||||||
|
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
||||||
|
|
||||||
|
// --- gitmost native bridge: shared types & helpers ------------------------
|
||||||
|
// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
|
||||||
|
// WKWebView). This module holds the parts shared between the open-page bridge
|
||||||
|
// (insertRecording, in page-editor.tsx) and the global bridge (gitmost-global-
|
||||||
|
// bridge.tsx): payload decoding/validation and the audio-insert pipeline, so
|
||||||
|
// both apply identical rules without depending on editor internals.
|
||||||
|
|
||||||
|
export interface GitmostInsertRecordingPayload {
|
||||||
|
base64: string; // raw file bytes, base64 (no data: prefix)
|
||||||
|
filename: string;
|
||||||
|
mimeType: string; // must be an audio/* type
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostInsertRecordingResult {
|
||||||
|
ok: boolean;
|
||||||
|
attachmentId?: string;
|
||||||
|
// Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
|
||||||
|
error?: string;
|
||||||
|
message?: string; // human-readable, may be surfaced by the host
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostSpaceSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostListSpacesResult {
|
||||||
|
ok: boolean;
|
||||||
|
spaces?: GitmostSpaceSummary[];
|
||||||
|
// v1 lists only the first page of spaces; true when more exist server-side.
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostListPagesPayload {
|
||||||
|
spaceId: string;
|
||||||
|
parentPageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostPageSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
hasChildren: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostListPagesResult {
|
||||||
|
ok: boolean;
|
||||||
|
pages?: GitmostPageSummary[];
|
||||||
|
// v1 lists only the first page of children; true when more exist server-side.
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostCreatePagePayload {
|
||||||
|
spaceId: string;
|
||||||
|
parentPageId?: string; // omit/null = space root
|
||||||
|
title?: string; // default "Recording <timestamp>"
|
||||||
|
base64: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitmostCreatePageResult {
|
||||||
|
ok: boolean;
|
||||||
|
pageId?: string;
|
||||||
|
// Machine-readable code: "no-space" | "create-failed" | "editor-timeout" | "insert-failed"
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full bridge surface exposed on `window.gitmost`. Writers attach a subset
|
||||||
|
// (Partial), so readonly/share pages and no-page states are valid.
|
||||||
|
export interface GitmostBridge {
|
||||||
|
ready: boolean;
|
||||||
|
version: number;
|
||||||
|
insertRecording: (
|
||||||
|
payload: GitmostInsertRecordingPayload,
|
||||||
|
) => Promise<GitmostInsertRecordingResult>;
|
||||||
|
listSpaces: () => Promise<GitmostListSpacesResult>;
|
||||||
|
listPages: (payload: GitmostListPagesPayload) => Promise<GitmostListPagesResult>;
|
||||||
|
createPageWithRecording: (
|
||||||
|
payload: GitmostCreatePagePayload,
|
||||||
|
) => Promise<GitmostCreatePageResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
|
||||||
|
// oversized payload can be rejected before the buffer is allocated.
|
||||||
|
export function gitmostEstimateBase64Bytes(base64: string): number {
|
||||||
|
const len = base64.length;
|
||||||
|
if (len === 0) return 0;
|
||||||
|
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
||||||
|
return Math.floor((len * 3) / 4) - padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
|
||||||
|
// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
|
||||||
|
// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
|
||||||
|
// with no embedded whitespace (per the native-host contract). Throws
|
||||||
|
// InvalidCharacterError on malformed input.
|
||||||
|
export function gitmostBase64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < base64.length; i += CHUNK) {
|
||||||
|
const binary = atob(base64.slice(i, i + CHUNK));
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let j = 0; j < binary.length; j++) {
|
||||||
|
bytes[j] = binary.charCodeAt(j);
|
||||||
|
}
|
||||||
|
parts.push(bytes);
|
||||||
|
total += bytes.length;
|
||||||
|
}
|
||||||
|
// Back the result with an explicit ArrayBuffer so the view is typed
|
||||||
|
// Uint8Array<ArrayBuffer> (not ArrayBufferLike), which `new File([...])`
|
||||||
|
// accepts as a BlobPart under the lib.dom typings.
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(total));
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
out.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode + validate a recording payload into a File, or return an error result.
|
||||||
|
// Shared so insertRecording (open page) and createPageWithRecording (no page
|
||||||
|
// open) apply identical validation. Error codes: "bad-type" | "too-large" |
|
||||||
|
// "insert-failed".
|
||||||
|
export function gitmostDecodePayloadToFile(
|
||||||
|
payload: GitmostInsertRecordingPayload,
|
||||||
|
): { file: File } | { error: GitmostInsertRecordingResult } {
|
||||||
|
const { filename, mimeType } =
|
||||||
|
payload || ({} as GitmostInsertRecordingPayload);
|
||||||
|
let base64 = payload?.base64;
|
||||||
|
|
||||||
|
if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
|
||||||
|
return {
|
||||||
|
error: { ok: false, error: "bad-type", message: "Not an audio file" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof base64 !== "string" || base64.length === 0) {
|
||||||
|
return {
|
||||||
|
error: { ok: false, error: "insert-failed", message: "Empty payload" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensively strip an accidental data:*;base64, prefix.
|
||||||
|
const marker = base64.indexOf("base64,");
|
||||||
|
if (base64.startsWith("data:") && marker !== -1) {
|
||||||
|
base64 = base64.slice(marker + "base64,".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeLimit = getFileUploadSizeLimit();
|
||||||
|
// Reject oversized payloads before allocating the decode buffer.
|
||||||
|
if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
ok: false,
|
||||||
|
error: "too-large",
|
||||||
|
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Uint8Array<ArrayBuffer>;
|
||||||
|
try {
|
||||||
|
bytes = gitmostBase64ToBytes(base64);
|
||||||
|
} catch (decodeErr: any) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message: decodeErr?.message ?? "Invalid base64 payload",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new File([bytes], filename || "recording", { type: mimeType });
|
||||||
|
|
||||||
|
// Exact size check (the pre-decode estimate is approximate).
|
||||||
|
if (file.size > sizeLimit) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
ok: false,
|
||||||
|
error: "too-large",
|
||||||
|
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an already-decoded recording File into a live editor via the existing
|
||||||
|
// audio pipeline (placeholder -> POST /api/files/upload -> `audio` node,
|
||||||
|
// Yjs-synced). Returns the attachment id on success.
|
||||||
|
export async function gitmostUploadFileToEditor(
|
||||||
|
editor: Editor,
|
||||||
|
pageId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<GitmostInsertRecordingResult> {
|
||||||
|
try {
|
||||||
|
// Insert at the cursor, falling back to the end of the document.
|
||||||
|
const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
|
||||||
|
|
||||||
|
// uploadAudioAction returns the attachment on success and undefined when
|
||||||
|
// the upload failed (the pipeline swallows the upload error and shows its
|
||||||
|
// own notification).
|
||||||
|
const attachment = (await (uploadAudioAction(
|
||||||
|
file,
|
||||||
|
editor,
|
||||||
|
pos,
|
||||||
|
pageId,
|
||||||
|
) as unknown as Promise<{ id?: string } | undefined>));
|
||||||
|
|
||||||
|
if (attachment?.id) {
|
||||||
|
return { ok: true, attachmentId: attachment.id };
|
||||||
|
}
|
||||||
|
return { ok: false, error: "insert-failed", message: "Upload failed" };
|
||||||
|
} catch (err: any) {
|
||||||
|
// Never swallow: log the raw error and surface the real reason.
|
||||||
|
console.error("[gitmost] audio upload into editor failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
|
// a result code.
|
||||||
|
export async function gitmostInsertRecordingIntoEditor(
|
||||||
|
editor: Editor | null,
|
||||||
|
pageId: string,
|
||||||
|
payload: GitmostInsertRecordingPayload,
|
||||||
|
): Promise<GitmostInsertRecordingResult> {
|
||||||
|
try {
|
||||||
|
// Only a live, editable editor may receive a recording.
|
||||||
|
if (!editor || editor.isDestroyed || !editor.isEditable) {
|
||||||
|
return { ok: false, error: "no-editor", message: "No editable page open" };
|
||||||
|
}
|
||||||
|
const decoded = gitmostDecodePayloadToFile(payload);
|
||||||
|
if ("error" in decoded) return decoded.error;
|
||||||
|
return await gitmostUploadFileToEditor(editor, pageId, decoded.file);
|
||||||
|
} catch (err: any) {
|
||||||
|
// The bridge must never throw — surface any unexpected failure as a code.
|
||||||
|
console.error("[gitmost] insertRecording failed", err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "insert-failed",
|
||||||
|
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import { TableHandlesLayer } from "@/features/editor/components/table/handle/tab
|
|||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
|
import AudioMenu from "@/features/editor/components/audio/audio-menu.tsx";
|
||||||
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
|
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
|
||||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,12 @@ import { queryClient } from "@/main.tsx";
|
|||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
||||||
|
import {
|
||||||
|
GitmostBridge,
|
||||||
|
GitmostInsertRecordingPayload,
|
||||||
|
GitmostInsertRecordingResult,
|
||||||
|
gitmostInsertRecordingIntoEditor,
|
||||||
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
@@ -113,6 +120,13 @@ export default function PageEditor({
|
|||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
|
// Always holds the latest collab token. The provider effect below runs once
|
||||||
|
// per pageId, so a handler created inside it would otherwise close over a
|
||||||
|
// stale `collabQuery`. Reading the ref gives the current token instead.
|
||||||
|
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
collabTokenRef.current = collabQuery?.token;
|
||||||
|
}, [collabQuery?.token]);
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -167,20 +181,33 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onAuthenticationFailedHandler = () => {
|
const onAuthenticationFailedHandler = () => {
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
// Read the latest token via the ref (the closure-captured `collabQuery`
|
||||||
const now = Date.now().valueOf() / 1000;
|
// may be stale). Guard the decode: a missing or unparseable token must
|
||||||
const isTokenExpired = now >= payload.exp;
|
// not throw "Invalid token specified" and should trigger a refresh so
|
||||||
if (isTokenExpired) {
|
// the editor reconnects even when the initial token fetch failed.
|
||||||
refetchCollabToken().then((result) => {
|
const token = collabTokenRef.current;
|
||||||
if (result.data?.token) {
|
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||||
socket.disconnect();
|
if (token) {
|
||||||
setTimeout(() => {
|
try {
|
||||||
remote.configuration.token = result.data.token;
|
// A token that decodes but lacks a numeric `exp` must be treated as
|
||||||
socket.connect();
|
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
||||||
}, 100);
|
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
||||||
}
|
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
||||||
});
|
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
||||||
|
} catch {
|
||||||
|
needsRefresh = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!needsRefresh) return;
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
socket.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
socket.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const remote = new HocuspocusProvider({
|
const remote = new HocuspocusProvider({
|
||||||
websocketProvider: socket,
|
websocketProvider: socket,
|
||||||
@@ -333,6 +360,39 @@ export default function PageEditor({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose the gitmost native bridge only while an editable page editor is
|
||||||
|
// mounted. Registering/tearing down here ties `ready` + `insertRecording`
|
||||||
|
// to the lifetime of the current editable editor: readonly/share pages and
|
||||||
|
// page switches re-run this effect (deps: live editable flag + pageId),
|
||||||
|
// recreating the closure over the active editor/pageId so a recording always
|
||||||
|
// targets whatever page is active at call time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !editor.isEditable) return;
|
||||||
|
|
||||||
|
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
|
||||||
|
w.gitmost = w.gitmost || {};
|
||||||
|
w.gitmost.version = 1;
|
||||||
|
w.gitmost.ready = true;
|
||||||
|
|
||||||
|
const insertRecording = (
|
||||||
|
payload: GitmostInsertRecordingPayload,
|
||||||
|
): Promise<GitmostInsertRecordingResult> =>
|
||||||
|
gitmostInsertRecordingIntoEditor(editor, pageId, payload);
|
||||||
|
|
||||||
|
w.gitmost.insertRecording = insertRecording;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Only tear down if our registration is still the active one. With
|
||||||
|
// React's mount-before-unmount ordering, a newer PageEditor instance may
|
||||||
|
// have already replaced the bridge; clearing it here would disable the
|
||||||
|
// live editor's bridge.
|
||||||
|
if (w.gitmost && w.gitmost.insertRecording === insertRecording) {
|
||||||
|
w.gitmost.ready = false;
|
||||||
|
delete w.gitmost.insertRecording;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editor, pageId, editorIsEditable]);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
|
|
||||||
@@ -441,6 +501,7 @@ export default function PageEditor({
|
|||||||
<TableHandlesLayer editor={editor} />
|
<TableHandlesLayer editor={editor} />
|
||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
|
<AudioMenu editor={editor} />
|
||||||
<PdfMenu editor={editor} />
|
<PdfMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
.codeBlock {
|
.codeBlock {
|
||||||
|
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
|
||||||
|
editable contentDOM is first) is lifted back above the code via `order`. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: var(--mantine-radius-default);
|
border-radius: var(--mantine-radius-default);
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
ToggleFavoriteParams,
|
ToggleFavoriteParams,
|
||||||
} from "../services/favorite-service";
|
} from "../services/favorite-service";
|
||||||
import { FavoriteType } from "../types/favorite.types";
|
import { FavoriteType } from "../types/favorite.types";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
|
|||||||
|
|
||||||
export function useAddFavoriteMutation() {
|
export function useAddFavoriteMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||||
mutationFn: (data) => addFavorite(data),
|
mutationFn: (data) => addFavorite(data),
|
||||||
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["favorites", variables.type],
|
queryKey: ["favorites", variables.type],
|
||||||
});
|
});
|
||||||
|
// Notify on success so the action gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Added to favorites") });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveFavoriteMutation() {
|
export function useRemoveFavoriteMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||||
mutationFn: (data) => removeFavorite(data),
|
mutationFn: (data) => removeFavorite(data),
|
||||||
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["favorites", variables.type],
|
queryKey: ["favorites", variables.type],
|
||||||
});
|
});
|
||||||
|
// Notify on success so the action gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Removed from favorites") });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { zod4Resolver } from 'mantine-form-zod-resolver';
|
import { zod4Resolver } from 'mantine-form-zod-resolver';
|
||||||
|
|
||||||
const formSchema = z.object({
|
type FormValues = {
|
||||||
name: z.string().trim().min(2).max(100),
|
name: string;
|
||||||
description: z.string().max(500),
|
description: string;
|
||||||
});
|
};
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function CreateGroupForm() {
|
export function CreateGroupForm() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
|
|||||||
const [userIds, setUserIds] = useState<string[]>([]);
|
const [userIds, setUserIds] = useState<string[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Build the schema with friendly, translated validation messages (issue #130)
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, t("Group name must be at least 2 characters"))
|
||||||
|
.max(100, t("Group name must be 100 characters or fewer")),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(500, t("Description must be 500 characters or fewer")),
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zod4Resolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function GroupMembersList() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
centered: true,
|
centered: true,
|
||||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: () => onRemove(userId),
|
onConfirm: () => onRemove(userId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { canCreatePage } from "./can-create-page.ts";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { SpaceRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
|
// Unit tests for `canCreatePage` (new-note-button.tsx). The home screen has no
|
||||||
|
// active space, so the "New note" button resolves its target from the user's
|
||||||
|
// writable spaces. This predicate mirrors the server space-ability mapping
|
||||||
|
// (ADMIN/WRITER can manage pages, READER is read-only). The /spaces list endpoint
|
||||||
|
// only returns membership.role (not CASL permissions), so a regression here would
|
||||||
|
// either hide the button for legitimate writers or offer it to read-only members.
|
||||||
|
|
||||||
|
function spaceWithRole(role?: SpaceRole): ISpace {
|
||||||
|
// Only `membership.role` is consulted by the predicate; the rest is filler.
|
||||||
|
return {
|
||||||
|
membership: role ? ({ role } as any) : undefined,
|
||||||
|
} as ISpace;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("canCreatePage", () => {
|
||||||
|
it("is true for ADMIN and WRITER roles", () => {
|
||||||
|
expect(canCreatePage(spaceWithRole(SpaceRole.ADMIN))).toBe(true);
|
||||||
|
expect(canCreatePage(spaceWithRole(SpaceRole.WRITER))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for the READER role", () => {
|
||||||
|
expect(canCreatePage(spaceWithRole(SpaceRole.READER))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when membership / role is missing", () => {
|
||||||
|
expect(canCreatePage(spaceWithRole(undefined))).toBe(false);
|
||||||
|
expect(canCreatePage({} as ISpace)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters an empty space list down to nothing writable", () => {
|
||||||
|
const spaces: ISpace[] = [
|
||||||
|
spaceWithRole(SpaceRole.READER),
|
||||||
|
spaceWithRole(undefined),
|
||||||
|
];
|
||||||
|
expect(spaces.filter(canCreatePage)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/client/src/features/home/components/can-create-page.ts
Normal file
15
apps/client/src/features/home/components/can-create-page.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { SpaceRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
|
// The /spaces list endpoint returns membership.role but NOT membership.permissions
|
||||||
|
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
|
||||||
|
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
|
||||||
|
// for the current user when their role is ADMIN or WRITER.
|
||||||
|
//
|
||||||
|
// Extracted from new-note-button.tsx into this pure sibling module so it can be
|
||||||
|
// unit-tested without importing the component (whose dependency chain pulls in
|
||||||
|
// main.tsx and renders the whole app at import time).
|
||||||
|
export function canCreatePage(space: ISpace): boolean {
|
||||||
|
const role = space.membership?.role;
|
||||||
|
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) {
|
|||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(page?.space.name)}
|
color={getInitialsColor(page?.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
tt="none"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function FavoritesPages({ spaceId }: Props) {
|
|||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(fav.space.name)}
|
color={getInitialsColor(fav.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
tt="none"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(fav.space.slug)}
|
to={getSpaceUrl(fav.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
|
|||||||
@@ -6,18 +6,9 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
|||||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
import { SpaceRole } from "@/lib/types.ts";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { canCreatePage } from "./can-create-page.ts";
|
||||||
// The /spaces list endpoint returns membership.role but NOT membership.permissions
|
|
||||||
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
|
|
||||||
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
|
|
||||||
// for the current user when their role is ADMIN or WRITER.
|
|
||||||
function canCreatePage(space: ISpace): boolean {
|
|
||||||
const role = space.membership?.role;
|
|
||||||
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prominent home-screen action to create a new note (page). Because the home
|
// 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
|
// screen has no active space, the target space is resolved from the user's
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function useAddLabelsMutation(pageId: string | undefined) {
|
|||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||||
|
// Notify on success so the action gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Label added") });
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
|
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||||
|
// Notify on success so the action gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Label removed") });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Schema } from "@tiptap/pm/model";
|
||||||
|
import { computeHistoryDiff } from "./history-diff.ts";
|
||||||
|
|
||||||
|
// Unit tests for `computeHistoryDiff` (history-diff.ts) — the pure core extracted
|
||||||
|
// from history-editor.tsx. Given the editor schema plus old/new ProseMirror
|
||||||
|
// document JSON it produces {decorationSet, added, deleted, total}: inline
|
||||||
|
// decorations for text edits, whole-node decorations for added block nodes
|
||||||
|
// (image/table), widget "ghosts" for deleted block nodes (callout), and an empty
|
||||||
|
// diff for the first version or malformed JSON.
|
||||||
|
//
|
||||||
|
// We drive it with a hand-built ProseMirror schema rather than the real
|
||||||
|
// `mainExtensions` because importing the editor extensions pulls in the whole app
|
||||||
|
// (main.tsx) at module load. The schema below mirrors the relevant shape: a doc of
|
||||||
|
// block content, an `image` block atom and a `table` block treated as whole-node
|
||||||
|
// diffs, and a `callout` block treated as a deletable whole node.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: "block+" },
|
||||||
|
paragraph: {
|
||||||
|
group: "block",
|
||||||
|
content: "inline*",
|
||||||
|
toDOM: () => ["p", 0],
|
||||||
|
},
|
||||||
|
callout: {
|
||||||
|
group: "block",
|
||||||
|
content: "inline*",
|
||||||
|
toDOM: () => ["div", { class: "callout" }, 0],
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
attrs: { src: { default: "" } },
|
||||||
|
toDOM: (node) => ["img", { src: node.attrs.src }],
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
group: "block",
|
||||||
|
content: "paragraph+",
|
||||||
|
toDOM: () => ["table", ["tbody", 0]],
|
||||||
|
},
|
||||||
|
text: { group: "inline" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const para = (text: string) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: text ? [{ type: "text", text }] : [],
|
||||||
|
});
|
||||||
|
const docOf = (...blocks: any[]) => ({ type: "doc", content: blocks });
|
||||||
|
|
||||||
|
describe("computeHistoryDiff", () => {
|
||||||
|
it("returns an empty diff (counts 0) when there is no previous version", () => {
|
||||||
|
const diff = computeHistoryDiff(schema, docOf(para("hello")), undefined);
|
||||||
|
expect(diff.added).toBe(0);
|
||||||
|
expect(diff.deleted).toBe(0);
|
||||||
|
expect(diff.total).toBe(0);
|
||||||
|
expect(diff.decorationSet.find()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty diff when content is missing", () => {
|
||||||
|
const diff = computeHistoryDiff(schema, undefined, docOf(para("x")));
|
||||||
|
expect(diff.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits inline decorations and counts for a text edit", () => {
|
||||||
|
const prev = docOf(para("hello world"));
|
||||||
|
const next = docOf(para("hello brave world"));
|
||||||
|
const diff = computeHistoryDiff(schema, next, prev);
|
||||||
|
|
||||||
|
expect(diff.added).toBeGreaterThan(0);
|
||||||
|
const decos = diff.decorationSet.find();
|
||||||
|
expect(decos.length).toBeGreaterThan(0);
|
||||||
|
// An inline text addition is rendered with the inline-added class.
|
||||||
|
const classes = decos.map((d) => (d.spec as any)?.class ?? (d as any).type?.attrs?.class);
|
||||||
|
const hasInline = JSON.stringify(decos).includes("history-diff-added") ||
|
||||||
|
classes.some((c) => c === "history-diff-added");
|
||||||
|
expect(hasInline).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an added image as a whole-node addition", () => {
|
||||||
|
const prev = docOf(para("text"));
|
||||||
|
const next = docOf(para("text"), { type: "image", attrs: { src: "a.png" } });
|
||||||
|
const diff = computeHistoryDiff(schema, next, prev);
|
||||||
|
expect(diff.added).toBeGreaterThan(0);
|
||||||
|
expect(JSON.stringify(diff.decorationSet.find())).toContain(
|
||||||
|
"history-diff-node-added",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an added table as a whole-node addition", () => {
|
||||||
|
const prev = docOf(para("text"));
|
||||||
|
const next = docOf(para("text"), {
|
||||||
|
type: "table",
|
||||||
|
content: [para("cell")],
|
||||||
|
});
|
||||||
|
const diff = computeHistoryDiff(schema, next, prev);
|
||||||
|
expect(diff.added).toBeGreaterThan(0);
|
||||||
|
expect(JSON.stringify(diff.decorationSet.find())).toContain(
|
||||||
|
"history-diff-node-added",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a widget ghost for a deleted callout", () => {
|
||||||
|
const prev = docOf(para("text"), {
|
||||||
|
type: "callout",
|
||||||
|
content: [{ type: "text", text: "warning" }],
|
||||||
|
});
|
||||||
|
const next = docOf(para("text"));
|
||||||
|
const diff = computeHistoryDiff(schema, next, prev);
|
||||||
|
expect(diff.deleted).toBeGreaterThan(0);
|
||||||
|
// The deleted whole node produces a widget decoration (toDOM callback).
|
||||||
|
const decos = diff.decorationSet.find();
|
||||||
|
expect(decos.some((d) => (d as any).type?.toDOM || (d as any).type?.widget)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to an empty diff (no throw) on malformed version JSON", () => {
|
||||||
|
const malformed = { type: "doc", content: [{ type: "nonexistent-node" }] };
|
||||||
|
expect(() =>
|
||||||
|
computeHistoryDiff(schema, malformed, docOf(para("x"))),
|
||||||
|
).not.toThrow();
|
||||||
|
const diff = computeHistoryDiff(schema, malformed, docOf(para("x")));
|
||||||
|
expect(diff.total).toBe(0);
|
||||||
|
expect(diff.decorationSet.find()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/client/src/features/page-history/components/history-diff.ts
Normal file
168
apps/client/src/features/page-history/components/history-diff.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import { DOMSerializer, Node, Schema } from "@tiptap/pm/model";
|
||||||
|
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||||
|
import { recreateTransform } from "@docmost/editor-ext";
|
||||||
|
|
||||||
|
export interface HistoryDiff {
|
||||||
|
decorationSet: DecorationSet;
|
||||||
|
added: number;
|
||||||
|
deleted: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block-level nodes that are diffed as a whole ("this image/table/callout was
|
||||||
|
// added/removed") instead of by inline character ranges.
|
||||||
|
const SPECIAL_NODE_TYPES = new Set([
|
||||||
|
"image",
|
||||||
|
"attachment",
|
||||||
|
"video",
|
||||||
|
"excalidraw",
|
||||||
|
"drawio",
|
||||||
|
"mermaid",
|
||||||
|
"mathBlock",
|
||||||
|
"mathInline",
|
||||||
|
"table",
|
||||||
|
"details",
|
||||||
|
"callout",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pure core of the history diff (extracted from history-editor.tsx, behaviour
|
||||||
|
// preserving): given the editor schema and two ProseMirror document JSONs, return
|
||||||
|
// the decoration set plus added/deleted/total counts. The widget decorations carry
|
||||||
|
// lazy DOM-building callbacks (only run by ProseMirror at render time), so this
|
||||||
|
// function itself does no DOM work and needs no live editor instance.
|
||||||
|
//
|
||||||
|
// `previousContent` undefined -> first version, so there is nothing to diff
|
||||||
|
// (empty decorations, all counts 0). Malformed JSON that throws while building
|
||||||
|
// nodes falls back to the same empty diff so the caller can still render plain
|
||||||
|
// content without crashing.
|
||||||
|
export function computeHistoryDiff(
|
||||||
|
schema: Schema,
|
||||||
|
content: any,
|
||||||
|
previousContent?: any,
|
||||||
|
): HistoryDiff {
|
||||||
|
const empty: HistoryDiff = {
|
||||||
|
decorationSet: DecorationSet.empty,
|
||||||
|
added: 0,
|
||||||
|
deleted: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!content || !previousContent) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oldContent = Node.fromJSON(schema, previousContent);
|
||||||
|
const newContent = Node.fromJSON(schema, content);
|
||||||
|
|
||||||
|
const tr = recreateTransform(oldContent, newContent, {
|
||||||
|
complexSteps: false,
|
||||||
|
wordDiffs: true,
|
||||||
|
simplifyDiff: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeSet = ChangeSet.create(oldContent).addSteps(
|
||||||
|
tr.doc,
|
||||||
|
tr.mapping.maps,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const changes = simplifyChanges(changeSet.changes, newContent);
|
||||||
|
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
let addedCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
let changeIndex = 0;
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.toB > change.fromB) {
|
||||||
|
changeIndex++;
|
||||||
|
const currentIndex = changeIndex;
|
||||||
|
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||||
|
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||||
|
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||||
|
foundSpecialNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundSpecialNode) {
|
||||||
|
const special = foundSpecialNode as { node: Node; pos: number };
|
||||||
|
const nodeEnd = special.pos + special.node.nodeSize;
|
||||||
|
decorations.push(
|
||||||
|
Decoration.node(special.pos, nodeEnd, {
|
||||||
|
class: "history-diff-node-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(change.fromB, change.toB, {
|
||||||
|
class: "history-diff-added",
|
||||||
|
"data-diff-index": String(currentIndex),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addedCount += 1;
|
||||||
|
}
|
||||||
|
if (change.toA > change.fromA) {
|
||||||
|
changeIndex++;
|
||||||
|
const currentIndex = changeIndex;
|
||||||
|
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||||
|
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||||
|
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
|
||||||
|
const nodeEnd = pos + node.nodeSize;
|
||||||
|
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||||
|
foundDeletedNode = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundDeletedNode) {
|
||||||
|
const deletedNode = foundDeletedNode as { node: Node; pos: number };
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "history-diff-node-deleted";
|
||||||
|
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
const serializer = DOMSerializer.fromSchema(schema);
|
||||||
|
const dom = serializer.serializeNode(deletedNode.node);
|
||||||
|
wrapper.appendChild(dom);
|
||||||
|
return wrapper;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const deletedText = oldContent.textBetween(
|
||||||
|
change.fromA,
|
||||||
|
change.toA,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
if (deletedText) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(change.fromB, () => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "history-diff-deleted";
|
||||||
|
span.setAttribute("data-diff-index", String(currentIndex));
|
||||||
|
span.textContent = deletedText;
|
||||||
|
return span;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deletedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decorationSet = DecorationSet.create(newContent, decorations);
|
||||||
|
const total = addedCount + deletedCount;
|
||||||
|
return { decorationSet, added: addedCount, deleted: deletedCount, total };
|
||||||
|
} catch (e) {
|
||||||
|
// Malformed version JSON: fall back to a plain (no-diff) render.
|
||||||
|
console.error("History diff failed:", e);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,9 @@ import { useEffect } from "react";
|
|||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from "@mantine/core";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { DecorationSet } from "@tiptap/pm/view";
|
||||||
import historyClasses from "./css/history.module.css";
|
import historyClasses from "./css/history.module.css";
|
||||||
import { recreateTransform } from "@docmost/editor-ext";
|
import { computeHistoryDiff } from "./history-diff.ts";
|
||||||
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
diffCountsAtom,
|
diffCountsAtom,
|
||||||
@@ -36,142 +34,18 @@ export function HistoryEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !content) return;
|
if (!editor || !content) return;
|
||||||
|
|
||||||
let decorationSet = DecorationSet.empty;
|
// Pure diff computation lives in history-diff.ts; the component keeps the
|
||||||
let addedCount = 0;
|
// editor side-effects (rendering the new content + wiring decorations).
|
||||||
let deletedCount = 0;
|
const { decorationSet, added, deleted, total } = computeHistoryDiff(
|
||||||
|
editor.schema,
|
||||||
|
content,
|
||||||
|
previousContent,
|
||||||
|
);
|
||||||
|
|
||||||
if (previousContent) {
|
editor.commands.setContent(content);
|
||||||
try {
|
|
||||||
const schema = editor.schema;
|
|
||||||
const oldContent = Node.fromJSON(schema, previousContent);
|
|
||||||
const newContent = Node.fromJSON(schema, content);
|
|
||||||
|
|
||||||
const tr = recreateTransform(oldContent, newContent, {
|
|
||||||
complexSteps: false,
|
|
||||||
wordDiffs: true,
|
|
||||||
simplifyDiff: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const changeSet = ChangeSet.create(oldContent).addSteps(
|
|
||||||
tr.doc,
|
|
||||||
tr.mapping.maps,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const changes = simplifyChanges(changeSet.changes, newContent);
|
|
||||||
|
|
||||||
editor.commands.setContent(content);
|
|
||||||
|
|
||||||
const specialNodeTypes = new Set([
|
|
||||||
"image",
|
|
||||||
"attachment",
|
|
||||||
"video",
|
|
||||||
"excalidraw",
|
|
||||||
"drawio",
|
|
||||||
"mermaid",
|
|
||||||
"mathBlock",
|
|
||||||
"mathInline",
|
|
||||||
"table",
|
|
||||||
"details",
|
|
||||||
"callout",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const decorations: Decoration[] = [];
|
|
||||||
let changeIndex = 0;
|
|
||||||
|
|
||||||
for (const change of changes) {
|
|
||||||
if (change.toB > change.fromB) {
|
|
||||||
changeIndex++;
|
|
||||||
const currentIndex = changeIndex;
|
|
||||||
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
|
||||||
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
|
||||||
if (specialNodeTypes.has(node.type.name)) {
|
|
||||||
const nodeEnd = pos + node.nodeSize;
|
|
||||||
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
|
||||||
foundSpecialNode = { node, pos };
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundSpecialNode) {
|
|
||||||
const nodeEnd =
|
|
||||||
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
|
|
||||||
decorations.push(
|
|
||||||
Decoration.node(foundSpecialNode.pos, nodeEnd, {
|
|
||||||
class: "history-diff-node-added",
|
|
||||||
"data-diff-index": String(currentIndex),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.inline(change.fromB, change.toB, {
|
|
||||||
class: "history-diff-added",
|
|
||||||
"data-diff-index": String(currentIndex),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
addedCount += 1;
|
|
||||||
}
|
|
||||||
if (change.toA > change.fromA) {
|
|
||||||
changeIndex++;
|
|
||||||
const currentIndex = changeIndex;
|
|
||||||
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
|
||||||
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
|
||||||
if (specialNodeTypes.has(node.type.name)) {
|
|
||||||
const nodeEnd = pos + node.nodeSize;
|
|
||||||
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
|
||||||
foundDeletedNode = { node, pos };
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundDeletedNode) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(change.fromB, () => {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "history-diff-node-deleted";
|
|
||||||
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
|
||||||
const serializer = DOMSerializer.fromSchema(schema);
|
|
||||||
const dom = serializer.serializeNode(foundDeletedNode!.node);
|
|
||||||
wrapper.appendChild(dom);
|
|
||||||
return wrapper;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const deletedText = oldContent.textBetween(
|
|
||||||
change.fromA,
|
|
||||||
change.toA,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
if (deletedText) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(change.fromB, () => {
|
|
||||||
const span = document.createElement("span");
|
|
||||||
span.className = "history-diff-deleted";
|
|
||||||
span.setAttribute("data-diff-index", String(currentIndex));
|
|
||||||
span.textContent = deletedText;
|
|
||||||
return span;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deletedCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decorationSet = DecorationSet.create(newContent, decorations);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("History diff failed:", e);
|
|
||||||
editor.commands.setContent(content);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor.commands.setContent(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = addedCount + deletedCount;
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
setDiffCounts({ added, deleted, total });
|
||||||
|
|
||||||
editor.setOptions({
|
editor.setOptions({
|
||||||
editorProps: {
|
editorProps: {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip, Badge } from "@mantine/core";
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./css/history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import {
|
|
||||||
activeAiChatIdAtom,
|
|
||||||
aiChatWindowOpenAtom,
|
|
||||||
aiChatDraftAtom,
|
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
@@ -26,87 +20,6 @@ interface HistoryItemProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Badge marking a version written by the AI agent (provenance C3 / §7.4). It is
|
|
||||||
* ADDITIVE — shown next to the human author, never replacing them. When the
|
|
||||||
* version carries an `aiChatId`, clicking the badge deep-links into that chat:
|
|
||||||
* it sets the active-chat atom, opens the floating AI-chat window, and closes
|
|
||||||
* the history modal. The click is contained (stopPropagation) so it does not
|
|
||||||
* also trigger the row's version-select.
|
|
||||||
*/
|
|
||||||
function AiAgentBadge({
|
|
||||||
authorName,
|
|
||||||
aiChatId,
|
|
||||||
}: {
|
|
||||||
authorName?: string;
|
|
||||||
aiChatId?: string | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
|
||||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
|
||||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
|
||||||
|
|
||||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
|
||||||
name: authorName ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const openChat = useCallback(
|
|
||||||
(event: React.SyntheticEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!aiChatId) return;
|
|
||||||
setActiveChatId(aiChatId);
|
|
||||||
// Switching to another chat must start with a clean composer — clear any
|
|
||||||
// unsent draft so it does not leak from the previously open chat.
|
|
||||||
setDraft("");
|
|
||||||
setAiChatWindowOpen(true);
|
|
||||||
setHistoryModalOpen(false);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
aiChatId,
|
|
||||||
setActiveChatId,
|
|
||||||
setDraft,
|
|
||||||
setAiChatWindowOpen,
|
|
||||||
setHistoryModalOpen,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color="violet"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
|
||||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
|
||||||
{...(aiChatId
|
|
||||||
? {
|
|
||||||
// Keep the default Badge root element (not a <button>) to avoid an
|
|
||||||
// invalid <button>-in-<button> nesting inside the history row's
|
|
||||||
// UnstyledButton; expose it as an accessible button via role/keyboard.
|
|
||||||
role: "button",
|
|
||||||
tabIndex: 0,
|
|
||||||
onClick: openChat,
|
|
||||||
onKeyDown: (event: React.KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
openChat(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
{t("AI-agent")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={tooltip} withArrow>
|
|
||||||
{badge}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
historyItem,
|
historyItem,
|
||||||
index,
|
index,
|
||||||
@@ -115,6 +28,8 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(historyItem.id, index);
|
onSelect(historyItem.id, index);
|
||||||
}, [onSelect, historyItem.id, index]);
|
}, [onSelect, historyItem.id, index]);
|
||||||
@@ -188,6 +103,9 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
<AiAgentBadge
|
<AiAgentBadge
|
||||||
authorName={historyItem.lastUpdatedBy?.name}
|
authorName={historyItem.lastUpdatedBy?.name}
|
||||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||||
|
// The history row owns the modal: close it when the badge deep-links
|
||||||
|
// into the chat (the badge no longer reaches into page-history).
|
||||||
|
onActivate={() => setHistoryModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconMessage,
|
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
@@ -102,18 +101,21 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
{!readOnly && <PageEditModeToggle size="xs" />}
|
{!readOnly && <PageEditModeToggle size="xs" />}
|
||||||
|
|
||||||
{!workspaceSharingDisabled && <ShareModal readOnly={readOnly ?? false} />}
|
{/* Hide the Share entry point for readers; the toggle inside is inert
|
||||||
|
without edit permission, so gate it like other edit-only actions
|
||||||
|
(issue #133) */}
|
||||||
|
{!readOnly && !workspaceSharingDisabled && (
|
||||||
|
<ShareModal readOnly={false} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Button
|
||||||
<ActionIcon
|
variant="subtle"
|
||||||
variant="subtle"
|
color="dark"
|
||||||
color="dark"
|
size="compact-sm"
|
||||||
aria-label={t("Comments")}
|
{...commentsTriggerProps}
|
||||||
{...commentsTriggerProps}
|
>
|
||||||
>
|
{t("Comments")}
|
||||||
<IconMessage size={20} stroke={2} />
|
</Button>
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -286,7 +288,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
leftSection={<IconArrowRight size={16} />}
|
leftSection={<IconArrowRight size={16} />}
|
||||||
onClick={openMovePageModal}
|
onClick={openMovePageModal}
|
||||||
>
|
>
|
||||||
{t("Move")}
|
{t("Move to space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
getDeletedPages,
|
||||||
restorePage,
|
restorePage,
|
||||||
|
getSpaceTree,
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@@ -273,7 +274,10 @@ export function useRestorePageMutation() {
|
|||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({ message: t("Failed to restore page"), color: "red" });
|
notifications.show({
|
||||||
|
message: t("Failed to restore page"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,10 +288,10 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
queryFn: ({ pageParam }) =>
|
||||||
|
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,11 +299,23 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
|
return getSidebarPages({
|
||||||
|
spaceId: data.spaceId,
|
||||||
|
cursor: pageParam,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetPageTreeQuery(pageId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-tree", pageId],
|
||||||
|
queryFn: () => getSpaceTree({ pageId }),
|
||||||
|
enabled: !!pageId,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,12 +329,17 @@ export function usePageBreadcrumbsQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
export async function fetchAllAncestorChildren(
|
||||||
|
params: SidebarPagesParams,
|
||||||
|
// `fresh: true` forces a server refetch (staleTime 0) — used by the reconnect
|
||||||
|
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||||
|
opts?: { fresh?: boolean },
|
||||||
|
) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
queryKey: ["sidebar-pages", params],
|
||||||
queryFn: () => getAllSidebarPages(params),
|
queryFn: () => getAllSidebarPages(params),
|
||||||
staleTime: 30 * 60 * 1000,
|
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allItems = response.pages.flatMap((page) => page.items);
|
const allItems = response.pages.flatMap((page) => page.items);
|
||||||
@@ -337,11 +358,15 @@ export function useRecentChangesQuery(spaceId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
|
export function useCreatedByQuery(params?: {
|
||||||
|
userId?: string;
|
||||||
|
spaceId?: string;
|
||||||
|
}) {
|
||||||
const { userId, spaceId } = params ?? {};
|
const { userId, spaceId } = params ?? {};
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["pages-created-by-user", { userId, spaceId }],
|
queryKey: ["pages-created-by-user", { userId, spaceId }],
|
||||||
queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
|
queryFn: ({ pageParam }) =>
|
||||||
|
getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||||
@@ -363,7 +388,18 @@ export function useDeletedPagesQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate every cached page-subtree (the recursive `subpages` node, issue
|
||||||
|
* #150). Called from each tree-structure cache helper below so a create / move /
|
||||||
|
* rename / delete (local OR websocket-echoed) refreshes any open recursive tree.
|
||||||
|
* Keyed loosely (`["page-tree"]` prefix) so all subtrees are caught.
|
||||||
|
*/
|
||||||
|
function invalidatePageTree() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["page-tree"] });
|
||||||
|
}
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||||
|
invalidatePageTree();
|
||||||
const newPage: Partial<IPage> = {
|
const newPage: Partial<IPage> = {
|
||||||
creatorId: data.creatorId,
|
creatorId: data.creatorId,
|
||||||
hasChildren: data.hasChildren,
|
hasChildren: data.hasChildren,
|
||||||
@@ -478,6 +514,7 @@ export function invalidateOnUpdatePage(
|
|||||||
title: string,
|
title: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
) {
|
) {
|
||||||
|
invalidatePageTree();
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if (parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = ["root-sidebar-pages", spaceId];
|
||||||
@@ -516,6 +553,7 @@ export function updateCacheOnMovePage(
|
|||||||
newParentId: string | null,
|
newParentId: string | null,
|
||||||
pageData: Partial<IPage>,
|
pageData: Partial<IPage>,
|
||||||
) {
|
) {
|
||||||
|
invalidatePageTree();
|
||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
@@ -633,6 +671,7 @@ export function updateCacheOnMovePage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
export function invalidateOnDeletePage(pageId: string) {
|
||||||
|
invalidatePageTree();
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export async function getAllSidebarPages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpaceTree(params: {
|
export async function getSpaceTree(params: {
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
}): Promise<IPage[]> {
|
}): Promise<IPage[]> {
|
||||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
className={classes.actionIcon}
|
className={classes.actionIcon}
|
||||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
openExportModal();
|
openExportModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Export page")}
|
{t("Export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
openMovePageModal();
|
openMovePageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Move")}
|
{t("Move to space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function SpaceTreeRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
<span className={classes.text}>{node.name || t("Untitled")}</span>
|
||||||
|
|
||||||
{node.isTemplate === true && (
|
{node.isTemplate === true && (
|
||||||
<Tooltip label={t("Template")} withArrow>
|
<Tooltip label={t("Template")} withArrow>
|
||||||
@@ -297,7 +297,7 @@ function CreateNode({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
className={classes.actionIcon}
|
className={classes.actionIcon}
|
||||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user