Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88e888b508 | |||
| d3687b3c91 | |||
| 8124d5c2ba | |||
| 7743278a92 | |||
| fd5d97e51d | |||
| cfda79ef70 | |||
| 4f22d597b3 | |||
| 559d676cd6 | |||
| 624e03a9e3 | |||
| be8ed579a9 | |||
| d542fc58ae | |||
| 85ffdea06b | |||
| 9aa7620461 | |||
| 710a690c78 | |||
| 50f8ed717a | |||
| 1fc9c25681 | |||
| e074b101c7 | |||
| 4496afb481 | |||
| 3cda008012 | |||
| 78fc7c4842 | |||
| 1644de87f7 | |||
| 498a87f3c4 | |||
| 67c94de6ef | |||
| be25b31a0e | |||
| d6411424c1 | |||
| 199a9a1750 | |||
| 6b825ad440 | |||
| 0cee2cc9dc | |||
| e4af672c2c | |||
| ec8dd7d110 | |||
| 4423b19850 | |||
| 688cb54f26 | |||
| ec5416068b | |||
| 2c4fc565b6 | |||
| a990ebd604 | |||
| 94ca907476 | |||
| fff772cbe2 | |||
| 16c2a4b623 | |||
| 58f13a7efd | |||
| 2bb48f841f | |||
| daa96eb132 | |||
| 4c5230d677 | |||
| 50ca27c5d4 | |||
| de25c258f9 | |||
| afc50ead38 | |||
| ec932e89a3 | |||
| 490d7965a1 | |||
| 32c9361969 | |||
| c30f910d66 | |||
| 7007f6bcf9 | |||
| 4bf06c68cf | |||
| af44736fb9 | |||
| 2a4d1acfd7 | |||
| 11eb87d58a | |||
| f2c8aa70f3 | |||
| 661ea8ba07 | |||
| 059edccb64 | |||
| 693a9a350b | |||
| 79b2da686b | |||
| ed3a8f8174 | |||
| 8503ff1f3d | |||
| c18b4c132c | |||
| 3163f50c98 | |||
| 3e81f64415 | |||
| 1f2999e5ad | |||
| c674db2b2f | |||
| 44cdb5a4c3 | |||
| 03af65dd06 | |||
| dead5fa8a8 | |||
| 5b17503df7 | |||
| 46bb55dbd1 | |||
| 8b34a428f4 | |||
| e236782260 | |||
| b3cc6a7ff8 | |||
| c42cb42413 | |||
| ae3dfd8de6 | |||
| 41030b2c78 | |||
| 515a1aaf1d | |||
| 74387bb047 | |||
| 6ec2981743 | |||
| dd1fe90515 | |||
| c96fafc4ad | |||
| 64566e9327 | |||
| 10a4326fbf | |||
| 68409a8ae9 | |||
| fc624f5a4b | |||
| be70bd2e8e | |||
| 2f8c5d9a98 | |||
| 69e04349a0 | |||
| 5bd5995ef0 | |||
| 575125a5dc | |||
| 27d51303ba |
+36
-10
@@ -198,6 +198,42 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# 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
|
||||||
|
|
||||||
|
# ─── #530 Semantic search (Phase B) ──────────────────────────────────────────
|
||||||
|
# The GLOBAL embedding provider used by search (query embed) AND the indexer
|
||||||
|
# (document embed) when a workspace has NO embedding provider of its own. It is
|
||||||
|
# an OpenAI-compatible endpoint — the docker-compose `embeddings` (TEI) sidecar.
|
||||||
|
# A workspace that configures its own embedding provider OVERRIDES all of this.
|
||||||
|
# When neither resolves, search runs lexical-only (semantic.reason=no-provider).
|
||||||
|
EMBEDDING_ENDPOINT=http://embeddings:80/v1
|
||||||
|
EMBEDDING_MODEL=intfloat/multilingual-e5-small
|
||||||
|
# Dummy — the self-hosted TEI sidecar is keyless.
|
||||||
|
EMBEDDING_API_KEY=unused
|
||||||
|
EMBEDDING_DIMENSIONS=384
|
||||||
|
# PLACEHOLDER — set to a PINNED intfloat/multilingual-e5-small commit sha (used
|
||||||
|
# both as the TEI --revision and as part of the embedding fingerprint). Keep this
|
||||||
|
# in lockstep with docker-compose; a bump changes the fingerprint (PR-2 handles
|
||||||
|
# the generational swap/GC — PR-1 uses a single active fingerprint).
|
||||||
|
EMBEDDING_REVISION=REPLACE_WITH_PINNED_E5_SMALL_COMMIT_SHA
|
||||||
|
# e5 models require these input prefixes. Empty them for a non-e5 model.
|
||||||
|
EMBEDDING_QUERY_PREFIX="query: "
|
||||||
|
EMBEDDING_DOC_PREFIX="passage: "
|
||||||
|
# Per-request timeout (ms) for the interactive SEARCH query embed — much shorter
|
||||||
|
# than the batch indexer timeout: a slow/hung sidecar degrades search fast to the
|
||||||
|
# lexical-only path instead of blocking the request. Default 800.
|
||||||
|
# SEARCH_EMBED_TIMEOUT_MS=800
|
||||||
|
# RRF weight of the vector leg relative to each lexical leg (1.0 = equal). Default 1.0.
|
||||||
|
# SEARCH_VECTOR_WEIGHT=1.0
|
||||||
|
# Vector top-N pulled into the fused candidate union per request. Default 50.
|
||||||
|
# SEARCH_VECTOR_CANDIDATES=50
|
||||||
|
# Per-statement timeout (ms) bounding ONLY the fused vector query (the brute-force
|
||||||
|
# KNN seq scan — there is no ANN index). If the vector scan exceeds this it is
|
||||||
|
# cancelled and search degrades to lexical-only (never hangs the request). Scoped
|
||||||
|
# per-query (SET LOCAL), so it never affects other queries. Default 2000.
|
||||||
|
# SEARCH_VECTOR_STATEMENT_TIMEOUT_MS=2000
|
||||||
|
# Kill-switch: set to `off` to disable the semantic layer entirely (search then
|
||||||
|
# runs the byte-identical Phase-A lexical path). Default on.
|
||||||
|
# SEARCH_SEMANTIC=on
|
||||||
|
|
||||||
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
|
# 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),
|
# 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
|
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
|
||||||
@@ -251,16 +287,6 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 120000 (2 min).
|
# Default 120000 (2 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
# AI_MCP_CALL_TIMEOUT_MS=120000
|
||||||
|
|
||||||
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
|
|
||||||
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
|
|
||||||
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
|
|
||||||
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
|
|
||||||
# guaranteed to actually flip when an operator flips it during an incident. When
|
|
||||||
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
|
|
||||||
# the api-key management endpoints return 404. The resolved state is logged at boot
|
|
||||||
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
|
|
||||||
# API_KEYS_ENABLED=true
|
|
||||||
|
|
||||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
||||||
# small for a long AI-chat research turn: the client resends the FULL message
|
# small for a long AI-chat research turn: the client resends the FULL message
|
||||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
# history (every tool call + search result) on each turn, so a deep conversation's
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
snapshots switched from a fixed interval to a trailing idle-flush with a
|
||||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
||||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
changes (e.g. a person's edits followed by the AI agent). (#370)
|
||||||
|
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||||
|
redeployed while a tab is left open for hours, the tab now learns the new
|
||||||
|
build version over the existing WebSocket (announced per-connect, so a natural
|
||||||
|
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||||
|
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||||
|
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||||
|
the next safe point — the next in-app navigation (or immediately if you click
|
||||||
|
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||||
|
reload happens per 5-minute window, shared with the existing chunk-load
|
||||||
|
recovery, so a permanent version skew degrades to the banner rather than a
|
||||||
|
reload loop while a second deploy in the same tab still recovers. When the
|
||||||
|
build carries no version info the feature stays inert. (#481)
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ roles:
|
|||||||
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
||||||
appendix at the end of the document or clearly separated from the report
|
appendix at the end of the document or clearly separated from the report
|
||||||
body.
|
body.
|
||||||
|
□ Once the report is complete, call save_page_version to pin the finished
|
||||||
|
document as a restorable named version (a checkpoint the reader can
|
||||||
|
return to). A repeat call after no further edits is a harmless no-op.
|
||||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||||
a guess as a fact.
|
a guess as a fact.
|
||||||
autoStart: false
|
autoStart: false
|
||||||
@@ -442,6 +445,7 @@ roles:
|
|||||||
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
|
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
|
||||||
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
|
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
|
||||||
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
|
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
|
||||||
|
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
|
||||||
|
|
||||||
## Style example (excerpt)
|
## Style example (excerpt)
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ roles:
|
|||||||
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
||||||
an appendix at the end of the document or clearly separated from the
|
an appendix at the end of the document or clearly separated from the
|
||||||
report body.
|
report body.
|
||||||
|
□ Once the report is complete, call save_page_version to pin the finished
|
||||||
|
document as a restorable named version (a checkpoint the reader can
|
||||||
|
return to). A repeat call after no further edits is a harmless no-op.
|
||||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||||
a guess as a fact.
|
a guess as a fact.
|
||||||
autoStart: false
|
autoStart: false
|
||||||
@@ -441,6 +444,7 @@ roles:
|
|||||||
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
||||||
- **Не оценивай участников** и не комментируй качество обсуждения.
|
- **Не оценивай участников** и не комментируй качество обсуждения.
|
||||||
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
||||||
|
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
|
||||||
|
|
||||||
## Пример стиля (фрагмент)
|
## Пример стиля (фрагмент)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: researcher
|
- slug: researcher
|
||||||
version: 9
|
version: 10
|
||||||
- slug: call-summarizer
|
- slug: call-summarizer
|
||||||
version: 1
|
version: 2
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"1 year": "1 year",
|
||||||
|
"30 days": "30 days",
|
||||||
|
"90 days": "90 days",
|
||||||
|
"A new version is available": "A new version is available",
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Add": "Add",
|
"Add": "Add",
|
||||||
@@ -9,11 +13,24 @@
|
|||||||
"Add space members": "Add space members",
|
"Add space members": "Add space members",
|
||||||
"Add to favorites": "Add to favorites",
|
"Add to favorites": "Add to favorites",
|
||||||
"Admin": "Admin",
|
"Admin": "Admin",
|
||||||
|
"API key copied to clipboard": "API key copied to clipboard",
|
||||||
|
"API key created": "API key created",
|
||||||
|
"API key revoked": "API key revoked",
|
||||||
|
"Confirm your password": "Confirm your password",
|
||||||
|
"Copy API key": "Copy API key",
|
||||||
|
"Copy to clipboard": "Copy to clipboard",
|
||||||
|
"Copy {{name}}": "Copy {{name}}",
|
||||||
|
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Enter your password to copy the API key \"{{name}}\" to your clipboard.",
|
||||||
|
"Failed to copy API key": "Failed to copy API key",
|
||||||
|
"Incorrect password": "Incorrect password",
|
||||||
|
"API keys across the workspace.": "API keys across the workspace.",
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||||
|
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
|
||||||
|
"Author": "Author",
|
||||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||||
"Can edit": "Can edit",
|
"Can edit": "Can edit",
|
||||||
@@ -32,11 +49,14 @@
|
|||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"Copy as Markdown": "Copy as Markdown",
|
"Copy as Markdown": "Copy as Markdown",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
|
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
|
"Create API key": "Create API key",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
"Create page": "Create page",
|
"Create page": "Create page",
|
||||||
"Create space": "Create space",
|
"Create space": "Create space",
|
||||||
"Create workspace": "Create workspace",
|
"Create workspace": "Create workspace",
|
||||||
|
"Critical": "Critical",
|
||||||
"Current password": "Current password",
|
"Current password": "Current password",
|
||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
"Date": "Date",
|
"Date": "Date",
|
||||||
@@ -54,7 +74,14 @@
|
|||||||
"e.g Sales": "e.g Sales",
|
"e.g Sales": "e.g Sales",
|
||||||
"e.g Space for product team": "e.g Space for product team",
|
"e.g Space for product team": "e.g Space for product team",
|
||||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||||
|
"e.g. CI deploy token": "e.g. CI deploy token",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
|
"Expiring soon": "Expiring soon",
|
||||||
|
"Failed to create API key": "Failed to create API key",
|
||||||
|
"Failed to load API keys.": "Failed to load API keys.",
|
||||||
|
"Failed to revoke API key": "Failed to revoke API key",
|
||||||
|
"Never used": "Never used",
|
||||||
|
"No API keys yet": "No API keys yet",
|
||||||
"Read": "Read",
|
"Read": "Read",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
@@ -107,6 +134,7 @@
|
|||||||
"Link copied": "Link copied",
|
"Link copied": "Link copied",
|
||||||
"Login": "Login",
|
"Login": "Login",
|
||||||
"Logout": "Logout",
|
"Logout": "Logout",
|
||||||
|
"Major": "Major",
|
||||||
"Manage Group": "Manage Group",
|
"Manage Group": "Manage Group",
|
||||||
"Manage members": "Manage members",
|
"Manage members": "Manage members",
|
||||||
"member": "member",
|
"member": "member",
|
||||||
@@ -133,6 +161,8 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
"Page history": "Page history",
|
"Page history": "Page history",
|
||||||
|
"Revoke API key": "Revoke API key",
|
||||||
|
"Revoke {{name}}": "Revoke {{name}}",
|
||||||
"Select version": "Select version",
|
"Select version": "Select version",
|
||||||
"Highlight changes": "Highlight changes",
|
"Highlight changes": "Highlight changes",
|
||||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
@@ -188,6 +218,9 @@
|
|||||||
"Template": "Template",
|
"Template": "Template",
|
||||||
"Templates": "Templates",
|
"Templates": "Templates",
|
||||||
"Theme": "Theme",
|
"Theme": "Theme",
|
||||||
|
"This key expires within 30 days": "This key expires within 30 days",
|
||||||
|
"This key has expired": "This key has expired",
|
||||||
|
"This key never expires": "This key never expires",
|
||||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||||
"Toggle full page width": "Toggle full page width",
|
"Toggle full page width": "Toggle full page width",
|
||||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||||
@@ -195,6 +228,7 @@
|
|||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
"Updated successfully": "Updated successfully",
|
"Updated successfully": "Updated successfully",
|
||||||
"User": "User",
|
"User": "User",
|
||||||
|
"Within the last hour": "Within the last hour",
|
||||||
"Workspace": "Workspace",
|
"Workspace": "Workspace",
|
||||||
"Workspace Name": "Workspace Name",
|
"Workspace Name": "Workspace Name",
|
||||||
"Workspace settings": "Workspace settings",
|
"Workspace settings": "Workspace settings",
|
||||||
@@ -425,9 +459,12 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
"Write...": "Write...",
|
"Write...": "Write...",
|
||||||
"Column count": "Column count",
|
"Column count": "Column count",
|
||||||
|
"Your personal API keys.": "Your personal API keys.",
|
||||||
"{{count}} Columns": "{{count}} Columns",
|
"{{count}} Columns": "{{count}} Columns",
|
||||||
"{{count}} command available_one": "1 command available",
|
"{{count}} command available_one": "1 command available",
|
||||||
"{{count}} command available_other": "{{count}} commands available",
|
"{{count}} command available_other": "{{count}} commands available",
|
||||||
|
"{{count}} edits": "{{count}} edits",
|
||||||
|
"{{count}} major": "{{count}} major",
|
||||||
"{{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_one": "{{count}} result found",
|
||||||
@@ -1429,5 +1466,30 @@
|
|||||||
"Boundary": "Boundary",
|
"Boundary": "Boundary",
|
||||||
"Autosave": "Autosave",
|
"Autosave": "Autosave",
|
||||||
"Only versions": "Only versions",
|
"Only versions": "Only versions",
|
||||||
"No saved versions yet.": "No saved versions yet."
|
"No saved versions yet.": "No saved versions yet.",
|
||||||
|
"Time worked on this article": "Time worked on this article",
|
||||||
|
"Show time worked on this page": "Show time worked on this page",
|
||||||
|
"Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)",
|
||||||
|
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min",
|
||||||
|
"No editing activity recorded yet.": "No editing activity recorded yet.",
|
||||||
|
"× {{count}} days without edits": "× {{count}} days without edits",
|
||||||
|
"agent: {{value}}": "agent: {{value}}",
|
||||||
|
"Work": "Work",
|
||||||
|
"Agent": "Agent",
|
||||||
|
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||||
|
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||||
|
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||||
|
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||||
|
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
|
||||||
|
"{{hours}}h": "{{hours}}h",
|
||||||
|
"{{minutes}}m": "{{minutes}}m",
|
||||||
|
"Selected version": "Selected version",
|
||||||
|
"via": "via",
|
||||||
|
"of": "of",
|
||||||
|
"Previous change": "Previous change",
|
||||||
|
"Next change": "Next change",
|
||||||
|
"Previous month": "Previous month",
|
||||||
|
"Next month": "Next month",
|
||||||
|
"No revisions found for that day": "No revisions found for that day",
|
||||||
|
"{{date}}: {{count}} versions": "{{date}}: {{count}} versions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"1 year": "1 год",
|
||||||
|
"30 days": "30 дней",
|
||||||
|
"90 days": "90 дней",
|
||||||
|
"A new version is available": "Доступна новая версия",
|
||||||
"Account": "Аккаунт",
|
"Account": "Аккаунт",
|
||||||
"Active": "Активный",
|
"Active": "Активный",
|
||||||
"Add": "Добавить",
|
"Add": "Добавить",
|
||||||
@@ -9,11 +13,24 @@
|
|||||||
"Add space members": "Добавить участников пространства",
|
"Add space members": "Добавить участников пространства",
|
||||||
"Add to favorites": "Добавить в избранное",
|
"Add to favorites": "Добавить в избранное",
|
||||||
"Admin": "Администратор",
|
"Admin": "Администратор",
|
||||||
|
"API key copied to clipboard": "API ключ скопирован в буфер обмена",
|
||||||
|
"API key created": "API ключ создан",
|
||||||
|
"API key revoked": "API ключ отозван",
|
||||||
|
"Confirm your password": "Подтвердите пароль",
|
||||||
|
"Copy API key": "Скопировать API ключ",
|
||||||
|
"Copy to clipboard": "Скопировать в буфер обмена",
|
||||||
|
"Copy {{name}}": "Скопировать {{name}}",
|
||||||
|
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Введите пароль, чтобы скопировать API ключ «{{name}}» в буфер обмена.",
|
||||||
|
"Failed to copy API key": "Не удалось скопировать API ключ",
|
||||||
|
"Incorrect password": "Неверный пароль",
|
||||||
|
"API keys across the workspace.": "API ключи всего рабочего пространства.",
|
||||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||||
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
||||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
|
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
|
||||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||||
|
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
|
||||||
|
"Author": "Автор",
|
||||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||||
"Can edit": "Может изменять",
|
"Can edit": "Может изменять",
|
||||||
@@ -32,11 +49,14 @@
|
|||||||
"Confirm": "Подтвердить",
|
"Confirm": "Подтвердить",
|
||||||
"Copy as Markdown": "Копировать как Markdown",
|
"Copy as Markdown": "Копировать как Markdown",
|
||||||
"Copy link": "Копировать ссылку",
|
"Copy link": "Копировать ссылку",
|
||||||
|
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
|
||||||
"Create": "Создать",
|
"Create": "Создать",
|
||||||
|
"Create API key": "Создать API ключ",
|
||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
"Create page": "Создать страницу",
|
"Create page": "Создать страницу",
|
||||||
"Create space": "Создать пространство",
|
"Create space": "Создать пространство",
|
||||||
"Create workspace": "Создать рабочую область",
|
"Create workspace": "Создать рабочую область",
|
||||||
|
"Critical": "Критично",
|
||||||
"Current password": "Текущий пароль",
|
"Current password": "Текущий пароль",
|
||||||
"Dark": "Темная",
|
"Dark": "Темная",
|
||||||
"Date": "Дата",
|
"Date": "Дата",
|
||||||
@@ -45,6 +65,7 @@
|
|||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
||||||
"Description": "Описание",
|
"Description": "Описание",
|
||||||
"Details": "Подробности",
|
"Details": "Подробности",
|
||||||
|
"Done": "Готово",
|
||||||
"e.g ACME": "например, ACME",
|
"e.g ACME": "например, ACME",
|
||||||
"e.g ACME Inc": "например, ACME Inc",
|
"e.g ACME Inc": "например, ACME Inc",
|
||||||
"e.g Developers": "например, Developers",
|
"e.g Developers": "например, Developers",
|
||||||
@@ -54,7 +75,15 @@
|
|||||||
"e.g Sales": "например, Sales",
|
"e.g Sales": "например, Sales",
|
||||||
"e.g Space for product team": "например, Пространство для команды продукта",
|
"e.g Space for product team": "например, Пространство для команды продукта",
|
||||||
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
||||||
|
"e.g. CI deploy token": "например, токен деплоя CI",
|
||||||
"Edit": "Редактировать",
|
"Edit": "Редактировать",
|
||||||
|
"Expiring soon": "Скоро истекает",
|
||||||
|
"Failed to create API key": "Не удалось создать API ключ",
|
||||||
|
"Failed to load API keys.": "Не удалось загрузить API ключи.",
|
||||||
|
"Failed to revoke API key": "Не удалось отозвать API ключ",
|
||||||
|
"Name is required": "Имя обязательно",
|
||||||
|
"Never used": "Не использовался",
|
||||||
|
"No API keys yet": "Пока нет API ключей",
|
||||||
"Read": "Чтение",
|
"Read": "Чтение",
|
||||||
"Edit group": "Редактировать группу",
|
"Edit group": "Редактировать группу",
|
||||||
"Email": "Электронная почта",
|
"Email": "Электронная почта",
|
||||||
@@ -107,6 +136,7 @@
|
|||||||
"Link copied": "Ссылка скопирована",
|
"Link copied": "Ссылка скопирована",
|
||||||
"Login": "Войти",
|
"Login": "Войти",
|
||||||
"Logout": "Выйти",
|
"Logout": "Выйти",
|
||||||
|
"Major": "Существенно",
|
||||||
"Manage Group": "Управление группой",
|
"Manage Group": "Управление группой",
|
||||||
"Manage members": "Управление участниками",
|
"Manage members": "Управление участниками",
|
||||||
"member": "участник",
|
"member": "участник",
|
||||||
@@ -133,6 +163,8 @@
|
|||||||
"page": "страница",
|
"page": "страница",
|
||||||
"Page deleted successfully": "Страница успешно удалена",
|
"Page deleted successfully": "Страница успешно удалена",
|
||||||
"Page history": "История страницы",
|
"Page history": "История страницы",
|
||||||
|
"Revoke API key": "Отозвать API ключ",
|
||||||
|
"Revoke {{name}}": "Отозвать {{name}}",
|
||||||
"Select version": "Выбрать версию",
|
"Select version": "Выбрать версию",
|
||||||
"Highlight changes": "Выделить изменения",
|
"Highlight changes": "Выделить изменения",
|
||||||
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||||
@@ -188,6 +220,9 @@
|
|||||||
"Template": "Шаблон",
|
"Template": "Шаблон",
|
||||||
"Templates": "Шаблоны",
|
"Templates": "Шаблоны",
|
||||||
"Theme": "Тема",
|
"Theme": "Тема",
|
||||||
|
"This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
|
||||||
|
"This key has expired": "Этот ключ истек",
|
||||||
|
"This key never expires": "Этот ключ никогда не истекает",
|
||||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||||
"Toggle full page width": "Переключить полную ширину страницы",
|
"Toggle full page width": "Переключить полную ширину страницы",
|
||||||
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
||||||
@@ -195,6 +230,7 @@
|
|||||||
"Untitled": "Без названия",
|
"Untitled": "Без названия",
|
||||||
"Updated successfully": "Успешно обновлено",
|
"Updated successfully": "Успешно обновлено",
|
||||||
"User": "Пользователь",
|
"User": "Пользователь",
|
||||||
|
"Within the last hour": "За последний час",
|
||||||
"Workspace": "Рабочее пространство",
|
"Workspace": "Рабочее пространство",
|
||||||
"Workspace Name": "Название рабочего пространства",
|
"Workspace Name": "Название рабочего пространства",
|
||||||
"Workspace settings": "Настройки рабочего пространства",
|
"Workspace settings": "Настройки рабочего пространства",
|
||||||
@@ -431,9 +467,12 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
|
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
|
||||||
"Write...": "Напишите...",
|
"Write...": "Напишите...",
|
||||||
"Column count": "Количество столбцов",
|
"Column count": "Количество столбцов",
|
||||||
|
"Your personal API keys.": "Ваши личные API ключи.",
|
||||||
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
|
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
|
||||||
"{{count}} command available_one": "Доступна 1 команда",
|
"{{count}} command available_one": "Доступна 1 команда",
|
||||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||||
|
"{{count}} edits": "{count, plural, one{# правка} few{# правки} many{# правок} other{# правки}}",
|
||||||
|
"{{count}} major": "{count, plural, one{# существенная} few{# существенных} many{# существенных} other{# существенных}}",
|
||||||
"{{count}} result available_one": "Доступен 1 результат",
|
"{{count}} result available_one": "Доступен 1 результат",
|
||||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||||
"{{count}} result found_one": "Найден {{count}} результат",
|
"{{count}} result found_one": "Найден {{count}} результат",
|
||||||
@@ -1444,5 +1483,30 @@
|
|||||||
"Boundary": "Граница",
|
"Boundary": "Граница",
|
||||||
"Autosave": "Автосейв",
|
"Autosave": "Автосейв",
|
||||||
"Only versions": "Только версии",
|
"Only versions": "Только версии",
|
||||||
"No saved versions yet.": "Пока нет сохранённых версий."
|
"No saved versions yet.": "Пока нет сохранённых версий.",
|
||||||
|
"Time worked on this article": "Время работы над статьёй",
|
||||||
|
"Show time worked on this page": "Показать время работы над страницей",
|
||||||
|
"Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)",
|
||||||
|
"Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин",
|
||||||
|
"No editing activity recorded yet.": "Правок пока нет.",
|
||||||
|
"× {{count}} days without edits": "× {{count}} дн. без правок",
|
||||||
|
"agent: {{value}}": "агент: {{value}}",
|
||||||
|
"Work": "Работа",
|
||||||
|
"Agent": "Агент",
|
||||||
|
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||||
|
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||||
|
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||||
|
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||||
|
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
|
||||||
|
"{{hours}}h": "{{hours}} ч",
|
||||||
|
"{{minutes}}m": "{{minutes}} м",
|
||||||
|
"Selected version": "Выбранная версия",
|
||||||
|
"via": "через",
|
||||||
|
"of": "из",
|
||||||
|
"Previous change": "Предыдущее изменение",
|
||||||
|
"Next change": "Следующее изменение",
|
||||||
|
"Previous month": "Предыдущий месяц",
|
||||||
|
"Next month": "Следующий месяц",
|
||||||
|
"No revisions found for that day": "Нет ревизий за этот день",
|
||||||
|
"{{date}}: {{count}} versions": "{{date}}: версий — {{count}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ const AccountSettings = lazy(
|
|||||||
const AccountPreferences = lazy(
|
const AccountPreferences = lazy(
|
||||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
);
|
);
|
||||||
|
// #506 — lazy leaf (own chunk): the API-keys management page is route-split so
|
||||||
|
// its code (Mantine table/modals + the create/revoke flow) stays out of the
|
||||||
|
// entry bundle (post-#342 bundle discipline).
|
||||||
|
const AccountApiKeys = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-api-keys.tsx"),
|
||||||
|
);
|
||||||
const WorkspaceSettings = lazy(
|
const WorkspaceSettings = lazy(
|
||||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
);
|
);
|
||||||
@@ -105,6 +111,7 @@ export default function App() {
|
|||||||
path={"account/preferences"}
|
path={"account/preferences"}
|
||||||
element={<AccountPreferences />}
|
element={<AccountPreferences />}
|
||||||
/>
|
/>
|
||||||
|
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
|
||||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
@@ -35,31 +35,3 @@ describe("isChunkLoadError", () => {
|
|||||||
expect(isChunkLoadError(err)).toBe(false);
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
|
||||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
|
||||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
|
||||||
describe("shouldAutoReload", () => {
|
|
||||||
const WINDOW = 5 * 60 * 1000;
|
|
||||||
const NOW = 1_000_000_000_000;
|
|
||||||
|
|
||||||
it("allows a reload when we have never auto-reloaded", () => {
|
|
||||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
|
||||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
hasAutoReloaded,
|
||||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
markAutoReloaded,
|
||||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
recordReloadBreadcrumb,
|
||||||
// by a single reload, so anything inside the window is treated as a reload loop
|
} from "@/lib/reload-guard";
|
||||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
|
||||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
|
||||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
|
||||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
|
||||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
|
||||||
export function shouldAutoReload(
|
|
||||||
now: number,
|
|
||||||
lastReloadAt: number | null,
|
|
||||||
windowMs: number,
|
|
||||||
): boolean {
|
|
||||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
|
||||||
return now - lastReloadAt > windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -39,24 +24,26 @@ export function isChunkLoadError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(error: unknown) {
|
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
||||||
|
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
||||||
|
// proactive version-coherence path) can be exercised against the real guard.
|
||||||
|
export function handleError(error: unknown) {
|
||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
||||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
||||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
// proactive version-coherence path consumes, so a mismatch that arrives on
|
||||||
// reload and falls through to the manual recovery UI below.
|
// both paths reloads at most once per window across BOTH). This recovers
|
||||||
try {
|
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
||||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
// lazy chunk (which would loop) is stopped after the first reload and falls
|
||||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
// through to the manual recovery UI below. If the shared budget is already
|
||||||
const now = Date.now();
|
// spent this window, or the stamp write fails (storage unavailable), we return
|
||||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
// without reloading rather than risk a loop.
|
||||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
if (hasAutoReloaded()) return;
|
||||||
} catch {
|
if (!markAutoReloaded()) return;
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
// proactive version-coherence path writes, tagged with this path).
|
||||||
return;
|
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||||
}
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
IconKey,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -46,6 +47,11 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconBrush,
|
icon: IconBrush,
|
||||||
path: "/settings/account/preferences",
|
path: "/settings/account/preferences",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "API keys",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/account/api-keys",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1254,4 +1254,88 @@ describe("ChatThread — live reconnect + stalled", () => {
|
|||||||
});
|
});
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
|
expect(onResumeFallback).toHaveBeenCalledWith(false); // POLL_IDLE_CAP -> idle -> disarm
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("#541: getRun HANGS on a live disconnect — the timeout race still enters reconnect (no silent freeze in `streaming`)", async () => {
|
||||||
|
// MUTATION-VERIFY: revert the race to a bare `getRun(cid).then/.catch` and this
|
||||||
|
// reddens — a HUNG (never-settling, NOT rejected) getRun leaves the FSM stuck in
|
||||||
|
// `streaming` with no reconnect banner and no poll (the axios client sets no
|
||||||
|
// request timeout, and the stalled-idle cap only arms AFTER reconnecting/polling).
|
||||||
|
renderLive();
|
||||||
|
h.state.getRun.mockReset();
|
||||||
|
h.state.getRun.mockReturnValue(new Promise(() => {})); // getRun HANGS forever
|
||||||
|
await disconnect(); // live partial = liveMsg (id "a2")
|
||||||
|
expect(h.state.getRun).toHaveBeenCalledWith("c1");
|
||||||
|
// BEFORE the bound fires the FSM is still in the live turn — the very freeze bug.
|
||||||
|
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||||
|
// The recovery-start bound fires -> the SAME fallback as the reject path.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4_000);
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
|
// The live partial (a2) was DROPPED from the store (replay-from-start, no stale
|
||||||
|
// tail-apply base). Mirrors the getRun-REJECT test's inspection.
|
||||||
|
const removedLivePartial = (
|
||||||
|
h.state.setMessages as unknown as {
|
||||||
|
mock: { calls: [unknown][] };
|
||||||
|
}
|
||||||
|
).mock.calls.some(([updater]) => {
|
||||||
|
if (typeof updater !== "function") return false;
|
||||||
|
const out = (updater as (p: { id: string }[]) => { id: string }[])([
|
||||||
|
{ id: "a2" },
|
||||||
|
{ id: "u1" },
|
||||||
|
]);
|
||||||
|
return !out.some((m) => m.id === "a2");
|
||||||
|
});
|
||||||
|
expect(removedLivePartial).toBe(true);
|
||||||
|
// ...and the anchor was NULLED -> replay-from-start (no ?anchor=&n= over the partial).
|
||||||
|
advanceToAttempt(1);
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#541: a getRun resolve AFTER the timeout already fired is IGNORED (no double reconnect, no stale re-seed)", async () => {
|
||||||
|
// The timeout wins first and enters the ladder via replay-from-start. When the
|
||||||
|
// hung getRun FINALLY answers, its late `.then` must be a full no-op: it must not
|
||||||
|
// re-seed the store from the (now stale) persisted row, must not re-set the
|
||||||
|
// anchor, and must not re-enter reconnect. The local `settled` flag makes the
|
||||||
|
// resolve/reject/timeout branches mutually exclusive.
|
||||||
|
// MUTATION-VERIFY: drop the `settled` guard (let the late `.then` run) and the
|
||||||
|
// late re-seed re-sets the anchor (URL regains ?anchor=a2&n=3) + calls setMessages.
|
||||||
|
renderLive();
|
||||||
|
let resolveGetRun!: (v: unknown) => void;
|
||||||
|
h.state.getRun.mockReset();
|
||||||
|
h.state.getRun.mockReturnValue(
|
||||||
|
new Promise((r) => {
|
||||||
|
resolveGetRun = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await disconnect();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4_000); // bound fires -> replay-from-start, reconnecting
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
|
||||||
|
advanceToAttempt(1);
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
// Anchor is null -> replay-from-start URL (the pre-condition the late resolve must
|
||||||
|
// not undo).
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
const setMessagesCallsBefore = h.state.setMessages.mock.calls.length;
|
||||||
|
// NOW the hung getRun finally resolves with a persisted anchor (id a2, steps 3).
|
||||||
|
await act(async () => {
|
||||||
|
resolveGetRun(persistedAnchor());
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
// The late resolve did NOT re-seed the store...
|
||||||
|
expect(h.state.setMessages.mock.calls.length).toBe(setMessagesCallsBefore);
|
||||||
|
// ...did NOT re-set the anchor (URL stays replay-from-start, no ?anchor=&n=)...
|
||||||
|
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||||
|
"/api/ai-chat/runs/c1/stream",
|
||||||
|
);
|
||||||
|
// ...and did NOT trigger a fresh reconnect attach.
|
||||||
|
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ const STREAM_THROTTLE_MS = 50;
|
|||||||
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
|
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
|
||||||
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
// #541: the bound on the persist re-seed (getRun) wait when ENTERING the reconnect
|
||||||
|
// ladder after a live SSE drop. The axios client (lib/api-client.ts) sets NO request
|
||||||
|
// timeout, and the stalled-idle cap only arms AFTER the FSM enters reconnecting/
|
||||||
|
// polling — which never happens if getRun HANGS (connection established, no response).
|
||||||
|
// Without this bound a live drop + a hung getRun sticks the FSM in `streaming` with
|
||||||
|
// no banner and no poll until the browser socket timeout (minutes) — the silent-freeze
|
||||||
|
// class #497 eliminates. This is a deliberate RECOVERY-START bound (drop the live
|
||||||
|
// partial + enter the ladder so the poll/idle-cap arms), intentionally much shorter
|
||||||
|
// than any network socket timeout — not a network read timeout.
|
||||||
|
const RECONNECT_RESEED_TIMEOUT_MS = 4_000;
|
||||||
|
|
||||||
/** The #487 active (non-terminal) run statuses — mirrors the server's
|
/** The #487 active (non-terminal) run statuses — mirrors the server's
|
||||||
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
|
* ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
|
||||||
function isActiveRunStatus(status: string | null | undefined): boolean {
|
function isActiveRunStatus(status: string | null | undefined): boolean {
|
||||||
@@ -286,6 +297,9 @@ export default function ChatThread({
|
|||||||
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
|
// stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// #541: the persist re-seed (getRun) timeout race on the disconnect->reconnect
|
||||||
|
// entry. Held in a ref so unmount (DISPOSE cleanup) clears it — no dangling timer.
|
||||||
|
const reseedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
|
// #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
|
||||||
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
|
// tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
|
||||||
@@ -748,37 +762,68 @@ export default function ChatThread({
|
|||||||
anchorRef.current = null;
|
anchorRef.current = null;
|
||||||
};
|
};
|
||||||
if (cid) {
|
if (cid) {
|
||||||
void getRun(cid)
|
// #541: bound the persist re-seed wait with a timeout race. getRun goes
|
||||||
.then((res) => {
|
// through the axios client, which has NO request timeout; a HUNG getRun
|
||||||
if (!mountedRef.current) return;
|
// (connection open, no response) — distinct from a REJECT, which the
|
||||||
const persisted = res.message;
|
// `.catch` already handles — would otherwise never let us enter the ladder,
|
||||||
if (persisted && persisted.role === "assistant") {
|
// leaving the FSM stuck in `streaming` with no banner and no poll until the
|
||||||
anchorRef.current = {
|
// browser socket timeout. `settled` makes the three branches (resolve /
|
||||||
id: persisted.id,
|
// reject / timeout) mutually exclusive: whichever fires FIRST wins and the
|
||||||
stepsPersisted: stepsPersistedOf(persisted),
|
// others become no-ops. So a LATE getRun resolve AFTER the timeout is fully
|
||||||
};
|
// ignored — it cannot (i) re-enter reconnect a second time, (ii) overwrite
|
||||||
// Replace the live partial with the persisted row IN PLACE by id —
|
// the timeout's replay-from-start with a stale re-seed, or (iii) re-arm any
|
||||||
// the re-seed from persist. The attach's tail (steps >= N) then
|
// timer. On timeout we take the SAME fallback as the reject path (drop the
|
||||||
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
|
// live partial + enter the ladder, so the poll and the stalled-idle cap arm).
|
||||||
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
|
let settled = false;
|
||||||
} else {
|
const finishReseed = (apply: () => void): void => {
|
||||||
// No persisted assistant row (pre-first-frame break): drop the live
|
if (settled) return;
|
||||||
// partial + replay from start (no anchor/n) so nothing is duplicated.
|
settled = true;
|
||||||
dropLivePartialAndReplayFromStart();
|
if (reseedTimerRef.current) {
|
||||||
}
|
clearTimeout(reseedTimerRef.current);
|
||||||
enterReconnect(res.run?.id ?? runId);
|
reseedTimerRef.current = null;
|
||||||
})
|
}
|
||||||
.catch(() => {
|
if (!mountedRef.current) return;
|
||||||
if (!mountedRef.current) return;
|
apply();
|
||||||
// Persist read FAILED: we cannot re-seed from fresh persist, and a
|
};
|
||||||
// stale mount-time anchor over the live partial would tail-apply
|
reseedTimerRef.current = setTimeout(() => {
|
||||||
// already-present steps -> duplication (a flaky-network blip:
|
finishReseed(() => {
|
||||||
// SSE + getRun both fail, network recovers in ~1s, the registry still
|
|
||||||
// covers from the mount frontier). Restore the removed-filter guarantee
|
|
||||||
// instead: drop the live partial + replay from start / 204 -> poll.
|
|
||||||
dropLivePartialAndReplayFromStart();
|
dropLivePartialAndReplayFromStart();
|
||||||
enterReconnect(runId);
|
enterReconnect(runId);
|
||||||
});
|
});
|
||||||
|
}, RECONNECT_RESEED_TIMEOUT_MS);
|
||||||
|
void getRun(cid)
|
||||||
|
.then((res) => {
|
||||||
|
finishReseed(() => {
|
||||||
|
const persisted = res.message;
|
||||||
|
if (persisted && persisted.role === "assistant") {
|
||||||
|
anchorRef.current = {
|
||||||
|
id: persisted.id,
|
||||||
|
stepsPersisted: stepsPersistedOf(persisted),
|
||||||
|
};
|
||||||
|
// Replace the live partial with the persisted row IN PLACE by id —
|
||||||
|
// the re-seed from persist. The attach's tail (steps >= N) then
|
||||||
|
// appends to a store holding EXACTLY steps 0..N-1: no duplication.
|
||||||
|
setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
|
||||||
|
} else {
|
||||||
|
// No persisted assistant row (pre-first-frame break): drop the live
|
||||||
|
// partial + replay from start (no anchor/n) so nothing is duplicated.
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
}
|
||||||
|
enterReconnect(res.run?.id ?? runId);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
finishReseed(() => {
|
||||||
|
// Persist read FAILED: we cannot re-seed from fresh persist, and a
|
||||||
|
// stale mount-time anchor over the live partial would tail-apply
|
||||||
|
// already-present steps -> duplication (a flaky-network blip:
|
||||||
|
// SSE + getRun both fail, network recovers in ~1s, the registry still
|
||||||
|
// covers from the mount frontier). Restore the removed-filter guarantee
|
||||||
|
// instead: drop the live partial + replay from start / 204 -> poll.
|
||||||
|
dropLivePartialAndReplayFromStart();
|
||||||
|
enterReconnect(runId);
|
||||||
|
});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
dropLivePartialAndReplayFromStart();
|
dropLivePartialAndReplayFromStart();
|
||||||
enterReconnect(runId);
|
enterReconnect(runId);
|
||||||
@@ -903,6 +948,12 @@ export default function ChatThread({
|
|||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
// #541: clear the in-flight persist re-seed timeout (not an FSM effect timer,
|
||||||
|
// so DISPOSE does not touch it) — no dangling setTimeout after unmount.
|
||||||
|
if (reseedTimerRef.current) {
|
||||||
|
clearTimeout(reseedTimerRef.current);
|
||||||
|
reseedTimerRef.current = null;
|
||||||
|
}
|
||||||
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
|
dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
|
||||||
};
|
};
|
||||||
// Mount-only by design; the parent remounts per chat via `key`.
|
// Mount-only by design; the parent remounts per chat via `key`.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|||||||
|
|
||||||
import MessageItem from "./message-item";
|
import MessageItem from "./message-item";
|
||||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
import { splitPlainChunks } from "./streaming-plain-text";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
expect(queryByText("streamed answer")).not.toBeNull();
|
expect(queryByText("streamed answer")).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PERF SMOKE (#492): the whole point of the incremental streaming render is that
|
||||||
|
// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
|
||||||
|
// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
|
||||||
|
// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
|
||||||
|
// calls (one per tick). With the incremental render each STABILIZED block is parsed
|
||||||
|
// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
|
||||||
|
// the call count collapses to ~= the block count regardless of tick granularity.
|
||||||
|
describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
|
||||||
|
// ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
|
||||||
|
// delimited markdown blocks — so the safe-cut block count is ~2× the section
|
||||||
|
// count. The perf claim is about the BLOCK count (the memoization granularity),
|
||||||
|
// measured directly with splitPlainChunks below, not the section count.
|
||||||
|
const buildAnswer = () => {
|
||||||
|
const SECTIONS = 100;
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
for (let i = 0; i < SECTIONS; i++) {
|
||||||
|
paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
|
||||||
|
}
|
||||||
|
const full = paragraphs.join("\n\n");
|
||||||
|
// The number of memoized markdown blocks the incremental render splits into
|
||||||
|
// (all but the live tail are parsed once each).
|
||||||
|
return { full, blocks: splitPlainChunks(full).length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
|
||||||
|
({
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text, state }],
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
const { full, blocks } = buildAnswer();
|
||||||
|
const CHUNK = 128; // a realistic ~20Hz throttled delta size
|
||||||
|
const ticks = Math.ceil(full.length / CHUNK);
|
||||||
|
|
||||||
|
let msg = streamMsg(full.slice(0, CHUNK), "streaming");
|
||||||
|
const { rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
|
||||||
|
msg = streamMsg(full.slice(0, end), "streaming");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg}
|
||||||
|
signature={messageSignature(msg)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Finalize: the streaming→done flip renders the whole answer through ONE
|
||||||
|
// canonical pass (visual parity), so the finished DOM matches the pre-#492
|
||||||
|
// output. This is the single extra parse on top of the per-block ones.
|
||||||
|
const done = streamMsg(full, "done");
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = renderChatMarkdownSpy.mock.calls.length;
|
||||||
|
// Sanity: the stream really had far more ticks than blocks (else the test is
|
||||||
|
// vacuous — the point is that calls scale with blocks, not ticks).
|
||||||
|
expect(ticks).toBeGreaterThan(blocks * 3);
|
||||||
|
// O(blocks): each stabilized block parsed once + the single final whole-text
|
||||||
|
// parse. A small constant absorbs the finalize render and the live-tail block;
|
||||||
|
// the load-bearing claim is the bound below.
|
||||||
|
expect(calls).toBeLessThanOrEqual(blocks + 2);
|
||||||
|
// ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
|
||||||
|
expect(calls).toBeLessThan(ticks / 3);
|
||||||
|
expect(calls).toBeGreaterThan(blocks / 2);
|
||||||
|
// MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
|
||||||
|
// MarkdownChunk (so every stable block re-parses each tick) drives `calls`
|
||||||
|
// toward `ticks` (~394), reddening both upper-bound assertions above.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 other
|
||||||
|
// message-item specs.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MessageItem from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
// The REAL canonical renderer (NOT the spy the memo test installs): this file
|
||||||
|
// exercises the actual markdown output so the visual-regression assertions below
|
||||||
|
// compare against genuine HTML (incl. the schema's `<li><p>` wrappers).
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "./ai-chat.module.css";
|
||||||
|
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
extra?: Partial<UIMessage>,
|
||||||
|
): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
|
||||||
|
|
||||||
|
const renderRow = (message: UIMessage, turnStreaming = false) =>
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={message}
|
||||||
|
signature={messageSignature(message)}
|
||||||
|
turnStreaming={turnStreaming}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A rich multi-block answer that exercises headings, a list (the `<li><p>` case
|
||||||
|
// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
|
||||||
|
const ANSWER = [
|
||||||
|
"# Заголовок",
|
||||||
|
"",
|
||||||
|
"Первый абзац с **жирным** и `кодом`.",
|
||||||
|
"",
|
||||||
|
"- пункт один",
|
||||||
|
"- пункт два",
|
||||||
|
"",
|
||||||
|
"Второй абзац.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("MessageItem final render — visual parity with the canonical pipeline", () => {
|
||||||
|
it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
|
||||||
|
const { container } = renderRow(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "done" }]),
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
// Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
|
||||||
|
// MarkdownPart produced), including `<li><p>…</p></li>` wrappers.
|
||||||
|
expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
// The list wrapper is really present (guards against a vacuous empty render).
|
||||||
|
expect(container.querySelectorAll("li p").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
|
||||||
|
// Mount mid-stream (live tail) — the DOM here is the incremental view.
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: ANSWER, state: "streaming" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: ANSWER, state: "streaming" }]),
|
||||||
|
)}
|
||||||
|
turnStreaming
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Finish the turn: state flips to done AND the turn is no longer streaming.
|
||||||
|
const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={done} signature={messageSignature(done)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// After finish there is exactly ONE canonical markdown container whose HTML is
|
||||||
|
// the whole-text render — identical to the non-streaming path above.
|
||||||
|
const blocks = container.querySelectorAll(`.${classes.markdown}`);
|
||||||
|
expect(blocks.length).toBe(1);
|
||||||
|
expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizeInternalLinks is honored on the finalized render", () => {
|
||||||
|
const linkAnswer = "См. [страницу](/p/abc).";
|
||||||
|
const { container } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem
|
||||||
|
message={msg([{ type: "text", text: linkAnswer, state: "done" }])}
|
||||||
|
signature={messageSignature(
|
||||||
|
msg([{ type: "text", text: linkAnswer, state: "done" }]),
|
||||||
|
)}
|
||||||
|
neutralizeInternalLinks
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const block = container.querySelector(`.${classes.markdown}`);
|
||||||
|
expect(block!.innerHTML).toBe(
|
||||||
|
renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
|
||||||
|
);
|
||||||
|
// The internal link was made inert (no href) by the neutralization flag.
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.hasAttribute("href")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ 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 ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||||
|
import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
|
||||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.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";
|
||||||
@@ -86,17 +87,39 @@ interface MessageItemProps {
|
|||||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
* 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
|
* 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
|
* 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
|
* part hits the memo and skips the expensive canonical parse + DOMPurify pass.
|
||||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
* Props are primitives, so React.memo's default shallow compare is exactly right
|
||||||
* `text` string is compared by value).
|
* (the `text` string is compared by value).
|
||||||
|
*
|
||||||
|
* Streaming gate (#492) — mirrors ReasoningBlock:
|
||||||
|
* - `streaming` (this is the live, actively-growing tail part of an in-flight
|
||||||
|
* turn): render incrementally via StreamingMarkdownText — the stabilized blocks
|
||||||
|
* go through the canonical pipeline (each parsed ONCE, memoized) and only the
|
||||||
|
* live tail is cheap plain text. This makes the per-tick cost O(new blocks),
|
||||||
|
* not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
|
||||||
|
* - finalized (the common case, and the turn-end flip): render the WHOLE text
|
||||||
|
* through ONE canonical pass — byte-identical to the pre-#492 output (visual
|
||||||
|
* parity). The row re-renders on the streaming→done flip because
|
||||||
|
* `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
|
||||||
|
* turn end), so the incremental view always converges to this single render.
|
||||||
*/
|
*/
|
||||||
const MarkdownPart = memo(function MarkdownPart({
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
text,
|
text,
|
||||||
neutralizeInternalLinks,
|
neutralizeInternalLinks,
|
||||||
|
streaming,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
neutralizeInternalLinks: boolean;
|
neutralizeInternalLinks: boolean;
|
||||||
|
streaming: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (streaming) {
|
||||||
|
return (
|
||||||
|
<StreamingMarkdownText
|
||||||
|
text={text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
if (html) {
|
if (html) {
|
||||||
return (
|
return (
|
||||||
@@ -179,47 +202,10 @@ function MessageItem({
|
|||||||
{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") {
|
// Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
|
||||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
// they cannot be a `switch` case; the runtime guard handles them, and the
|
||||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
// switch below covers every CLOSED (literal-typed) part kind with a
|
||||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
// compile-time exhaustiveness check in its default.
|
||||||
const text = (part as { text?: string }).text ?? "";
|
|
||||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
|
||||||
return null;
|
|
||||||
// Absent state (persisted rows) and "done" both mean finalized.
|
|
||||||
// `messageSignature` already includes each part's `state`, so the
|
|
||||||
// streaming→done flip changes the row signature and re-renders this
|
|
||||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
|
||||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
|
||||||
// ALSO require the turn to be live: a part stranded at
|
|
||||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
|
||||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
|
||||||
const streaming =
|
|
||||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
|
||||||
return (
|
|
||||||
<ReasoningBlock
|
|
||||||
key={index}
|
|
||||||
text={text}
|
|
||||||
tokens={reasoningTokens}
|
|
||||||
streaming={streaming}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "text") {
|
|
||||||
// Skip empty/whitespace-only text parts (a streaming message often
|
|
||||||
// starts with an empty text part before the first token arrives); the
|
|
||||||
// typing indicator covers that gap until real content streams in.
|
|
||||||
if (!part.text.trim()) return null;
|
|
||||||
return (
|
|
||||||
<MarkdownPart
|
|
||||||
key={index}
|
|
||||||
text={part.text}
|
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isToolPart(part.type)) {
|
if (isToolPart(part.type)) {
|
||||||
return (
|
return (
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
@@ -232,7 +218,76 @@ function MessageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (part.type) {
|
||||||
|
case "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.text ?? "";
|
||||||
|
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||||
|
return null;
|
||||||
|
// Absent state (persisted rows) and "done" both mean finalized.
|
||||||
|
// `messageSignature` already includes each part's `state`, so the
|
||||||
|
// streaming→done flip changes the row signature and re-renders this
|
||||||
|
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||||
|
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||||
|
// ALSO require the turn to be live: a part stranded at
|
||||||
|
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||||
|
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<ReasoningBlock
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
tokens={reasoningTokens}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "text": {
|
||||||
|
// Skip empty/whitespace-only text parts (a streaming message often
|
||||||
|
// starts with an empty text part before the first token arrives); the
|
||||||
|
// typing indicator covers that gap until real content streams in.
|
||||||
|
if (!part.text.trim()) return null;
|
||||||
|
// The live, actively-growing tail part of the in-flight turn renders
|
||||||
|
// incrementally (see MarkdownPart); a finalized part (persisted, or
|
||||||
|
// the turn-end flip) renders the whole text through one canonical
|
||||||
|
// pass. Same liveness rule as the reasoning branch above.
|
||||||
|
const streaming = turnStreaming && part.state === "streaming";
|
||||||
|
return (
|
||||||
|
<MarkdownPart
|
||||||
|
key={index}
|
||||||
|
text={part.text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
streaming={streaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "source-url":
|
||||||
|
case "source-document":
|
||||||
|
case "file":
|
||||||
|
case "step-start":
|
||||||
|
// Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Compile-time exhaustiveness over the CLOSED union members: every
|
||||||
|
// literal-typed part kind is handled above, so the only kinds that
|
||||||
|
// can reach here are the OPEN template-literal ones (`tool-*` — caught
|
||||||
|
// by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
|
||||||
|
// a NEW closed part kind to UIMessagePart makes this assignment fail
|
||||||
|
// to compile, forcing it to be handled instead of silently ignored
|
||||||
|
// (this replaces the pre-#492 fall-through `return null` + WARNING).
|
||||||
|
const _exhaustive:
|
||||||
|
| `tool-${string}`
|
||||||
|
| "dynamic-tool"
|
||||||
|
| `data-${string}` = part.type;
|
||||||
|
void _exhaustive;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
{/* A persisted turn error (server stored it in metadata.error). Rendered
|
||||||
here so it survives a thread remount and shows in reopened history. */}
|
here so it survives a thread remount and shows in reopened history. */}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One STABILIZED markdown block, rendered through the canonical pipeline and
|
||||||
|
* memoized on its string prop. During streaming only the TAIL chunk grows (the
|
||||||
|
* `splitPlainChunks` append-only invariant guarantees every earlier chunk is
|
||||||
|
* byte-identical across deltas), so React skips every stable block and each one
|
||||||
|
* is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
|
||||||
|
* "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
|
||||||
|
* O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
|
||||||
|
* before it reaches `dangerouslySetInnerHTML`.
|
||||||
|
*
|
||||||
|
* NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
|
||||||
|
* so a construct that legitimately contains a blank line (e.g. a fenced code block
|
||||||
|
* with an empty line) can be split across chunks and render oddly WHILE it is still
|
||||||
|
* streaming. This is cosmetic and self-heals: the moment the part finalizes,
|
||||||
|
* MarkdownPart renders the WHOLE text through one canonical pass (visual parity
|
||||||
|
* with the pre-#492 output). The reasoning path makes the same trade (plain text
|
||||||
|
* while streaming, one markdown parse at the end).
|
||||||
|
*/
|
||||||
|
const MarkdownChunk = memo(function MarkdownChunk({
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Malformed/unsupported markdown could not render synchronously: raw text.
|
||||||
|
return (
|
||||||
|
<div className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cheap streaming-time stand-in for the finalized answer's one-time markdown
|
||||||
|
* parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
|
||||||
|
* chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
|
||||||
|
* block parsed once, memoized) and only the LIVE tail as flat plain text — so the
|
||||||
|
* user sees formatted output for everything up to the last safe cut, and the not-
|
||||||
|
* yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
|
||||||
|
* single cheap escaped text node until it stabilizes into a new block.
|
||||||
|
*
|
||||||
|
* `splitPlainChunks` yields chunks where, under append-only growth, every chunk
|
||||||
|
* except the LAST is immutable; the last chunk is the live tail. Index keys are
|
||||||
|
* therefore stable (a given index never changes to a different chunk's content).
|
||||||
|
*/
|
||||||
|
export function StreamingMarkdownText({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) =>
|
||||||
|
index < chunks.length - 1 ? (
|
||||||
|
<MarkdownChunk
|
||||||
|
key={index}
|
||||||
|
text={chunk}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// The live tail: flat, React-escaped plain text (no markdown parse, no
|
||||||
|
// sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
|
||||||
|
// separator newlines are dropped at display time so the block gap comes
|
||||||
|
// from the markdown margins, not a doubled empty line (mirrors
|
||||||
|
// PlainChunk in streaming-plain-text.tsx).
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={classes.markdown}
|
||||||
|
style={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{chunk.replace(/\n+$/, "")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import { UserRole } from "@/lib/types";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import { IApiKey } from "@/features/api-key/types/api-key.types";
|
||||||
|
|
||||||
|
// Mock the service layer so no real HTTP is attempted; every test drives the
|
||||||
|
// component through these three functions.
|
||||||
|
vi.mock("@/features/api-key/services/api-key-service", () => ({
|
||||||
|
getApiKeys: vi.fn(),
|
||||||
|
createApiKey: vi.fn(),
|
||||||
|
revokeApiKey: vi.fn(),
|
||||||
|
revealApiKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getApiKeys,
|
||||||
|
createApiKey,
|
||||||
|
revokeApiKey,
|
||||||
|
revealApiKey,
|
||||||
|
} from "@/features/api-key/services/api-key-service";
|
||||||
|
import ApiKeysManager from "./api-keys-manager";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString();
|
||||||
|
const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString();
|
||||||
|
const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString();
|
||||||
|
|
||||||
|
// Dump the storage stub via the Web Storage API — its data lives in a closure
|
||||||
|
// (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous.
|
||||||
|
function storageDump(): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i) as string;
|
||||||
|
out += `${k}=${localStorage.getItem(k)};`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKey(overrides: Partial<IApiKey> = {}): IApiKey {
|
||||||
|
return {
|
||||||
|
id: "key-1",
|
||||||
|
name: "CI token",
|
||||||
|
expiresAt: ISO_FAR,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManager(role: UserRole) {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(currentUserAtom, {
|
||||||
|
user: { id: "me", role } as never,
|
||||||
|
workspace: {} as never,
|
||||||
|
});
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
const utils = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MantineProvider>
|
||||||
|
<ModalsProvider>
|
||||||
|
<ApiKeysManager />
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
return { store, queryClient, ...utils };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApiKeysManager — list rendering", () => {
|
||||||
|
it("renders an explicit expiry date and highlights a <30-day key (acceptance #3)", async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([
|
||||||
|
makeKey({ id: "k-soon", name: "Soon key", expiresAt: ISO_SOON }),
|
||||||
|
makeKey({ id: "k-far", name: "Far key", expiresAt: ISO_FAR }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
|
||||||
|
await screen.findByText("Soon key");
|
||||||
|
// Explicit dates, not "in N days": the year is rendered verbatim.
|
||||||
|
const soonYear = new Date(ISO_SOON).getFullYear().toString();
|
||||||
|
expect(screen.getAllByText(new RegExp(soonYear)).length).toBeGreaterThan(0);
|
||||||
|
// Exactly one key is inside the 30-day warning window.
|
||||||
|
expect(screen.getAllByText("Expiring soon")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([
|
||||||
|
makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }),
|
||||||
|
]);
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("Dead key");
|
||||||
|
// A past expiry is labelled "Expired", never the forward-looking badge.
|
||||||
|
expect(screen.getByText("Expired")).toBeDefined();
|
||||||
|
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Never" for an unlimited key and no highlight', async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([
|
||||||
|
makeKey({ id: "k-forever", name: "Forever", expiresAt: null }),
|
||||||
|
]);
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("Forever");
|
||||||
|
expect(screen.getByText("Never")).toBeDefined();
|
||||||
|
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty list shows the empty state", async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
expect(await screen.findByText("No API keys yet")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApiKeysManager — admin vs member view (acceptance #6)", () => {
|
||||||
|
it("a member does NOT see the author column", async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([
|
||||||
|
makeKey({ creator: { id: "me", name: "Me", email: "m@x.io", avatarUrl: null } }),
|
||||||
|
]);
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("CI token");
|
||||||
|
// Author header absent + creator name not rendered (no author column).
|
||||||
|
expect(screen.queryByText("Author")).toBeNull();
|
||||||
|
expect(screen.queryByText("Me")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin sees the author column with the creator's name", async () => {
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([
|
||||||
|
makeKey({ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null } }),
|
||||||
|
]);
|
||||||
|
renderManager(UserRole.ADMIN);
|
||||||
|
await screen.findByText("CI token");
|
||||||
|
expect(screen.getByText("Author")).toBeDefined();
|
||||||
|
expect(screen.getByText("Alice")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApiKeysManager — revoke (acceptance #4)", () => {
|
||||||
|
it("revoke removes the row from the list", async () => {
|
||||||
|
vi.mocked(getApiKeys)
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
makeKey({ id: "k1", name: "Doomed" }),
|
||||||
|
makeKey({ id: "k2", name: "Survivor" }),
|
||||||
|
])
|
||||||
|
// After revoke, invalidation refetches the reduced list.
|
||||||
|
.mockResolvedValue([makeKey({ id: "k2", name: "Survivor" })]);
|
||||||
|
vi.mocked(revokeApiKey).mockResolvedValue();
|
||||||
|
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("Doomed");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Revoke Doomed"));
|
||||||
|
// Confirm modal → click the destructive confirm button.
|
||||||
|
const confirm = await screen.findByRole("button", { name: "Revoke" });
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Doomed")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Survivor")).toBeDefined();
|
||||||
|
expect(revokeApiKey).toHaveBeenCalledWith("k1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApiKeysManager — create no longer shows the token (copy replaces show-once)", () => {
|
||||||
|
it("create closes the modal + toasts without ever rendering the token", async () => {
|
||||||
|
const SECRET = "gm_secret-created-value";
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||||
|
vi.mocked(createApiKey).mockResolvedValue({
|
||||||
|
token: SECRET,
|
||||||
|
apiKey: {
|
||||||
|
id: "new-1",
|
||||||
|
name: "My key",
|
||||||
|
expiresAt: ISO_FAR,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryClient } = renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("No API keys yet");
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("button", { name: "Create API key" })[0],
|
||||||
|
);
|
||||||
|
const nameInput = await screen.findByLabelText(/Name/);
|
||||||
|
fireEvent.change(nameInput, { target: { value: "My key" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createApiKey).toHaveBeenCalled());
|
||||||
|
// The token is never rendered (no more show-once modal).
|
||||||
|
expect(screen.queryByTestId("api-key-token")).toBeNull();
|
||||||
|
expect(screen.queryByText(SECRET)).toBeNull();
|
||||||
|
|
||||||
|
// The created token never lands in localStorage or the react-query caches.
|
||||||
|
expect(storageDump()).not.toContain(SECRET);
|
||||||
|
const cacheDump = JSON.stringify(
|
||||||
|
queryClient.getQueryCache().getAll().map((q) => q.state.data),
|
||||||
|
);
|
||||||
|
expect(cacheDump).not.toContain(SECRET);
|
||||||
|
const mutationDump = JSON.stringify(
|
||||||
|
queryClient.getMutationCache().getAll().map((m) => m.state.data),
|
||||||
|
);
|
||||||
|
expect(mutationDump).not.toContain(SECRET);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ApiKeysManager — copy under step-up (acceptance #9)", () => {
|
||||||
|
it("copies a re-minted token to the clipboard and leaks it nowhere else", async () => {
|
||||||
|
const SECRET = "gm_revealed-token-value-xyz";
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
// jsdom has no clipboard; install a stub the copy helper will use.
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
|
||||||
|
vi.mocked(revealApiKey).mockResolvedValue(SECRET);
|
||||||
|
|
||||||
|
const { queryClient } = renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("CI token");
|
||||||
|
|
||||||
|
// Click the per-row Copy action → password step-up modal.
|
||||||
|
fireEvent.click(screen.getByLabelText("Copy CI token"));
|
||||||
|
const pwInput = await screen.findByLabelText("Password");
|
||||||
|
fireEvent.change(pwInput, { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
||||||
|
|
||||||
|
// The reveal request carried the id + password; the token was written to the
|
||||||
|
// clipboard and NOWHERE else.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(revealApiKey).toHaveBeenCalledWith({
|
||||||
|
id: "k1",
|
||||||
|
password: "hunter2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(writeText).toHaveBeenCalledWith(SECRET));
|
||||||
|
|
||||||
|
// The revealed secret is never in the DOM, localStorage, or either cache.
|
||||||
|
expect(screen.queryByText(SECRET)).toBeNull();
|
||||||
|
expect(storageDump()).not.toContain(SECRET);
|
||||||
|
const cacheDump = JSON.stringify(
|
||||||
|
queryClient.getQueryCache().getAll().map((q) => q.state.data),
|
||||||
|
);
|
||||||
|
expect(cacheDump).not.toContain(SECRET);
|
||||||
|
const mutationDump = JSON.stringify(
|
||||||
|
queryClient.getMutationCache().getAll().map((m) => m.state.data),
|
||||||
|
);
|
||||||
|
expect(mutationDump).not.toContain(SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wrong password keeps the modal open and does not copy", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
|
||||||
|
// Server returns a 401 for a wrong step-up password.
|
||||||
|
vi.mocked(revealApiKey).mockRejectedValue({ response: { status: 401 } });
|
||||||
|
|
||||||
|
renderManager(UserRole.MEMBER);
|
||||||
|
await screen.findByText("CI token");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Copy CI token"));
|
||||||
|
const pwInput = await screen.findByLabelText("Password");
|
||||||
|
fireEvent.change(pwInput, { target: { value: "wrong" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(revealApiKey).toHaveBeenCalled());
|
||||||
|
// Nothing copied; the password field is still on screen for a retry.
|
||||||
|
expect(writeText).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByLabelText("Password")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { IconAlertTriangle, IconCopy, IconKey, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
|
||||||
|
import { timeAgo } from "@/lib/time";
|
||||||
|
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
useApiKeysQuery,
|
||||||
|
useCreateApiKeyMutation,
|
||||||
|
useRevealApiKeyMutation,
|
||||||
|
useRevokeApiKeyMutation,
|
||||||
|
} from "@/features/api-key/queries/api-key-query";
|
||||||
|
import { IApiKey } from "@/features/api-key/types/api-key.types";
|
||||||
|
import {
|
||||||
|
isExpired,
|
||||||
|
isExpiringSoon,
|
||||||
|
lastUsedBucket,
|
||||||
|
} from "@/features/api-key/utils";
|
||||||
|
import { CreateApiKeyModal } from "./create-api-key-modal";
|
||||||
|
import { RevealKeyModal } from "./reveal-key-modal";
|
||||||
|
|
||||||
|
export default function ApiKeysManager() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locale = useDateFnsLocale();
|
||||||
|
// Manage-on-API === Owner/Admin server-side, so the admin (workspace-wide)
|
||||||
|
// list + "author" column mirror exactly the roles the server serves the
|
||||||
|
// whole-workspace response to.
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
|
const { data: keys, isLoading, isError } = useApiKeysQuery();
|
||||||
|
const createMutation = useCreateApiKeyMutation();
|
||||||
|
const revokeMutation = useRevokeApiKeyMutation();
|
||||||
|
const revealMutation = useRevealApiKeyMutation();
|
||||||
|
|
||||||
|
const [createOpened, setCreateOpened] = useState(false);
|
||||||
|
// SECURITY: only the key METADATA is held here (for the modal title) — never a
|
||||||
|
// token. The token is copied straight to the clipboard in handleCopy and is
|
||||||
|
// never stored in state, the query cache or localStorage.
|
||||||
|
const [revealTarget, setRevealTarget] = useState<IApiKey | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async (values: {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(values);
|
||||||
|
// The create response carries a token, but it is now retrievable any time
|
||||||
|
// via the per-row Copy action (reveal), so we DISCARD it here: purge
|
||||||
|
// react-query's copy immediately and never move it into state. The user
|
||||||
|
// copies the key from the list via a password step-up.
|
||||||
|
createMutation.reset();
|
||||||
|
setCreateOpened(false);
|
||||||
|
notifications.show({ message: t("API key created") });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to create API key"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password step-up accepted -> re-mint + copy. The token exists only as a local
|
||||||
|
// `const` for the single clipboard write, then react-query's copy is reset().
|
||||||
|
// It never enters component state, the query cache or localStorage.
|
||||||
|
const handleCopy = async (password: string): Promise<boolean> => {
|
||||||
|
const target = revealTarget;
|
||||||
|
if (!target) return false;
|
||||||
|
try {
|
||||||
|
const token = await revealMutation.mutateAsync({
|
||||||
|
id: target.id,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await copyToClipboard(token);
|
||||||
|
revealMutation.reset();
|
||||||
|
notifications.show({ message: t("API key copied to clipboard") });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
revealMutation.reset();
|
||||||
|
// 401 = wrong password (keep the modal open to retry); anything else is a
|
||||||
|
// uniform failure (the server does not distinguish key states).
|
||||||
|
const status = (err as { response?: { status?: number } })?.response
|
||||||
|
?.status;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
status === 401
|
||||||
|
? t("Incorrect password")
|
||||||
|
: t("Failed to copy API key"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRevokeModal = (key: IApiKey) =>
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Revoke API key"),
|
||||||
|
centered: true,
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'Are you sure you want to revoke "{{name}}"? Any client using this key will immediately lose access. This cannot be undone.',
|
||||||
|
{ name: key.name },
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => revokeMutation.mutate(key.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
formatLocalized(new Date(iso), "MMM dd, yyyy", "PP", locale);
|
||||||
|
|
||||||
|
const renderLastUsed = (lastUsedAt: string | null) => {
|
||||||
|
switch (lastUsedBucket(lastUsedAt)) {
|
||||||
|
case "never":
|
||||||
|
return t("Never used");
|
||||||
|
case "recent":
|
||||||
|
return t("Within the last hour");
|
||||||
|
case "stale":
|
||||||
|
// Coarse relative time — last_used_at is throttled to ~1h server-side,
|
||||||
|
// so we don't promise finer precision.
|
||||||
|
return timeAgo(new Date(lastUsedAt as string));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (keys ?? []).map((key) => {
|
||||||
|
// Mutually exclusive: an already-expired key is labelled "Expired" (a past
|
||||||
|
// expiry) rather than the forward-looking "Expiring soon". isExpiringSoon
|
||||||
|
// also matches past expiries, so gate "soon" on !expired.
|
||||||
|
const expired = isExpired(key.expiresAt);
|
||||||
|
const soon = !expired && isExpiringSoon(key.expiresAt);
|
||||||
|
return (
|
||||||
|
<Table.Tr key={key.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500}>{key.name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatDate(key.createdAt)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{key.expiresAt ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm">{formatDate(key.expiresAt)}</Text>
|
||||||
|
{expired && (
|
||||||
|
<Tooltip label={t("This key has expired")} withArrow>
|
||||||
|
<Badge
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconAlertTriangle size={12} />}
|
||||||
|
>
|
||||||
|
{t("Expired")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{soon && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("This key expires within 30 days")}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconAlertTriangle size={12} />}
|
||||||
|
>
|
||||||
|
{t("Expiring soon")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("Never")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{renderLastUsed(key.lastUsedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{isAdmin && (
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">
|
||||||
|
{key.creator?.name ?? key.creator?.email ?? t("Unknown")}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td style={{ textAlign: "right" }}>
|
||||||
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
|
<Tooltip label={t("Copy API key")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
aria-label={t("Copy {{name}}", { name: key.name })}
|
||||||
|
disabled={expired}
|
||||||
|
onClick={() => setRevealTarget(key)}
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t("Revoke")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={t("Revoke {{name}}", { name: key.name })}
|
||||||
|
onClick={() => openRevokeModal(key)}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{isAdmin
|
||||||
|
? t("API keys across the workspace.")
|
||||||
|
: t("Your personal API keys.")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconKey size={16} />}
|
||||||
|
onClick={() => setCreateOpened(true)}
|
||||||
|
>
|
||||||
|
{t("Create API key")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{t("Failed to load API keys.")}
|
||||||
|
</Text>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Stack align="center" gap="xs" py="xl">
|
||||||
|
<IconKey size={32} opacity={0.4} />
|
||||||
|
<Text c="dimmed">{t("No API keys yet")}</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconKey size={16} />}
|
||||||
|
onClick={() => setCreateOpened(true)}
|
||||||
|
>
|
||||||
|
{t("Create API key")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Table.ScrollContainer minWidth={600}>
|
||||||
|
<Table verticalSpacing="sm" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Name")}</Table.Th>
|
||||||
|
<Table.Th>{t("Created")}</Table.Th>
|
||||||
|
<Table.Th>{t("Expires")}</Table.Th>
|
||||||
|
<Table.Th>{t("Last used")}</Table.Th>
|
||||||
|
{isAdmin && <Table.Th>{t("Author")}</Table.Th>}
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateApiKeyModal
|
||||||
|
opened={createOpened}
|
||||||
|
onClose={() => setCreateOpened(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RevealKeyModal
|
||||||
|
keyName={revealTarget?.name ?? null}
|
||||||
|
opened={revealTarget !== null}
|
||||||
|
onClose={() => setRevealTarget(null)}
|
||||||
|
onConfirm={handleCopy}
|
||||||
|
loading={revealMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ApiKeyLifetime,
|
||||||
|
DEFAULT_LIFETIME,
|
||||||
|
lifetimeToExpiresAt,
|
||||||
|
} from "@/features/api-key/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// Resolves the create request, returning true on success. The parent owns the
|
||||||
|
// mutation (and the token it returns); this modal only collects the name +
|
||||||
|
// lifetime. On failure (false) the form state is kept so the user can retry.
|
||||||
|
onSubmit: (values: {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
name: string;
|
||||||
|
lifetime: ApiKeyLifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateApiKeyModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
loading,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
lifetime: DEFAULT_LIFETIME,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
name: (value) =>
|
||||||
|
value.trim().length === 0 ? t("Name is required") : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifetimeOptions: { value: ApiKeyLifetime; label: string }[] = [
|
||||||
|
{ value: "30d", label: t("30 days") },
|
||||||
|
{ value: "90d", label: t("90 days") },
|
||||||
|
{ value: "1y", label: t("1 year") },
|
||||||
|
{ value: "never", label: t("No expiration") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
|
const ok = await onSubmit({
|
||||||
|
name: values.name.trim(),
|
||||||
|
expiresAt: lifetimeToExpiresAt(values.lifetime),
|
||||||
|
});
|
||||||
|
// Reset only after a successful submit so a failed create keeps the form
|
||||||
|
// state (the parent surfaces the error via a notification).
|
||||||
|
if (ok) form.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={t("Create API key")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label={t("Name")}
|
||||||
|
placeholder={t("e.g. CI deploy token")}
|
||||||
|
data-autofocus
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t("Expiration")}
|
||||||
|
data={lifetimeOptions}
|
||||||
|
allowDeselect={false}
|
||||||
|
checkIconPosition="right"
|
||||||
|
{...form.getInputProps("lifetime")}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" onClick={handleClose} type="button">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{t("Create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The key being copied (metadata only — never its token). `null` closes.
|
||||||
|
keyName: string | null;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// Step-up: resolves true when the password was accepted, the token was copied
|
||||||
|
// to the clipboard and the modal should close; false to keep it open (wrong
|
||||||
|
// password) so the user can retry. The password is passed straight through and
|
||||||
|
// never persisted here.
|
||||||
|
onConfirm: (password: string) => Promise<boolean>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password step-up before a copyable key is re-minted + copied. This modal only
|
||||||
|
// ever holds the PASSWORD (transient, cleared on close) — never the revealed
|
||||||
|
// token, which the parent writes straight to the clipboard.
|
||||||
|
export function RevealKeyModal({
|
||||||
|
keyName,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
loading,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setPassword("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password.length === 0) return;
|
||||||
|
const ok = await onConfirm(password);
|
||||||
|
if (ok) close();
|
||||||
|
// On failure keep the modal open; clear the field so a retry starts clean.
|
||||||
|
else setPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={t("Confirm your password")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
'Enter your password to copy the API key "{{name}}" to your clipboard.',
|
||||||
|
{ name: keyName ?? "" },
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("Password")}
|
||||||
|
data-autofocus
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" onClick={close} type="button">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading} disabled={password.length === 0}>
|
||||||
|
{t("Copy to clipboard")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
createApiKey,
|
||||||
|
getApiKeys,
|
||||||
|
revealApiKey,
|
||||||
|
revokeApiKey,
|
||||||
|
} from "@/features/api-key/services/api-key-service";
|
||||||
|
import {
|
||||||
|
IApiKey,
|
||||||
|
ICreateApiKey,
|
||||||
|
ICreateApiKeyResponse,
|
||||||
|
IRevealApiKey,
|
||||||
|
} from "@/features/api-key/types/api-key.types";
|
||||||
|
|
||||||
|
export const API_KEYS_QUERY_KEY = ["api-keys"];
|
||||||
|
|
||||||
|
export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: API_KEYS_QUERY_KEY,
|
||||||
|
queryFn: () => getApiKeys(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mutation.
|
||||||
|
*
|
||||||
|
* SECURITY: the response contains the token. This hook deliberately does NOT
|
||||||
|
* stash it anywhere — the caller reads it from `mutateAsync`'s resolved value
|
||||||
|
* and immediately calls `mutation.reset()` to purge react-query's own copy (the
|
||||||
|
* create flow discards the token; it is re-obtainable later via the reveal/copy
|
||||||
|
* action). `gcTime: 0` is a second belt so nothing lingers in the mutation cache
|
||||||
|
* after the observer unmounts. The list is invalidated here (it carries no token).
|
||||||
|
*/
|
||||||
|
export function useCreateApiKeyMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<ICreateApiKeyResponse, Error, ICreateApiKey>({
|
||||||
|
mutationFn: (data) => createApiKey(data),
|
||||||
|
gcTime: 0,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal (copy) mutation.
|
||||||
|
*
|
||||||
|
* SECURITY (mirrors useCreateApiKeyMutation): the resolved value is the raw
|
||||||
|
* token. This hook deliberately stashes it NOWHERE — the caller reads it from
|
||||||
|
* `mutateAsync`, writes it straight to the clipboard, then calls
|
||||||
|
* `mutation.reset()` to purge react-query's own copy. `gcTime: 0` is the second
|
||||||
|
* belt so nothing lingers in the mutation cache after the observer unmounts.
|
||||||
|
* There is no `onSuccess` list invalidation: reveal does not change the list.
|
||||||
|
*/
|
||||||
|
export function useRevealApiKeyMutation() {
|
||||||
|
return useMutation<string, Error, IRevealApiKey>({
|
||||||
|
mutationFn: (data) => revealApiKey(data),
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeApiKeyMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (id) => revokeApiKey(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||||
|
notifications.show({ message: t("API key revoked") });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to revoke API key"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
|
||||||
|
// the real response interceptor — which returns `response.data`, i.e. the
|
||||||
|
// server envelope { data, success, status } — is bypassed. Our mocked post/get
|
||||||
|
// therefore resolve to that post-interceptor envelope shape directly, and the
|
||||||
|
// service under test reads `.data` off it to unwrap the inner payload. This is
|
||||||
|
// the one bug-prone line the component tests (which fully mock the service)
|
||||||
|
// never exercise.
|
||||||
|
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
|
||||||
|
vi.mock("@/lib/api-client", () => ({
|
||||||
|
default: { post, get },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createApiKey,
|
||||||
|
getApiKeys,
|
||||||
|
revealApiKey,
|
||||||
|
} from "@/features/api-key/services/api-key-service";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api-key-service response-contract unwrap", () => {
|
||||||
|
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
|
||||||
|
const payload = {
|
||||||
|
token: "gm_secret-abc",
|
||||||
|
apiKey: {
|
||||||
|
id: "key-1",
|
||||||
|
name: "CI token",
|
||||||
|
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||||
|
createdAt: "2026-07-11T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The server envelope as the interceptor hands it to the service.
|
||||||
|
post.mockResolvedValue({ data: payload, success: true, status: 200 });
|
||||||
|
|
||||||
|
const result = await createApiKey({
|
||||||
|
name: "CI token",
|
||||||
|
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The inner payload only — not the { data, success, status } wrapper.
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
expect(result.token).toBe("gm_secret-abc");
|
||||||
|
expect(result.apiKey).toEqual(payload.apiKey);
|
||||||
|
expect(post).toHaveBeenCalledWith("/api-keys/create", {
|
||||||
|
name: "CI token",
|
||||||
|
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getApiKeys unwraps the envelope to the array of rows", async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: "key-1",
|
||||||
|
name: "CI token",
|
||||||
|
expiresAt: null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "key-2",
|
||||||
|
name: "Deploy token",
|
||||||
|
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
post.mockResolvedValue({ data: rows, success: true, status: 200 });
|
||||||
|
|
||||||
|
const result = await getApiKeys();
|
||||||
|
|
||||||
|
expect(result).toEqual(rows);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(post).toHaveBeenCalledWith("/api-keys/list");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revealApiKey unwraps the envelope to the bare token string", async () => {
|
||||||
|
const SECRET = "gm_revealed-token";
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: { token: SECRET },
|
||||||
|
success: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await revealApiKey({ id: "key-1", password: "pw" });
|
||||||
|
|
||||||
|
// The service returns ONLY the token string (not the { token } wrapper), so
|
||||||
|
// the caller can copy it straight to the clipboard.
|
||||||
|
expect(result).toBe(SECRET);
|
||||||
|
expect(post).toHaveBeenCalledWith("/api-keys/reveal", {
|
||||||
|
id: "key-1",
|
||||||
|
password: "pw",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import {
|
||||||
|
IApiKey,
|
||||||
|
ICreateApiKey,
|
||||||
|
ICreateApiKeyResponse,
|
||||||
|
IRevealApiKey,
|
||||||
|
} from "@/features/api-key/types/api-key.types";
|
||||||
|
|
||||||
|
// Mint a new key. The response carries the token, but the create flow now
|
||||||
|
// DISCARDS it (the user copies the key later via the per-row reveal action, so
|
||||||
|
// there is no show-once modal) — the caller must never cache or persist it. See
|
||||||
|
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
|
||||||
|
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
|
||||||
|
// after the mint) for the reset()-after-read discipline the reveal path mirrors.
|
||||||
|
export async function createApiKey(
|
||||||
|
data: ICreateApiKey,
|
||||||
|
): Promise<ICreateApiKeyResponse> {
|
||||||
|
const res = await api.post<ICreateApiKeyResponse>("/api-keys/create", data);
|
||||||
|
return res.data as ICreateApiKeyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List the caller's keys (or, for an admin, every key in the workspace with
|
||||||
|
// creator attribution). Never returns token material.
|
||||||
|
export async function getApiKeys(): Promise<IApiKey[]> {
|
||||||
|
const res = await api.post<IApiKey[]>("/api-keys/list");
|
||||||
|
return res.data as IApiKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revocation is server-side immediate; the caller drops the row on success.
|
||||||
|
export async function revokeApiKey(id: string): Promise<void> {
|
||||||
|
await api.post("/api-keys/revoke", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal (re-mint) an existing key's token under a password step-up. Returns the
|
||||||
|
// bare token string — the SECURITY contract (mirrors create): the caller must
|
||||||
|
// write it straight to the clipboard and never stash it in state, the query
|
||||||
|
// cache or localStorage. See useRevealApiKeyMutation (gcTime: 0 + reset-after-
|
||||||
|
// read) and api-keys-manager.tsx `handleCopy`.
|
||||||
|
export async function revealApiKey(data: IRevealApiKey): Promise<string> {
|
||||||
|
const res = await api.post<{ token: string }>("/api-keys/reveal", data);
|
||||||
|
return (res.data as { token: string }).token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Compact creator attribution embedded in the admin (workspace-wide) list. A
|
||||||
|
// normal member's list only ever contains their own keys, so the field is
|
||||||
|
// present but redundant; the author column is only rendered for admins.
|
||||||
|
export interface IApiKeyCreator {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single api-key row as returned by `POST /api/api-keys/list`. Note: the list
|
||||||
|
// NEVER carries token material — only metadata.
|
||||||
|
export interface IApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// ISO string, or null for an unlimited ("never expires") key.
|
||||||
|
expiresAt: string | null;
|
||||||
|
// ISO string, or null if the key was never used. Throttled to ~1h server-side
|
||||||
|
// (#501), so the UI must not promise sub-hour precision.
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
creator?: IApiKeyCreator | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload for `POST /api/api-keys/create`. `expiresAt`: an ISO date string for a
|
||||||
|
// bounded lifetime, or null for an unlimited key. (undefined would let the
|
||||||
|
// server apply its 1-year default, but the form always sends an explicit value.)
|
||||||
|
export interface ICreateApiKey {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The metadata half of the create response. The token itself is carried
|
||||||
|
// separately (see ICreateApiKeyResponse); it is re-obtainable later by its owner
|
||||||
|
// via a deterministic re-mint under a step-up (POST /api-keys/reveal).
|
||||||
|
export interface ICreatedApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response of `POST /api/api-keys/create`. `token` is returned on create; it is
|
||||||
|
// ALSO retrievable later via reveal (deterministic re-mint under a step-up), so
|
||||||
|
// it is no longer "show once". It must never be cached, persisted or logged —
|
||||||
|
// the create flow discards it (the user copies via the per-row Copy action).
|
||||||
|
export interface ICreateApiKeyResponse {
|
||||||
|
token: string;
|
||||||
|
apiKey: ICreatedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload for `POST /api/api-keys/reveal`: the key id + the caller's current
|
||||||
|
// password (step-up). The response is `{ token }` — a re-minted, byte-identical
|
||||||
|
// copy of the key's token, which must be written straight to the clipboard and
|
||||||
|
// never held in state, cache or storage.
|
||||||
|
export interface IRevealApiKey {
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_LIFETIME,
|
||||||
|
EXPIRY_WARNING_DAYS,
|
||||||
|
isExpired,
|
||||||
|
isExpiringSoon,
|
||||||
|
lastUsedBucket,
|
||||||
|
lifetimeToExpiresAt,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-07-11T12:00:00.000Z");
|
||||||
|
const daysFromNow = (n: number) =>
|
||||||
|
new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
describe("lifetimeToExpiresAt", () => {
|
||||||
|
it("default lifetime is 1 year (acceptance #7)", () => {
|
||||||
|
expect(DEFAULT_LIFETIME).toBe("1y");
|
||||||
|
const iso = lifetimeToExpiresAt("1y", NOW);
|
||||||
|
expect(iso).toBe("2027-07-11T12:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"never" sends null (acceptance #7)', () => {
|
||||||
|
expect(lifetimeToExpiresAt("never", NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("30d / 90d map to the exact future instant", () => {
|
||||||
|
expect(lifetimeToExpiresAt("30d", NOW)).toBe(daysFromNow(30));
|
||||||
|
expect(lifetimeToExpiresAt("90d", NOW)).toBe(daysFromNow(90));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isExpiringSoon (acceptance #3 highlight)", () => {
|
||||||
|
it("an unlimited key is never 'soon'", () => {
|
||||||
|
expect(isExpiringSoon(null, NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights a key expiring within the 30-day window", () => {
|
||||||
|
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(true);
|
||||||
|
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight a key well outside the window", () => {
|
||||||
|
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS + 1), NOW)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isExpiringSoon(daysFromNow(200), NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-expired key is highlighted", () => {
|
||||||
|
expect(isExpiringSoon(daysFromNow(-3), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isExpired (past vs future expiry)", () => {
|
||||||
|
it("an unlimited key is never expired", () => {
|
||||||
|
expect(isExpired(null, NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a past expiry is expired", () => {
|
||||||
|
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||||
|
expect(isExpired(daysFromNow(-1), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a future expiry is not expired (even within the warning window)", () => {
|
||||||
|
expect(isExpired(daysFromNow(1), NOW)).toBe(false);
|
||||||
|
expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false);
|
||||||
|
expect(isExpired(daysFromNow(200), NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => {
|
||||||
|
expect(isExpired(NOW.toISOString(), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => {
|
||||||
|
// A key 3 days in the past: expired, and (by design) also matches
|
||||||
|
// isExpiringSoon — the UI resolves this by checking isExpired first.
|
||||||
|
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||||
|
// A key 10 days in the future: NOT expired, but expiring soon.
|
||||||
|
expect(isExpired(daysFromNow(10), NOW)).toBe(false);
|
||||||
|
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lastUsedBucket (within-the-last-hour semantics)", () => {
|
||||||
|
const minutesAgo = (n: number) =>
|
||||||
|
new Date(NOW.getTime() - n * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
it("null last-used is 'never'", () => {
|
||||||
|
expect(lastUsedBucket(null, NOW)).toBe("never");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("under an hour is 'recent' (no sub-hour precision promised)", () => {
|
||||||
|
expect(lastUsedBucket(minutesAgo(5), NOW)).toBe("recent");
|
||||||
|
expect(lastUsedBucket(minutesAgo(59), NOW)).toBe("recent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over an hour is 'stale'", () => {
|
||||||
|
expect(lastUsedBucket(minutesAgo(90), NOW)).toBe("stale");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { addDays, addYears, differenceInMinutes } from "date-fns";
|
||||||
|
|
||||||
|
// The single early-warning window (#501/#506): keys whose expiry is closer than
|
||||||
|
// this are visually highlighted in the list. Also used to classify a key as
|
||||||
|
// already-expired (a negative "days until" is < 30 too).
|
||||||
|
export const EXPIRY_WARNING_DAYS = 30;
|
||||||
|
|
||||||
|
// last_used_at is throttled to ~1h server-side, so anything under an hour is
|
||||||
|
// shown as the coarse "within the last hour" rather than a false-precise
|
||||||
|
// "5 minutes ago".
|
||||||
|
export const LAST_USED_THROTTLE_MINUTES = 60;
|
||||||
|
|
||||||
|
export type ApiKeyLifetime = "30d" | "90d" | "1y" | "never";
|
||||||
|
|
||||||
|
export const DEFAULT_LIFETIME: ApiKeyLifetime = "1y";
|
||||||
|
|
||||||
|
// Maps a lifetime choice to the `expiresAt` payload sent to the server: an ISO
|
||||||
|
// string for a bounded lifetime, or null for an explicit unlimited key.
|
||||||
|
export function lifetimeToExpiresAt(
|
||||||
|
lifetime: ApiKeyLifetime,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string | null {
|
||||||
|
switch (lifetime) {
|
||||||
|
case "30d":
|
||||||
|
return addDays(now, 30).toISOString();
|
||||||
|
case "90d":
|
||||||
|
return addDays(now, 90).toISOString();
|
||||||
|
case "1y":
|
||||||
|
return addYears(now, 1).toISOString();
|
||||||
|
case "never":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a bounded key's expiry is already in the past (or exactly now). An
|
||||||
|
// unlimited key (null) is never expired. This is distinct from "expiring soon":
|
||||||
|
// the two states are mutually exclusive at the call site (see api-keys-manager),
|
||||||
|
// so an already-expired key is labelled "Expired", not "Expiring soon".
|
||||||
|
export function isExpired(
|
||||||
|
expiresAt: string | null,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
if (Number.isNaN(expiry)) return false;
|
||||||
|
return expiry <= now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a bounded key expires within the warning window (or is already
|
||||||
|
// expired). An unlimited key (null) is never "expiring soon". Callers that need
|
||||||
|
// to distinguish an already-past expiry should check isExpired() first, as this
|
||||||
|
// predicate deliberately also covers the already-expired case.
|
||||||
|
export function isExpiringSoon(
|
||||||
|
expiresAt: string | null,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
if (Number.isNaN(expiry)) return false;
|
||||||
|
const msLeft = expiry - now.getTime();
|
||||||
|
return msLeft < EXPIRY_WARNING_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classifies last-used recency so the caller can pick the honest label without
|
||||||
|
// promising sub-hour precision. Returns "never", "recent" (< 1h) or "stale".
|
||||||
|
export function lastUsedBucket(
|
||||||
|
lastUsedAt: string | null,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): "never" | "recent" | "stale" {
|
||||||
|
if (!lastUsedAt) return "never";
|
||||||
|
const used = new Date(lastUsedAt);
|
||||||
|
if (Number.isNaN(used.getTime())) return "never";
|
||||||
|
return differenceInMinutes(now, used) < LAST_USED_THROTTLE_MINUTES
|
||||||
|
? "recent"
|
||||||
|
: "stale";
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { createInstance } from "i18next";
|
||||||
|
import { initReactI18next, I18nextProvider } from "react-i18next";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The suggestion mutations reach react-query/network — stub them so the card
|
||||||
|
// renders in isolation. We assert the Apply/Dismiss gating and that the click
|
||||||
|
// hands the {commentId, pageId} pair to the existing mutation unchanged.
|
||||||
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
|
useApplySuggestionMutation: () => ({
|
||||||
|
mutateAsync: applyMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CommentContentView -> mention-view -> page-query/share-query pull in the app
|
||||||
|
// entry (createRoot) as a side effect; stub the queries so the card renders in
|
||||||
|
// isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query.ts -> main.tsx (createRoot) is a module side effect reached via the
|
||||||
|
// mention view; stub it so importing the card is side-effect free.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useSpaceQuery: () => ({ data: undefined }),
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AgentEditCard, { RunHeader } from "./agent-edit-card";
|
||||||
|
|
||||||
|
const body = (text: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const edit = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
content: body("[Существенно] tighten the wording"),
|
||||||
|
creatorId: "user-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
createdSource: "agent",
|
||||||
|
aiChatId: "chat-1",
|
||||||
|
agent: { name: "Corrector", emoji: "✏️", avatarUrl: null },
|
||||||
|
launcher: { name: "Alice", avatarUrl: null },
|
||||||
|
creator: { id: "user-1", name: "Corrector", avatarUrl: null } as any,
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
function renderCard(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<AgentEditCard
|
||||||
|
comment={comment}
|
||||||
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AgentEditCard — suggested edit diff + Apply (#315)", () => {
|
||||||
|
it("renders the было→стало diff and an Apply button when canEdit, not applied/resolved", () => {
|
||||||
|
const { container } = renderCard(edit(), true);
|
||||||
|
// Both diff lines are present (old struck-through, new added).
|
||||||
|
expect(container.textContent).toContain("old wording here");
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
|
// Diff line signs (aria-hidden) present for a replacement.
|
||||||
|
expect(container.textContent).toContain("−");
|
||||||
|
expect(container.textContent).toContain("+");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Apply when canEdit is false (still shows the diff)", () => {
|
||||||
|
const { container } = renderCard(edit(), false);
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Apply once the thread is resolved", () => {
|
||||||
|
renderCard(edit({ resolvedAt: new Date() }), true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Apply once suggestionAppliedAt is set", () => {
|
||||||
|
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Applied badge for an applied suggestion, not a pending one (F1)", () => {
|
||||||
|
// Pending: no Applied badge.
|
||||||
|
const { unmount } = renderCard(edit(), true);
|
||||||
|
expect(screen.queryByText("Applied")).toBeNull();
|
||||||
|
unmount();
|
||||||
|
// Applied (kept alive by replies -> resolved, #329): the badge is restored.
|
||||||
|
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the apply mutation with {commentId, pageId} on click", () => {
|
||||||
|
applyMutateAsync.mockClear();
|
||||||
|
renderCard(edit(), true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||||
|
expect(applyMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pure insertion (empty selection) hides the removed line", () => {
|
||||||
|
const { container } = renderCard(
|
||||||
|
edit({ selection: "", suggestedText: "added text" }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// No "−" del sign — nothing was removed.
|
||||||
|
expect(container.textContent).not.toContain("−");
|
||||||
|
expect(container.textContent).toContain("+");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pure deletion (empty suggestedText) hides the added line", () => {
|
||||||
|
const { container } = renderCard(
|
||||||
|
edit({ selection: "removed text", suggestedText: "" }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain("+");
|
||||||
|
expect(container.textContent).toContain("−");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AgentEditCard — Dismiss gate (#329/#338)", () => {
|
||||||
|
it("shows Dismiss alongside Apply for an admin who can edit/comment", () => {
|
||||||
|
renderCard(edit(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderCard(edit(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin (mirrors server 403, #338 F5)", () => {
|
||||||
|
renderCard(edit(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderCard(edit(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation with {commentId, pageId} on click", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderCard(edit(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AgentEditCard — provenance", () => {
|
||||||
|
it("renders the agent avatar stack (provenance) for the edit author (#300)", () => {
|
||||||
|
renderCard(edit(), true);
|
||||||
|
// The agent role name is shown by the provenance stack.
|
||||||
|
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The owner-or-admin gate uses the currentUser atom; clear localStorage so a
|
||||||
|
// previous test's seed never leaks into the non-owner assertions above.
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
});
|
||||||
|
|
||||||
|
// The RunHeader's "N edits · M major" is built with interpolated t() keys; the
|
||||||
|
// default (uninitialized) react-i18next t returns the key verbatim WITHOUT
|
||||||
|
// interpolating, so a numeric assertion needs a real, initialised i18n instance.
|
||||||
|
// This isolated instance carries just the two count keys with escapeValue off so
|
||||||
|
// "{{count}}" is substituted and the rendered numbers are assertable.
|
||||||
|
const headerI18n = createInstance();
|
||||||
|
headerI18n.use(initReactI18next).init({
|
||||||
|
lng: "en",
|
||||||
|
fallbackLng: "en",
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
"{{count}} edits": "{{count}} edits",
|
||||||
|
"{{count}} major": "{{count}} major",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const runComment = (id: string, tag: string): IComment =>
|
||||||
|
edit({
|
||||||
|
id,
|
||||||
|
content: body(`${tag} some rationale`),
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRunHeader(comments: IComment[]) {
|
||||||
|
return render(
|
||||||
|
<I18nextProvider i18n={headerI18n}>
|
||||||
|
<MantineProvider>
|
||||||
|
<RunHeader comments={comments} />
|
||||||
|
</MantineProvider>
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RunHeader — agent-run series header (F3)", () => {
|
||||||
|
// 5 edits: 2 critical + 1 major + 1 minor + 1 unknown(verdict) => 3 "major".
|
||||||
|
const series = () => [
|
||||||
|
runComment("e1", "[Критично]"),
|
||||||
|
runComment("e2", "[Критично]"),
|
||||||
|
runComment("e3", "[Существенно]"),
|
||||||
|
runComment("e4", "[Незначительно]"),
|
||||||
|
runComment("e5", "[Неверно]"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("shows the total edit count", () => {
|
||||||
|
renderRunHeader(series());
|
||||||
|
expect(screen.getByText(/5 edits/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts ONLY major+critical as major (not minor/unknown)", () => {
|
||||||
|
renderRunHeader(series());
|
||||||
|
// 2 critical + 1 major = 3; minor and the [Неверно] verdict are excluded.
|
||||||
|
expect(screen.getByText(/3 major/)).toBeDefined();
|
||||||
|
// Non-vacuous: the wrong tally (counting all 5, or 4) must NOT appear.
|
||||||
|
expect(screen.queryByText(/5 major/)).toBeNull();
|
||||||
|
expect(screen.queryByText(/4 major/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the major segment entirely when there are no significant edits", () => {
|
||||||
|
renderRunHeader([
|
||||||
|
runComment("e1", "[Незначительно]"),
|
||||||
|
runComment("e2", "[Неверно]"),
|
||||||
|
]);
|
||||||
|
expect(screen.getByText(/2 edits/)).toBeDefined();
|
||||||
|
expect(screen.queryByText(/major/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the provenance line (agent role name)", () => {
|
||||||
|
renderRunHeader(series());
|
||||||
|
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders NO 'Accept all' control (rejected by product)", () => {
|
||||||
|
renderRunHeader(series());
|
||||||
|
expect(screen.queryByText(/accept all/i)).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /accept all/i }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
Segment,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
|
||||||
|
import {
|
||||||
|
isSignificant,
|
||||||
|
parseSeverity,
|
||||||
|
Severity,
|
||||||
|
} from "@/features/comment/utils/severity";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
|
|
||||||
|
// Scroll the document to this comment's inline mark and flash it — the same
|
||||||
|
// anchor navigation the old panel used (handleCommentClick). Used both by a card
|
||||||
|
// click and by an explicit "Go to text" affordance.
|
||||||
|
function scrollToCommentMark(commentId: string) {
|
||||||
|
const el = document.querySelector(
|
||||||
|
`.comment-mark[data-comment-id="${commentId}"]`,
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
el.classList.add("comment-highlight");
|
||||||
|
setTimeout(() => el.classList.remove("comment-highlight"), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make otherwise-invisible edited whitespace legible inside a highlighted diff
|
||||||
|
// fragment (a pure-space insertion/deletion would otherwise look like nothing
|
||||||
|
// changed).
|
||||||
|
function visibleWhitespace(s: string): string {
|
||||||
|
return s.replace(/ /g, "␣").replace(/\t/g, "⇥");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One line of the intraline diff. `kind==="del"` is the old ("было") line drawn
|
||||||
|
// red with a strike-through; `kind==="ins"` is the new ("стало") line drawn
|
||||||
|
// green. Only the `changed` segments carry the emphasised mark background — the
|
||||||
|
// common segments read as plain context (git/GitHub-style, #331).
|
||||||
|
function DiffLine({
|
||||||
|
segments,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
segments: Segment[];
|
||||||
|
kind: "del" | "ins";
|
||||||
|
}) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
const isDel = kind === "del";
|
||||||
|
const sign = isDel ? "−" : "+";
|
||||||
|
const signColor = isDel
|
||||||
|
? theme.colors.red[dark ? 5 : 6]
|
||||||
|
: theme.colors.green[dark ? 5 : 6];
|
||||||
|
const baseColor = isDel
|
||||||
|
? dark
|
||||||
|
? theme.colors.gray[5]
|
||||||
|
: theme.colors.gray[6]
|
||||||
|
: dark
|
||||||
|
? theme.colors.gray[1]
|
||||||
|
: theme.colors.dark[9];
|
||||||
|
const markBg = isDel
|
||||||
|
? dark
|
||||||
|
? "rgba(224,49,49,.22)"
|
||||||
|
: "#ffe3e3"
|
||||||
|
: dark
|
||||||
|
? "rgba(47,158,68,.22)"
|
||||||
|
: "#d3f9d8";
|
||||||
|
const markFg = isDel
|
||||||
|
? dark
|
||||||
|
? theme.colors.red[3]
|
||||||
|
: theme.colors.red[8]
|
||||||
|
: dark
|
||||||
|
? theme.colors.green[3]
|
||||||
|
: theme.colors.green[9];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={7} wrap="nowrap" align="flex-start">
|
||||||
|
<Text
|
||||||
|
ff="monospace"
|
||||||
|
fw={600}
|
||||||
|
fz={12}
|
||||||
|
c={signColor}
|
||||||
|
w={11}
|
||||||
|
ta="center"
|
||||||
|
aria-hidden
|
||||||
|
style={{ flex: "none", lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{sign}
|
||||||
|
</Text>
|
||||||
|
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
|
||||||
|
{segments.map((seg, i) =>
|
||||||
|
seg.changed ? (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
component="mark"
|
||||||
|
fw={600}
|
||||||
|
style={{
|
||||||
|
background: markBg,
|
||||||
|
color: markFg,
|
||||||
|
borderRadius: 2,
|
||||||
|
// Snug intra-word highlight: a hair of horizontal padding keeps
|
||||||
|
// the mark background slightly wider than the glyph, canceled by
|
||||||
|
// an equal negative margin so neighbouring letters are NOT pushed
|
||||||
|
// apart. The old px={3} padding made single-letter edits (е→ё,
|
||||||
|
// х→е) look like they had spaces around the changed character.
|
||||||
|
padding: "0 1px",
|
||||||
|
margin: "0 -1px",
|
||||||
|
textDecoration: isDel ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleWhitespace(seg.text)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
component="span"
|
||||||
|
style={{ textDecoration: isDel ? "line-through" : "none" }}
|
||||||
|
>
|
||||||
|
{seg.text}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "было → стало" block. A pure insertion (empty `before`) hides the old
|
||||||
|
// line; a pure deletion (empty `after`) hides the new line.
|
||||||
|
function DiffBlock({ before, after }: { before: Segment[]; after: Segment[] }) {
|
||||||
|
const pureInsert = before.length === 0;
|
||||||
|
const pureDelete = after.length === 0;
|
||||||
|
return (
|
||||||
|
<Stack gap={1}>
|
||||||
|
{!pureInsert && <DiffLine segments={before} kind="del" />}
|
||||||
|
{!pureDelete && <DiffLine segments={after} kind="ins" />}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEV_DOT: Record<Severity, { color: string; shade: number }> = {
|
||||||
|
critical: { color: "red", shade: 6 },
|
||||||
|
major: { color: "orange", shade: 6 },
|
||||||
|
minor: { color: "gray", shade: 5 },
|
||||||
|
// A neutral, deliberately faint dot — NOT the minor grey — so an untagged or
|
||||||
|
// verdict-only edit never reads as a graded severity.
|
||||||
|
unknown: { color: "gray", shade: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function SeverityDot({ severity }: { severity: Severity }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w={8}
|
||||||
|
h={8}
|
||||||
|
aria-hidden
|
||||||
|
style={(t) => ({
|
||||||
|
flex: "none",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: t.colors[SEV_DOT[severity].color][SEV_DOT[severity].shade],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentEditCardProps {
|
||||||
|
comment: IComment;
|
||||||
|
canComment: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
|
userSpaceRole?: string;
|
||||||
|
// Whether to render the per-card agent avatar + timestamp. A card inside a
|
||||||
|
// collapsed run omits it (the RunHeader carries the one provenance line);
|
||||||
|
// a standalone card shows it (#300 provenance must be visible on the card).
|
||||||
|
showProvenance?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type A — the diff-first agent suggested-edit card. It carries NO TipTap editor
|
||||||
|
// (a perf win vs. the old row, #340); the body renders through the static
|
||||||
|
// CommentContentView. Apply/Dismiss reuse the existing mutations and gates
|
||||||
|
// verbatim — the reskin changes presentation only, not the 409/404/400 toast
|
||||||
|
// semantics or the authz gating.
|
||||||
|
function AgentEditCard({
|
||||||
|
comment,
|
||||||
|
canComment,
|
||||||
|
canEdit,
|
||||||
|
userSpaceRole,
|
||||||
|
showProvenance = true,
|
||||||
|
}: AgentEditCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
|
const diff = useMemo(
|
||||||
|
() =>
|
||||||
|
computeSuggestionDiff(comment.selection ?? "", comment.suggestedText ?? ""),
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const severity = useMemo(
|
||||||
|
() => parseSeverity(commentContentToText(comment.content)),
|
||||||
|
[comment.content],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338), mirrored from the old row so we never
|
||||||
|
// render a Dismiss the server would 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
const isApplied = comment.suggestionAppliedAt != null;
|
||||||
|
const showApply = canShowApply(comment, canEdit);
|
||||||
|
const showDismiss = canShowDismiss(comment, canComment, isOwnerOrAdmin);
|
||||||
|
const pending =
|
||||||
|
applySuggestionMutation.isPending || dismissSuggestionMutation.isPending;
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
try {
|
||||||
|
await applySuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Errors (incl. 409 "text changed") surface via the mutation's onError.
|
||||||
|
console.error("Failed to apply suggestion:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent 404 is reconciled to success in the mutation's onError.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sevLabel =
|
||||||
|
severity === "critical"
|
||||||
|
? t("Critical")
|
||||||
|
: severity === "major"
|
||||||
|
? t("Major")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p="10px 12px"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("Jump to comment selection")}
|
||||||
|
onClick={() => scrollToCommentMark(comment.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
scrollToCommentMark(comment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{showProvenance && comment.agent && (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
justify="space-between"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AgentAvatarStack
|
||||||
|
agent={comment.agent}
|
||||||
|
launcher={comment.launcher}
|
||||||
|
aiChatId={comment.aiChatId}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
|
||||||
|
{createdAtAgo}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DiffBlock before={diff.old} after={diff.new} />
|
||||||
|
|
||||||
|
{/* Agent rationale — rendered through the static ProseMirror view, not
|
||||||
|
restructured into a flat string. */}
|
||||||
|
<Box fz="xs" c="dimmed">
|
||||||
|
<CommentContentView content={comment.content} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<SeverityDot severity={severity} />
|
||||||
|
{sevLabel && (
|
||||||
|
<Text
|
||||||
|
fz={10}
|
||||||
|
fw={600}
|
||||||
|
tt="uppercase"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ letterSpacing: ".06em", flex: 1 }}
|
||||||
|
>
|
||||||
|
{sevLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1 }} />
|
||||||
|
{/* Applied state (#315): a suggestion that was applied but kept alive by
|
||||||
|
its replies (so #329 resolved instead of hard-deleting it) still
|
||||||
|
shows it was applied — the badge the pre-redesign card carried. A
|
||||||
|
childless applied suggestion is gone from the list entirely, so this
|
||||||
|
only renders in the Resolved tab. */}
|
||||||
|
{isApplied && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
aria-label={t("Applied")}
|
||||||
|
>
|
||||||
|
{t("Applied")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{showDismiss && (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="default"
|
||||||
|
color="gray"
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleDismiss}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showApply && (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="green"
|
||||||
|
loading={applySuggestionMutation.isPending}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series header for a collapsed agent run: ONE provenance line for N edits,
|
||||||
|
// with a "N edits · M major" tally. There is intentionally NO "Accept all"
|
||||||
|
// button / progress / Stop (rejected by product) and NO "K applied" counter
|
||||||
|
// (uncomputable after the #329 hard-delete). Purely visual.
|
||||||
|
export function RunHeader({ comments }: { comments: IComment[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const head = comments[0];
|
||||||
|
const createdAtAgo = useTimeAgo(head?.createdAt);
|
||||||
|
|
||||||
|
const majors = useMemo(
|
||||||
|
() =>
|
||||||
|
comments.filter((c) =>
|
||||||
|
isSignificant(parseSeverity(commentContentToText(c.content))),
|
||||||
|
).length,
|
||||||
|
[comments],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!head) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p="11px 13px"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} wrap="nowrap" justify="space-between">
|
||||||
|
{head.agent ? (
|
||||||
|
<AgentAvatarStack
|
||||||
|
agent={head.agent}
|
||||||
|
launcher={head.launcher}
|
||||||
|
aiChatId={head.aiChatId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{head.creator?.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
|
||||||
|
{createdAtAgo}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz={11.5} c="dimmed" mt={4}>
|
||||||
|
{t("{{count}} edits", { count: comments.length })}
|
||||||
|
{majors > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<Text span c="orange.7" fw={600} inherit>
|
||||||
|
{t("{{count}} major", { count: majors })}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(AgentEditCard);
|
||||||
@@ -156,125 +156,6 @@ describe("CommentListItem — agent avatar stack", () => {
|
|||||||
// only guards the insertion gate (agent → stack, user → no stack).
|
// only guards the insertion gate (agent → stack, user → no stack).
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CommentListItem — suggested edit (#315)", () => {
|
|
||||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
|
||||||
baseComment({
|
|
||||||
selection: "old wording here",
|
|
||||||
suggestedText: "new wording here",
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
|
||||||
const { container } = renderItem(suggestion(), true);
|
|
||||||
// Old text appears as the selection quote (a single unsplit Text node).
|
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
|
||||||
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
|
||||||
// so it is no longer a single text node — assert the concatenated content.
|
|
||||||
expect(container.textContent).toContain("new wording here");
|
|
||||||
// Apply button is present.
|
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
|
||||||
// No Applied badge yet.
|
|
||||||
expect(screen.queryByText("Applied")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
|
||||||
const { container } = renderItem(suggestion(), false);
|
|
||||||
// Diff still renders (as per-fragment spans, #331)...
|
|
||||||
expect(container.textContent).toContain("new wording here");
|
|
||||||
// ...but no Apply button.
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
|
|
||||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
|
|
||||||
expect(screen.getByText("Applied")).toBeDefined();
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Apply button once the thread is resolved", () => {
|
|
||||||
renderItem(suggestion({ resolvedAt: new Date() }), true);
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls the apply mutation when the Apply button is clicked", () => {
|
|
||||||
applyMutateAsync.mockClear();
|
|
||||||
renderItem(suggestion(), true);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
|
||||||
expect(applyMutateAsync).toHaveBeenCalledWith({
|
|
||||||
commentId: "c-1",
|
|
||||||
pageId: "page-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render the diff block for a reply (child) comment", () => {
|
|
||||||
renderItem(
|
|
||||||
suggestion({ parentCommentId: "c-0" }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("new wording here")).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
|
||||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
|
||||||
baseComment({
|
|
||||||
selection: "old wording here",
|
|
||||||
suggestedText: "new wording here",
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
|
||||||
// regardless of who authored the comment; the tests below use it as the lever
|
|
||||||
// since the currentUser atom is unseeded (null) in this harness.
|
|
||||||
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
|
||||||
renderItem(suggestion(), true, true, "admin");
|
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
|
||||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
|
||||||
renderItem(suggestion(), false, true, "admin");
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides Dismiss when the viewer cannot comment", () => {
|
|
||||||
renderItem(suggestion(), false, false, "admin");
|
|
||||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
|
||||||
// canComment=true but NOT a space admin and NOT the comment owner (the
|
|
||||||
// currentUser atom is null while the comment is authored by user-1), so the
|
|
||||||
// server would 403 a dismiss — the button must not be shown at all.
|
|
||||||
renderItem(suggestion(), false, true, "member");
|
|
||||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides Dismiss once the thread is resolved", () => {
|
|
||||||
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
|
||||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
|
||||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
|
||||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
|
||||||
expect(screen.getByText("Applied")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
|
||||||
dismissMutateAsync.mockClear();
|
|
||||||
renderItem(suggestion(), true, true, "admin");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
|
||||||
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
|
||||||
commentId: "c-1",
|
|
||||||
pageId: "page-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("canShowApply predicate", () => {
|
describe("canShowApply predicate", () => {
|
||||||
const c = (over?: Partial<IComment>): IComment =>
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
({ suggestedText: "x", ...over }) as IComment;
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { 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";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
@@ -12,18 +12,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
|
|||||||
import ResolveComment from "@/features/comment/components/resolve-comment";
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
useApplySuggestionMutation,
|
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
useDismissSuggestionMutation,
|
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import {
|
|
||||||
canShowApply,
|
|
||||||
canShowDismiss,
|
|
||||||
computeSuggestionDiff,
|
|
||||||
} from "@/features/comment/utils/suggestion";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -32,13 +25,21 @@ interface CommentListItemProps {
|
|||||||
comment: IComment;
|
comment: IComment;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
|
// Real page-edit permission (page.permissions.canEdit). Kept on the props for
|
||||||
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
|
// parity with the container's wiring even though the thread row itself no
|
||||||
// allowed to comment cannot apply edits).
|
// longer renders the suggestion Apply button (that moved to AgentEditCard).
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type B — the thread ROW. Renders a single human OR agent-without-edit comment
|
||||||
|
// in the redesigned visual: provenance avatar, author + timeago, hover-revealed
|
||||||
|
// resolve + edit/delete menu, the anchored selection quote, and the body through
|
||||||
|
// the static CommentContentView (or the inline TipTap editor while editing). It
|
||||||
|
// is used for both a top-level thread comment and, recursively, each reply row.
|
||||||
|
// ALL wiring (update/delete/resolve mutations, owner/admin gate, anchor nav) is
|
||||||
|
// the same logic the old row carried — only the presentation changed, and the
|
||||||
|
// agent suggested-edit block was lifted out into AgentEditCard.
|
||||||
function CommentListItem({
|
function CommentListItem({
|
||||||
comment,
|
comment,
|
||||||
pageId,
|
pageId,
|
||||||
@@ -55,29 +56,20 @@ function CommentListItem({
|
|||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const applySuggestionMutation = useApplySuggestionMutation();
|
|
||||||
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
// `canEdit`/`pageId` are threaded through for wiring parity with the container;
|
||||||
// fragments that actually changed get emphasised inside the red/green block,
|
// the thread row does not itself gate on them (Apply lives on AgentEditCard).
|
||||||
// instead of striking through / greening the whole line. Memoised on the
|
void canEdit;
|
||||||
// (selection, suggestedText) pair so it recomputes only when they change.
|
void pageId;
|
||||||
const suggestionDiff = useMemo(
|
|
||||||
() =>
|
|
||||||
comment.suggestedText != null
|
|
||||||
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
|
||||||
: null,
|
|
||||||
[comment.selection, comment.suggestedText],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
// Owner-or-space-admin gate (#338): mirrors the server authz for the comment
|
||||||
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
// menu (edit/delete), so we never render an action the server will 403.
|
||||||
// render an action the server will 403.
|
|
||||||
const isOwnerOrAdmin =
|
const isOwnerOrAdmin =
|
||||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
const isAgent = comment.createdSource === "agent" && !!comment.agent;
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
@@ -121,34 +113,9 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApplySuggestion() {
|
function handleCommentClick(target: IComment) {
|
||||||
try {
|
|
||||||
await applySuggestionMutation.mutateAsync({
|
|
||||||
commentId: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Errors surface via the mutation's onError notification (incl. 409).
|
|
||||||
console.error("Failed to apply suggestion:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDismissSuggestion() {
|
|
||||||
try {
|
|
||||||
await dismissSuggestionMutation.mutateAsync({
|
|
||||||
commentId: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Idempotent races are reconciled to success in the mutation's onError;
|
|
||||||
// anything else surfaces there as a notification.
|
|
||||||
console.error("Failed to dismiss suggestion:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${target.id}"]`,
|
||||||
);
|
);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
@@ -169,10 +136,10 @@ function CommentListItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} pb={6}>
|
<Box ref={ref} pb={6}>
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||||
{comment.createdSource === "agent" && comment.agent ? (
|
{isAgent ? (
|
||||||
<AgentAvatarStack
|
<AgentAvatarStack
|
||||||
agent={comment.agent}
|
agent={comment.agent!}
|
||||||
launcher={comment.launcher}
|
launcher={comment.launcher}
|
||||||
aiChatId={comment.aiChatId}
|
aiChatId={comment.aiChatId}
|
||||||
showName={false}
|
showName={false}
|
||||||
@@ -185,13 +152,13 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{comment.createdSource === "agent" && comment.agent ? (
|
{isAgent ? (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||||
{comment.agent.name}
|
{comment.agent!.name}
|
||||||
</Text>
|
</Text>
|
||||||
{comment.launcher && (
|
{comment.launcher && (
|
||||||
<>
|
<>
|
||||||
@@ -262,87 +229,6 @@ function CommentListItem({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
|
||||||
carrying a suggestion. Old text struck-through/red, new text green. */}
|
|
||||||
{!comment.parentCommentId && comment.suggestedText && (
|
|
||||||
<Box className={classes.suggestionBlock}>
|
|
||||||
{comment.selection && (
|
|
||||||
// Old line: read as removed as a whole (line-through/red); only the
|
|
||||||
// changed fragments carry the extra intraline emphasis.
|
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
|
||||||
{suggestionDiff?.old.map((segment, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
|
||||||
>
|
|
||||||
{segment.text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
|
||||||
{suggestionDiff?.new.map((segment, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
|
||||||
>
|
|
||||||
{segment.text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
color="green"
|
|
||||||
variant="light"
|
|
||||||
mt={6}
|
|
||||||
aria-label={t("Applied")}
|
|
||||||
>
|
|
||||||
{t("Applied")}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
(canShowApply(comment, canEdit) ||
|
|
||||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
|
||||||
<Group gap="xs" mt={6}>
|
|
||||||
{canShowApply(comment, canEdit) && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="green"
|
|
||||||
onClick={handleApplySuggestion}
|
|
||||||
loading={applySuggestionMutation.isPending}
|
|
||||||
disabled={
|
|
||||||
applySuggestionMutation.isPending ||
|
|
||||||
dismissSuggestionMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Apply")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{/* Dismiss ("Не применять", #329): removes the suggestion
|
|
||||||
without changing the page text. Gated on canComment. */}
|
|
||||||
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={handleDismissSuggestion}
|
|
||||||
loading={dismissSuggestionMutation.isPending}
|
|
||||||
disabled={
|
|
||||||
applySuggestionMutation.isPending ||
|
|
||||||
dismissSuggestionMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Dismiss")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentContentView content={comment.content} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
@@ -350,7 +236,9 @@ function CommentListItem({
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={comment.content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => {
|
||||||
|
editContentRef.current = newContent;
|
||||||
|
}}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||||
|
import AgentEditCard, {
|
||||||
|
RunHeader,
|
||||||
|
} from "@/features/comment/components/agent-edit-card";
|
||||||
import {
|
import {
|
||||||
useCommentsQuery,
|
useCommentsQuery,
|
||||||
useCreateCommentMutation,
|
useCreateCommentMutation,
|
||||||
@@ -22,6 +25,10 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
|||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
|
import {
|
||||||
|
groupAgentRuns,
|
||||||
|
CommentRenderUnit,
|
||||||
|
} from "@/features/comment/utils/group-agent-runs";
|
||||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -69,6 +76,32 @@ export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The redesigned card shell: a rounded, bordered surface on the panel body. Both
|
||||||
|
// a thread card and an agent-run group live inside one of these.
|
||||||
|
function PanelCard({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
m="8px 10px"
|
||||||
|
p={0}
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-body)",
|
||||||
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -90,6 +123,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
canEdit ||
|
canEdit ||
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
(space?.settings?.comments?.allowViewerComments === true);
|
||||||
|
|
||||||
|
const userSpaceRole = space?.membership?.role;
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
if (!comments?.items) {
|
if (!comments?.items) {
|
||||||
@@ -113,6 +148,17 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
};
|
};
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Collapse each tab's top-level list into render units (a lone comment or a
|
||||||
|
// collapsed agent run). Purely visual — the underlying data is untouched.
|
||||||
|
const activeUnits = useMemo(
|
||||||
|
() => groupAgentRuns(activeComments),
|
||||||
|
[activeComments],
|
||||||
|
);
|
||||||
|
const resolvedUnits = useMemo(
|
||||||
|
() => groupAgentRuns(resolvedComments),
|
||||||
|
[resolvedComments],
|
||||||
|
);
|
||||||
|
|
||||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
@@ -168,56 +214,104 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
[createCommentAsync, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
// The full subtree for ONE top-level comment: its head card (thread row or
|
||||||
(comment: IComment) => (
|
// agent edit card), its nested replies, and a lazily-mounted reply editor.
|
||||||
<Paper
|
// Shared by a standalone card and a card inside an agent-run group so the
|
||||||
shadow="sm"
|
// reply threading / lazy editor (#340) is wired identically in both.
|
||||||
radius="md"
|
const renderCommentSubtree = useCallback(
|
||||||
p="xs"
|
(comment: IComment, isEdit: boolean, showProvenance: boolean) => (
|
||||||
mb="xs"
|
<>
|
||||||
withBorder
|
{isEdit ? (
|
||||||
key={comment.id}
|
<AgentEditCard
|
||||||
data-comment-id={comment.id}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<CommentListItem
|
|
||||||
comment={comment}
|
comment={comment}
|
||||||
pageId={page?.id}
|
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={userSpaceRole}
|
||||||
|
showProvenance={showProvenance}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Box p="xs">
|
||||||
|
<CommentListItem
|
||||||
|
comment={comment}
|
||||||
|
pageId={page?.id}
|
||||||
|
canComment={canComment}
|
||||||
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box px="xs">
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
childrenByParent={childrenByParent}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{!comment.resolvedAt && canComment && (
|
{!comment.resolvedAt && canComment && (
|
||||||
<>
|
<Box px="xs" pb="xs">
|
||||||
<Divider my={2} />
|
<Divider my={2} />
|
||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
/>
|
/>
|
||||||
</>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
childrenByParent,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
page?.id,
|
page?.id,
|
||||||
space?.membership?.role,
|
userSpaceRole,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render one collapsed unit: a standalone card (thread or lone edit) or an
|
||||||
|
// agent-run group (one RunHeader over N stacked edit cards).
|
||||||
|
const renderUnit = useCallback(
|
||||||
|
(unit: CommentRenderUnit) => {
|
||||||
|
if (unit.kind === "single") {
|
||||||
|
const c = unit.comment;
|
||||||
|
const isEdit =
|
||||||
|
c.createdSource === "agent" &&
|
||||||
|
c.suggestedText != null &&
|
||||||
|
!c.parentCommentId;
|
||||||
|
return (
|
||||||
|
<PanelCard key={c.id} data-comment-id={c.id}>
|
||||||
|
{renderCommentSubtree(c, isEdit, true)}
|
||||||
|
</PanelCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A collapsed agent run: one header, then each edit card (provenance
|
||||||
|
// suppressed on the cards — the header carries the single provenance line).
|
||||||
|
return (
|
||||||
|
<PanelCard key={unit.key}>
|
||||||
|
<RunHeader comments={unit.comments} />
|
||||||
|
{unit.comments.map((c) => (
|
||||||
|
<Box
|
||||||
|
key={c.id}
|
||||||
|
data-comment-id={c.id}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderCommentSubtree(c, true, false)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</PanelCard>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[renderCommentSubtree],
|
||||||
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
@@ -226,8 +320,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return <div>{t("Error loading comments.")}</div>;
|
return <div>{t("Error loading comments.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalComments = activeComments.length + resolvedComments.length;
|
|
||||||
|
|
||||||
const pageCommentInput = canComment ? (
|
const pageCommentInput = canComment ? (
|
||||||
<PageCommentInput
|
<PageCommentInput
|
||||||
onSave={handleAddPageComment}
|
onSave={handleAddPageComment}
|
||||||
@@ -328,7 +420,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
activeComments.map(renderComments)
|
activeUnits.map(renderUnit)
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -347,7 +439,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
resolvedComments.map(renderComments)
|
resolvedUnits.map(renderUnit)
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,53 +21,6 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Suggested-edit (#315) "было → стало" diff block. */
|
|
||||||
.suggestionBlock {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionOld {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--mantine-color-red-7);
|
|
||||||
background: var(--mantine-color-red-light);
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 1px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionNew {
|
|
||||||
color: var(--mantine-color-green-9);
|
|
||||||
background: var(--mantine-color-green-light);
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 1px 3px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Intraline diff (#331): the fragment that actually changed within the
|
|
||||||
red "before" / green "after" block. It inherits the surrounding red/green
|
|
||||||
framing and adds a stronger tint plus bold weight so the eye lands on the
|
|
||||||
changed letters/words (git/GitHub-style) rather than the whole line. The
|
|
||||||
container's line-through (old) / green (new) still marks the full line. */
|
|
||||||
.suggestionChanged {
|
|
||||||
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
|
||||||
within the block. `currentColor` follows the parent's red (old) or green
|
|
||||||
(new) text colour. No `text-decoration` here on purpose: the old block's
|
|
||||||
inherited line-through must survive on the changed letters too. */
|
|
||||||
background: color-mix(in srgb, currentColor 22%, transparent);
|
|
||||||
border-radius: 2px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { groupAgentRuns, runKey, GROUP_MIN } from "./group-agent-runs";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const editComment = (id: string, over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
createdSource: "agent",
|
||||||
|
aiChatId: "chat-1",
|
||||||
|
agent: { name: "Corrector" },
|
||||||
|
suggestedText: "new text",
|
||||||
|
parentCommentId: null,
|
||||||
|
...over,
|
||||||
|
}) as unknown as IComment;
|
||||||
|
|
||||||
|
const human = (id: string): IComment =>
|
||||||
|
({ id, createdSource: "user", parentCommentId: null }) as unknown as IComment;
|
||||||
|
|
||||||
|
describe("runKey", () => {
|
||||||
|
it("keys a groupable agent edit on aiChatId + agent.name", () => {
|
||||||
|
expect(runKey(editComment("a"))).toBe("chat-1:Corrector");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for an external MCP agent (aiChatId null)", () => {
|
||||||
|
expect(runKey(editComment("a", { aiChatId: null }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for a non-edit agent comment (no suggestedText)", () => {
|
||||||
|
expect(runKey(editComment("a", { suggestedText: null }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for a reply (has parentCommentId)", () => {
|
||||||
|
expect(runKey(editComment("a", { parentCommentId: "p" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for a human comment", () => {
|
||||||
|
expect(runKey(human("a"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupAgentRuns", () => {
|
||||||
|
it("collapses >= GROUP_MIN same chat+role edits into one run at the first position", () => {
|
||||||
|
const units = groupAgentRuns([
|
||||||
|
editComment("e1"),
|
||||||
|
editComment("e2"),
|
||||||
|
editComment("e3"),
|
||||||
|
]);
|
||||||
|
expect(units).toHaveLength(1);
|
||||||
|
expect(units[0].kind).toBe("run");
|
||||||
|
if (units[0].kind === "run") {
|
||||||
|
expect(units[0].key).toBe("chat-1:Corrector");
|
||||||
|
expect(units[0].comments.map((c) => c.id)).toEqual(["e1", "e2", "e3"]);
|
||||||
|
}
|
||||||
|
expect(GROUP_MIN).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a lone edit as a single (below the threshold)", () => {
|
||||||
|
const units = groupAgentRuns([editComment("e1")]);
|
||||||
|
expect(units).toHaveLength(1);
|
||||||
|
expect(units[0].kind).toBe("single");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never groups external MCP edits (aiChatId null) — each is a single", () => {
|
||||||
|
const units = groupAgentRuns([
|
||||||
|
editComment("m1", { aiChatId: null }),
|
||||||
|
editComment("m2", { aiChatId: null }),
|
||||||
|
]);
|
||||||
|
expect(units).toHaveLength(2);
|
||||||
|
expect(units.every((u) => u.kind === "single")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collapse two different roles sharing one chat", () => {
|
||||||
|
const units = groupAgentRuns([
|
||||||
|
editComment("a", { agent: { name: "Corrector" } as any }),
|
||||||
|
editComment("b", { agent: { name: "FactChecker" } as any }),
|
||||||
|
]);
|
||||||
|
// Each key has count 1 -> both remain singles.
|
||||||
|
expect(units).toHaveLength(2);
|
||||||
|
expect(units.every((u) => u.kind === "single")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves order and keeps human threads as singles interleaved with a run", () => {
|
||||||
|
const units = groupAgentRuns([
|
||||||
|
human("h1"),
|
||||||
|
editComment("e1"),
|
||||||
|
editComment("e2"),
|
||||||
|
human("h2"),
|
||||||
|
]);
|
||||||
|
// h1 single, then the run (emitted at e1's position, e2 absorbed), then h2.
|
||||||
|
expect(units.map((u) => u.kind)).toEqual(["single", "run", "single"]);
|
||||||
|
if (units[1].kind === "run") {
|
||||||
|
expect(units[1].comments.map((c) => c.id)).toEqual(["e1", "e2"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// A visual-only series threshold: this many agent suggested-edits sharing one
|
||||||
|
// run key collapse under a single RunHeader. A group of one renders as a plain
|
||||||
|
// standalone card (no header). Policy constant — no env override.
|
||||||
|
export const GROUP_MIN = 2;
|
||||||
|
|
||||||
|
// One rendered unit of the top-level comment list: either a collapsed agent-run
|
||||||
|
// group (>= GROUP_MIN suggested-edits from the same chat+role) or a single
|
||||||
|
// comment (a human thread, an agent thread without an edit, or a lone edit).
|
||||||
|
export interface AgentRunGroup {
|
||||||
|
kind: "run";
|
||||||
|
key: string;
|
||||||
|
comments: IComment[];
|
||||||
|
}
|
||||||
|
export interface SingleUnit {
|
||||||
|
kind: "single";
|
||||||
|
comment: IComment;
|
||||||
|
}
|
||||||
|
export type CommentRenderUnit = AgentRunGroup | SingleUnit;
|
||||||
|
|
||||||
|
// The grouping key of a top-level agent suggested-edit, or null when the comment
|
||||||
|
// is not a groupable edit. The key pins BOTH the AI chat and the acting role
|
||||||
|
// (`aiChatId + ":" + agent.name`) so two roles running in the same chat do not
|
||||||
|
// collapse under one header. A comment with `aiChatId == null` (an external MCP
|
||||||
|
// agent) has no chat to group by — it is deliberately never groupable and always
|
||||||
|
// renders as a single card (a time-bucketed synthetic run would split/merge runs
|
||||||
|
// arbitrarily and choke on ISO `createdAt`). PURE.
|
||||||
|
export function runKey(c: IComment): string | null {
|
||||||
|
if (
|
||||||
|
c.createdSource === "agent" &&
|
||||||
|
c.suggestedText != null &&
|
||||||
|
!c.parentCommentId &&
|
||||||
|
c.aiChatId != null &&
|
||||||
|
c.agent?.name
|
||||||
|
) {
|
||||||
|
return `${c.aiChatId}:${c.agent.name}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse an ORDERED list of top-level comments into render units, preserving
|
||||||
|
// list order: a run is emitted in place of its FIRST member (later members are
|
||||||
|
// absorbed), everything else stays a single unit. Grouping is computed over the
|
||||||
|
// snapshot handed in; a streamed page / WS update may later promote a single
|
||||||
|
// edit into a run as more members arrive — that is acceptable (purely visual).
|
||||||
|
// PURE — no React/DOM, no mutation of the input.
|
||||||
|
export function groupAgentRuns(comments: IComment[]): CommentRenderUnit[] {
|
||||||
|
// First pass: tally groupable edits per key so we know which keys clear
|
||||||
|
// GROUP_MIN before we start emitting.
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const c of comments) {
|
||||||
|
const key = runKey(c);
|
||||||
|
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const units: CommentRenderUnit[] = [];
|
||||||
|
for (const c of comments) {
|
||||||
|
const key = runKey(c);
|
||||||
|
if (key && (counts.get(key) ?? 0) >= GROUP_MIN) {
|
||||||
|
// Emit the whole run once, at the position of its first member; skip the
|
||||||
|
// absorbed later members.
|
||||||
|
if (emitted.has(key)) continue;
|
||||||
|
emitted.add(key);
|
||||||
|
units.push({
|
||||||
|
kind: "run",
|
||||||
|
key,
|
||||||
|
comments: comments.filter((x) => runKey(x) === key),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
units.push({ kind: "single", comment: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseSeverity, isSignificant } from "./severity";
|
||||||
|
|
||||||
|
describe("parseSeverity — exact importance dictionary", () => {
|
||||||
|
it("maps the Russian importance tags", () => {
|
||||||
|
expect(parseSeverity("[Критично] fix now")).toBe("critical");
|
||||||
|
expect(parseSeverity("[Существенно] tighten")).toBe("major");
|
||||||
|
expect(parseSeverity("[Незначительно] nit")).toBe("minor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the English equivalents and is case-insensitive", () => {
|
||||||
|
expect(parseSeverity("[Critical] x")).toBe("critical");
|
||||||
|
expect(parseSeverity("[MAJOR] x")).toBe("major");
|
||||||
|
expect(parseSeverity("[minor] x")).toBe("minor");
|
||||||
|
expect(parseSeverity("[критично] x")).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats fact-checker verdicts as unknown, NOT a severity", () => {
|
||||||
|
expect(parseSeverity("[Неверно] this is wrong")).toBe("unknown");
|
||||||
|
expect(parseSeverity("[Не проверено] can't verify")).toBe("unknown");
|
||||||
|
expect(parseSeverity("[Непроверяемо] x")).toBe("unknown");
|
||||||
|
expect(parseSeverity("[Это мнение] x")).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a typo'd / unrecognised tag as unknown (never falls back to minor)", () => {
|
||||||
|
expect(parseSeverity("[Существенное] typo ending")).toBe("unknown");
|
||||||
|
expect(parseSeverity("[whatever] x")).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unknown for no tag or empty input", () => {
|
||||||
|
expect(parseSeverity("plain body, no tag")).toBe("unknown");
|
||||||
|
expect(parseSeverity("")).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the recognised importance tag even after a leading verdict tag", () => {
|
||||||
|
expect(parseSeverity("[Неверно] [Существенно] body")).toBe("major");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSignificant", () => {
|
||||||
|
it("counts only major and critical", () => {
|
||||||
|
expect(isSignificant("critical")).toBe(true);
|
||||||
|
expect(isSignificant("major")).toBe(true);
|
||||||
|
expect(isSignificant("minor")).toBe(false);
|
||||||
|
expect(isSignificant("unknown")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Severity of an agent suggested-edit, parsed CLIENT-SIDE from the importance
|
||||||
|
// tag the editorial role prompts emit at the head of the comment body
|
||||||
|
// (`[Критично]` / `[Существенно]` / `[Незначительно]`, or their English
|
||||||
|
// equivalents). It is a DISPLAY-ONLY signal: it drives the coloured dot on the
|
||||||
|
// edit card and the "M major" counter in a run header. It never gates an action.
|
||||||
|
//
|
||||||
|
// `unknown` is a first-class value, NOT a synonym for `minor`: any bracketed
|
||||||
|
// text that is not in the exact dictionary — a Fact-checker verdict
|
||||||
|
// (`[Неверно]`, `[Не проверено]`, `[Непроверяемо]`, `[Это мнение]`), a typo
|
||||||
|
// (`[Существенное]`), or the absence of a tag — resolves to `unknown` and draws
|
||||||
|
// a neutral dot. Passing an unrecognised tag off as `minor` would mis-label it.
|
||||||
|
export type Severity = "critical" | "major" | "minor" | "unknown";
|
||||||
|
|
||||||
|
// EXACT (case-insensitive) dictionary. Only these tokens map to a real severity;
|
||||||
|
// everything else falls through to `unknown` on purpose.
|
||||||
|
const SEVERITY_TAGS: Record<string, Severity> = {
|
||||||
|
критично: "critical",
|
||||||
|
существенно: "major",
|
||||||
|
незначительно: "minor",
|
||||||
|
critical: "critical",
|
||||||
|
major: "major",
|
||||||
|
minor: "minor",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse the first RECOGNISED importance tag out of the flattened comment text
|
||||||
|
// (use `commentContentToText` to obtain it). Scans every `[...]` token so a tag
|
||||||
|
// that trails a verdict still counts; the first exact-dictionary hit wins. When
|
||||||
|
// no token matches the dictionary the result is `unknown`. PURE — no React/DOM.
|
||||||
|
export function parseSeverity(text: string): Severity {
|
||||||
|
if (!text) return "unknown";
|
||||||
|
const matches = text.matchAll(/\[([^\]]+)\]/g);
|
||||||
|
for (const m of matches) {
|
||||||
|
const tag = m[1].trim().toLowerCase();
|
||||||
|
const sev = SEVERITY_TAGS[tag];
|
||||||
|
if (sev) return sev;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a severity counts toward the "M major" tally in a run header: only the
|
||||||
|
// genuinely significant ones (major + critical). `minor` and `unknown` do not.
|
||||||
|
export function isSignificant(severity: Severity): boolean {
|
||||||
|
return severity === "major" || severity === "critical";
|
||||||
|
}
|
||||||
@@ -92,13 +92,23 @@
|
|||||||
wrapper, never in the document model) and is rendered inline at the start of
|
wrapper, never in the document model) and is rendered inline at the start of
|
||||||
the first content line via ::before. This keeps text and wrapped lines flush
|
the first content line via ::before. This keeps text and wrapped lines flush
|
||||||
to the left margin — no hanging indent — while the editable contentDOM stays
|
to the left margin — no hanging indent — while the editable contentDOM stays
|
||||||
the FIRST DOM child (#146). */
|
the FIRST DOM child (#146).
|
||||||
|
|
||||||
|
The rules below target `p:first-child`, NOT `> :first-child`: tiptap-react
|
||||||
|
wraps the NodeViewContent children in an extra block-level div
|
||||||
|
(`[data-node-view-content-react]`, style="white-space: inherit"), so
|
||||||
|
`.definitionContent`'s first child is that wrapper, not the paragraph. An
|
||||||
|
inline ::before on the block wrapper (whose child is a block <p>) drops onto
|
||||||
|
its own line above the text — the "+1 line" regression. `p:first-child`
|
||||||
|
reaches the real first paragraph so the number stays inline, and it works
|
||||||
|
whether or not the wrapper is present (definition content is `paragraph+`,
|
||||||
|
so there are no nested paragraphs to mis-match). */
|
||||||
.definitionContent {
|
.definitionContent {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.definitionContent > :first-child::before {
|
.definitionContent p:first-child::before {
|
||||||
content: var(--footnote-number, "?") ". ";
|
content: var(--footnote-number, "?") ". ";
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -108,12 +118,13 @@
|
|||||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
|
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
|
||||||
Drop the outer margins so the definition sits tight to the heading above and
|
Drop the outer margins so the definition sits tight to the heading above and
|
||||||
the ::before number aligns with the top of the row — same approach used for
|
the ::before number aligns with the top of the row — same approach used for
|
||||||
callouts in core.css. */
|
callouts in core.css. Target the paragraphs directly (through the
|
||||||
.definitionContent > :first-child {
|
tiptap-react content wrapper), same reasoning as the ::before rule above. */
|
||||||
|
.definitionContent p:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.definitionContent > :last-child {
|
.definitionContent p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,130 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: rem(16px) rem(40px);
|
padding: rem(16px) rem(40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #568 — redesigned desktop history window */
|
||||||
|
|
||||||
|
.desktopRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 78vh;
|
||||||
|
max-height: rem(760px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopToolbar {
|
||||||
|
flex: none;
|
||||||
|
height: rem(56px);
|
||||||
|
padding: 0 rem(16px);
|
||||||
|
border-bottom: rem(1px) solid
|
||||||
|
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopBody {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navPanel {
|
||||||
|
flex: none;
|
||||||
|
width: rem(280px);
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: rem(1px) solid
|
||||||
|
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navFilterRow {
|
||||||
|
flex: none;
|
||||||
|
height: rem(44px);
|
||||||
|
padding: 0 rem(16px);
|
||||||
|
border-bottom: rem(1px) solid
|
||||||
|
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightView {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayHeading {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: rem(9px) rem(16px) rem(5px);
|
||||||
|
font-size: rem(10.5px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisionRow {
|
||||||
|
margin: rem(1px) rem(6px);
|
||||||
|
border-radius: rem(7px);
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisionRowActive {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-blue-0),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-blue-0),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mini-calendar heatmap */
|
||||||
|
.calDay {
|
||||||
|
position: relative;
|
||||||
|
height: rem(26px);
|
||||||
|
border-radius: rem(6px);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: rem(10.5px);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calHeat0 {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.calHeat1 {
|
||||||
|
background: light-dark(#e7f0ff, rgba(34, 139, 230, 0.2));
|
||||||
|
color: light-dark(#1971c2, #74c0fc);
|
||||||
|
}
|
||||||
|
.calHeat2 {
|
||||||
|
background: light-dark(#a5c8ff, rgba(34, 139, 230, 0.45));
|
||||||
|
color: light-dark(#0b3d91, #dbeafe);
|
||||||
|
}
|
||||||
|
.calHeat3 {
|
||||||
|
background: #4c8dff;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calDaySelected {
|
||||||
|
box-shadow: inset 0 0 0 rem(2px)
|
||||||
|
light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calDayOutside {
|
||||||
|
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,28 +16,14 @@ import { memo, useCallback } from "react";
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
// #568 — historyKindMeta moved to a pure module; re-exported here so existing
|
||||||
|
// importers (history-list, this file) keep their import path unchanged.
|
||||||
|
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
|
||||||
|
|
||||||
|
export { historyKindMeta };
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
|
|
||||||
* marks the intentional points (manual / agent); autosaves (boundary / idle /
|
|
||||||
* legacy null) are non-versions and get dimmed in the list.
|
|
||||||
*/
|
|
||||||
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
|
|
||||||
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
|
||||||
switch (kind) {
|
|
||||||
case "manual":
|
|
||||||
return { labelKey: "Saved", color: "blue", version: true };
|
|
||||||
case "agent":
|
|
||||||
return { labelKey: "Agent version", color: "violet", version: true };
|
|
||||||
case "boundary":
|
|
||||||
return { labelKey: "Boundary", color: "gray", version: false };
|
|
||||||
default: // "idle" | null | undefined (legacy autosave)
|
|
||||||
return { labelKey: "Autosave", color: "gray", version: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
ScrollArea,
|
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import HistoryList from "@/features/page-history/components/history-list";
|
|
||||||
import classes from "./css/history.module.css";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
activeHistoryIdAtom,
|
|
||||||
activeHistoryPrevIdAtom,
|
|
||||||
diffCountsAtom,
|
|
||||||
highlightChangesAtom,
|
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
|
||||||
import HistoryView from "@/features/page-history/components/history-view";
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useDiffNavigation,
|
|
||||||
useHistoryReset,
|
|
||||||
} from "@/features/page-history/hooks";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HistoryModalBody({ pageId }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
|
||||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
|
||||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
|
||||||
const diffCounts = useAtomValue(diffCountsAtom);
|
|
||||||
|
|
||||||
useHistoryReset(pageId);
|
|
||||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
|
||||||
useDiffNavigation(scrollViewportRef);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.sidebarFlex}>
|
|
||||||
<nav className={classes.sidebar}>
|
|
||||||
<div className={classes.sidebarMain}>
|
|
||||||
<HistoryList pageId={pageId} />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div style={{ position: "relative", flex: 1 }}>
|
|
||||||
<ScrollArea
|
|
||||||
h={650}
|
|
||||||
w="100%"
|
|
||||||
scrollbarSize={5}
|
|
||||||
viewportRef={scrollViewportRef}
|
|
||||||
>
|
|
||||||
<div className={classes.sidebarRightSection}>
|
|
||||||
{activeHistoryId && <HistoryView />}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{activeHistoryId && activeHistoryPrevId && (
|
|
||||||
<Paper
|
|
||||||
shadow="md"
|
|
||||||
radius="xl"
|
|
||||||
px="md"
|
|
||||||
py="xs"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 16,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="md" wrap="nowrap">
|
|
||||||
<Switch
|
|
||||||
label={t("Highlight changes")}
|
|
||||||
checked={highlightChanges}
|
|
||||||
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
|
||||||
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
|
||||||
/>
|
|
||||||
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{currentChangeIndex} of {diffCounts.total}
|
|
||||||
</Text>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePrevChange}
|
|
||||||
>
|
|
||||||
<IconChevronUp size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextChange}
|
|
||||||
>
|
|
||||||
<IconChevronDown size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
activeHistoryIdAtom,
|
||||||
|
activeHistoryPrevIdAtom,
|
||||||
|
diffCountsAtom,
|
||||||
|
highlightChangesAtom,
|
||||||
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
|
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||||
|
import { usePageHistoryDayCounts } from "@/features/page-history/queries/day-counts-query";
|
||||||
|
import HistoryView from "@/features/page-history/components/history-view";
|
||||||
|
import HistoryNavPanel from "@/features/page-history/components/history-nav-panel";
|
||||||
|
import {
|
||||||
|
useDiffNavigation,
|
||||||
|
useHistoryReset,
|
||||||
|
useHistoryRestore,
|
||||||
|
} from "@/features/page-history/hooks";
|
||||||
|
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
||||||
|
import {
|
||||||
|
dayGroupLabel,
|
||||||
|
toRevisionRow,
|
||||||
|
} from "@/features/page-history/utils/revision-row";
|
||||||
|
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
|
||||||
|
import classes from "./css/history.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — redesigned DESKTOP page-history window: a single-row header (title,
|
||||||
|
* selected-version label, Highlight-changes toggle + N/M diff navigation, Restore,
|
||||||
|
* close) over a two-pane body — LEFT navigation panel (calendar heatmap + dense
|
||||||
|
* revision list), RIGHT the rendered version with change highlighting.
|
||||||
|
*
|
||||||
|
* Mechanics are REUSED verbatim: the same list query + atoms + diff engine +
|
||||||
|
* restore hook as the original body. Only the layout and row density change.
|
||||||
|
* The mobile branch (history-modal-mobile) is untouched.
|
||||||
|
*/
|
||||||
|
export default function HistoryModalDesktop({ pageId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tz = useMemo(() => viewerTimezone(), []);
|
||||||
|
const now = useMemo(() => new Date(), []);
|
||||||
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||||
|
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||||
|
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||||
|
const diffCounts = useAtomValue(diffCountsAtom);
|
||||||
|
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||||
|
|
||||||
|
useHistoryReset(pageId);
|
||||||
|
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||||
|
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||||
|
useDiffNavigation(scrollViewportRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
} = usePageHistoryListQuery(pageId);
|
||||||
|
const historyItems = useMemo(
|
||||||
|
() => data?.pages.flatMap((page) => page.items) ?? [],
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// fail-open: heatmap counts degrade to an empty grid on error; the list and
|
||||||
|
// restore keep working.
|
||||||
|
const { data: dayCounts } = usePageHistoryDayCounts(pageId);
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const d of dayCounts ?? []) map.set(d.dayISO, d.count);
|
||||||
|
return map;
|
||||||
|
}, [dayCounts]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveHistoryId(id);
|
||||||
|
// Baseline = true previous snapshot in the FULL flat list (never the
|
||||||
|
// filtered/grouped neighbour), so diff/restore stay correct under "Only
|
||||||
|
// versions".
|
||||||
|
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
||||||
|
},
|
||||||
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyItems.length > 0 && !activeHistoryId) {
|
||||||
|
setActiveHistoryId(historyItems[0].id);
|
||||||
|
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||||
|
}
|
||||||
|
}, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]);
|
||||||
|
|
||||||
|
const selectedRow = useMemo(() => {
|
||||||
|
const item = historyItems.find((i) => i.id === activeHistoryId);
|
||||||
|
return item ? toRevisionRow(item, tz) : null;
|
||||||
|
}, [historyItems, activeHistoryId, tz]);
|
||||||
|
|
||||||
|
const selectedLabel = selectedRow
|
||||||
|
? `${dayGroupLabel(selectedRow, tz, now, t)} · ${selectedRow.atLabel}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// #568 §C — Restore disabled for the newest snapshot (index 0, globally newest
|
||||||
|
// even mid-pagination) and when the viewer lacks Manage-Page.
|
||||||
|
const isNewestSelected =
|
||||||
|
!!activeHistoryId && historyItems[0]?.id === activeHistoryId;
|
||||||
|
const restoreDisabled = !canRestore || isNewestSelected || !selectedRow;
|
||||||
|
|
||||||
|
const showDiffNav =
|
||||||
|
highlightChanges && !!diffCounts && diffCounts.total > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.desktopRoot}>
|
||||||
|
<Group className={classes.desktopToolbar} gap={14} wrap="nowrap">
|
||||||
|
<Text fz={16} fw={600} style={{ flex: "none" }}>
|
||||||
|
{t("Page history")}
|
||||||
|
</Text>
|
||||||
|
<Divider orientation="vertical" my={14} />
|
||||||
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={12} fw={600} truncate>
|
||||||
|
{selectedLabel}
|
||||||
|
</Text>
|
||||||
|
<Text fz={10.5} c="dimmed">
|
||||||
|
{t("Selected version")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={highlightChanges}
|
||||||
|
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||||
|
label={t("Highlight changes")}
|
||||||
|
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||||
|
/>
|
||||||
|
{showDiffNav && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{currentChangeIndex} {t("of")} {diffCounts.total}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("Previous change")}
|
||||||
|
onClick={handlePrevChange}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("Next change")}
|
||||||
|
onClick={handleNextChange}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" my={14} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="compact-md"
|
||||||
|
onClick={confirmRestore}
|
||||||
|
disabled={restoreDisabled}
|
||||||
|
style={{ flex: "none" }}
|
||||||
|
>
|
||||||
|
{t("Restore")}
|
||||||
|
</Button>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Close")}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div className={classes.desktopBody}>
|
||||||
|
<HistoryNavPanel
|
||||||
|
fullItems={historyItems}
|
||||||
|
activeId={activeHistoryId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
|
hasNextPage={!!hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading}
|
||||||
|
counts={counts}
|
||||||
|
selectedDayISO={selectedRow?.dayISO ?? null}
|
||||||
|
tz={tz}
|
||||||
|
onlyVersions={onlyVersions}
|
||||||
|
setOnlyVersions={setOnlyVersions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
className={classes.rightView}
|
||||||
|
viewportRef={scrollViewportRef}
|
||||||
|
scrollbarSize={5}
|
||||||
|
>
|
||||||
|
{/* F1 — the right pane mirrors the list state instead of going blank:
|
||||||
|
error on a failed history query, an empty state for a page with no
|
||||||
|
revisions, otherwise the rendered version. */}
|
||||||
|
{isError ? (
|
||||||
|
<Center h="100%" p={40}>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t("Error fetching page data.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
) : !isLoading && historyItems.length === 0 ? (
|
||||||
|
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
|
||||||
|
<Text fw={600} fz="sm">
|
||||||
|
{t("No page history saved yet.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box p="26px 0" maw={720} mx="auto">
|
||||||
|
{activeHistoryId && <HistoryView />}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Modal, Text } from "@mantine/core";
|
import { Modal, Text } from "@mantine/core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
||||||
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
import HistoryModalDesktop from "@/features/page-history/components/history-modal-desktop";
|
||||||
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
|
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
@@ -45,6 +45,9 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #568 — the redesigned desktop window carries its OWN single-row header
|
||||||
|
// (title + selected label + diff nav + Restore + close), so the Modal chrome is
|
||||||
|
// dropped for desktop and the body renders edge-to-edge.
|
||||||
return (
|
return (
|
||||||
<Modal.Root
|
<Modal.Root
|
||||||
size={1400}
|
size={1400}
|
||||||
@@ -54,16 +57,11 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
|||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header>
|
<Modal.Body p={0}>
|
||||||
<Modal.Title>
|
<HistoryModalDesktop
|
||||||
<Text size="md" fw={500}>
|
pageId={pageId}
|
||||||
{t("Page history")}
|
onClose={() => setModalOpen(false)}
|
||||||
</Text>
|
/>
|
||||||
</Modal.Title>
|
|
||||||
<Modal.CloseButton aria-label={t("Close")} />
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
<HistoryModalBody pageId={pageId} />
|
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import HistoryNavPanel from "./history-nav-panel";
|
||||||
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
|
||||||
|
|
||||||
|
// matchMedia / ResizeObserver are stubbed globally in vitest.setup.ts.
|
||||||
|
// react-i18next with no initialized instance returns the key as the label.
|
||||||
|
|
||||||
|
const TZ = "UTC";
|
||||||
|
const todayISO = isoDayInTz(new Date(), TZ);
|
||||||
|
|
||||||
|
function item(partial: Partial<IPageHistory>): IPageHistory {
|
||||||
|
return {
|
||||||
|
id: "x",
|
||||||
|
// Default to "today" so the current-month calendar has a matching cell.
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||||
|
...partial,
|
||||||
|
} as IPageHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manual = item({ id: "m1", kind: "manual", lastUpdatedSource: "user" });
|
||||||
|
const autosave = item({ id: "a1", kind: null, lastUpdatedSource: "user" });
|
||||||
|
|
||||||
|
// Controlled harness so the "Only versions" switch actually toggles the filter,
|
||||||
|
// exercising the switch → filter wiring (not just a static prop).
|
||||||
|
function Harness({
|
||||||
|
items = [manual, autosave],
|
||||||
|
counts = new Map<string, number>(),
|
||||||
|
onPick = vi.fn(),
|
||||||
|
fetchNextPage = vi.fn(),
|
||||||
|
}: {
|
||||||
|
items?: IPageHistory[];
|
||||||
|
counts?: Map<string, number>;
|
||||||
|
onPick?: () => void;
|
||||||
|
fetchNextPage?: () => Promise<any>;
|
||||||
|
}) {
|
||||||
|
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<HistoryNavPanel
|
||||||
|
fullItems={items}
|
||||||
|
activeId=""
|
||||||
|
onSelect={onPick}
|
||||||
|
fetchNextPage={fetchNextPage as any}
|
||||||
|
hasNextPage={false}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
isError={false}
|
||||||
|
isLoading={false}
|
||||||
|
counts={counts}
|
||||||
|
selectedDayISO={todayISO}
|
||||||
|
tz={TZ}
|
||||||
|
onlyVersions={onlyVersions}
|
||||||
|
setOnlyVersions={setOnlyVersions}
|
||||||
|
/>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HistoryNavPanel (#568 left nav)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// jsdom has no scrollTo on elements — provide a spyable stub.
|
||||||
|
// @ts-ignore
|
||||||
|
Element.prototype.scrollTo = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one dense row per revision and the mini-calendar", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
|
||||||
|
// 42-cell month grid is present.
|
||||||
|
expect(screen.getAllByTestId("calendar-day")).toHaveLength(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the 'Only versions' switch filters out autosaves (non-vacuous)", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
// Both the manual version and the autosave are shown initially.
|
||||||
|
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Only versions"));
|
||||||
|
|
||||||
|
// After filtering, only the manual VERSION row remains — if the filter
|
||||||
|
// predicate were dropped this assertion would fail.
|
||||||
|
const rows = screen.getAllByTestId("revision-row");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(screen.getByText("Alice")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picking a loaded day scrolls the list to that day's anchor", () => {
|
||||||
|
render(<Harness counts={new Map([[todayISO, 1]])} />);
|
||||||
|
const todayCell = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||||
|
fireEvent.click(todayCell);
|
||||||
|
// The day IS loaded (the row is on `today`), so scrollTo is invoked directly
|
||||||
|
// without any pagination fetch.
|
||||||
|
expect(Element.prototype.scrollTo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an explicit error state instead of a blank list", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<HistoryNavPanel
|
||||||
|
fullItems={[]}
|
||||||
|
activeId=""
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
fetchNextPage={vi.fn() as any}
|
||||||
|
hasNextPage={false}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
isError
|
||||||
|
isLoading={false}
|
||||||
|
counts={new Map()}
|
||||||
|
selectedDayISO={null}
|
||||||
|
tz={TZ}
|
||||||
|
onlyVersions={false}
|
||||||
|
setOnlyVersions={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Error loading page history.")).toBeDefined();
|
||||||
|
expect(screen.queryAllByTestId("revision-row")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty state (not blank) when there is no history", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<HistoryNavPanel
|
||||||
|
fullItems={[]}
|
||||||
|
activeId=""
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
fetchNextPage={vi.fn() as any}
|
||||||
|
hasNextPage={false}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
isError={false}
|
||||||
|
isLoading={false}
|
||||||
|
counts={new Map()}
|
||||||
|
selectedDayISO={null}
|
||||||
|
tz={TZ}
|
||||||
|
onlyVersions={false}
|
||||||
|
setOnlyVersions={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No page history saved yet.")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flash the empty text while the initial query is loading", () => {
|
||||||
|
// F1-residual: during the first fetch data is empty but isLoading is true —
|
||||||
|
// the empty state must stay hidden until loading settles (mirrors the right
|
||||||
|
// pane). Reverting the `!isLoading` gate makes this fail.
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<HistoryNavPanel
|
||||||
|
fullItems={[]}
|
||||||
|
activeId=""
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
fetchNextPage={vi.fn() as any}
|
||||||
|
hasNextPage={false}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
isError={false}
|
||||||
|
isLoading={true}
|
||||||
|
counts={new Map()}
|
||||||
|
selectedDayISO={null}
|
||||||
|
tz={TZ}
|
||||||
|
onlyVersions={false}
|
||||||
|
setOnlyVersions={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("No page history saved yet.")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Box, Center, Group, Loader, ScrollArea, Switch, Text } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
import {
|
||||||
|
dayGroupLabel,
|
||||||
|
groupRevisionsByDay,
|
||||||
|
isoDayInTz,
|
||||||
|
toRevisionRow,
|
||||||
|
} from "@/features/page-history/utils/revision-row";
|
||||||
|
import RevisionRow from "@/features/page-history/components/revision-row";
|
||||||
|
import MiniCalendar from "@/features/page-history/components/mini-calendar";
|
||||||
|
import classes from "./css/history.module.css";
|
||||||
|
|
||||||
|
// react-query's fetchNextPage returns the accumulated infinite result; typed
|
||||||
|
// loosely here to avoid re-importing its generics.
|
||||||
|
type FetchNextPage = () => Promise<{
|
||||||
|
data?: { pages: Array<{ items: IPageHistory[] }> };
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fullItems: IPageHistory[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onHover?: (id: string) => void;
|
||||||
|
onHoverEnd?: () => void;
|
||||||
|
fetchNextPage: FetchNextPage;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
counts: Map<string, number>;
|
||||||
|
selectedDayISO: string | null;
|
||||||
|
tz: string;
|
||||||
|
onlyVersions: boolean;
|
||||||
|
setOnlyVersions: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — left navigation panel: "Only versions" filter + mini-calendar heatmap +
|
||||||
|
* the DENSE, day-grouped revision list. The grouping is presentational only —
|
||||||
|
* selection still resolves the previous snapshot from the FULL flat list (done
|
||||||
|
* in the parent), so diff/restore never target a filtered neighbour.
|
||||||
|
*/
|
||||||
|
export default function HistoryNavPanel({
|
||||||
|
fullItems,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onHoverEnd,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
counts,
|
||||||
|
selectedDayISO,
|
||||||
|
tz,
|
||||||
|
onlyVersions,
|
||||||
|
setOnlyVersions,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const rows = fullItems
|
||||||
|
.map((item) => toRevisionRow(item, tz))
|
||||||
|
// "Only versions": same predicate as the badge (manual/agent), so the
|
||||||
|
// filter and the heatmap describe the same set.
|
||||||
|
.filter((row) => (onlyVersions ? row.version : true));
|
||||||
|
return groupRevisionsByDay(rows);
|
||||||
|
}, [fullItems, tz, onlyVersions]);
|
||||||
|
|
||||||
|
// Bottom-sentinel auto-load (mirrors history-list.tsx).
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = loadMoreRef.current;
|
||||||
|
if (!sentinel || !hasNextPage) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
const scrollToDay = useCallback((dayISO: string): boolean => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (!viewport) return false;
|
||||||
|
// ISO 'YYYY-MM-DD' is attribute-selector safe (digits + hyphens).
|
||||||
|
const el = viewport.querySelector(`[data-day="${dayISO}"]`);
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
viewport.scrollTo({ top: Math.max(el.offsetTop - 8, 0), behavior: "smooth" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePickDay = useCallback(
|
||||||
|
async (targetDayISO: string) => {
|
||||||
|
if (scrollToDay(targetDayISO)) return;
|
||||||
|
|
||||||
|
// Not in the loaded pages yet — the list is newest-first, so pull pages
|
||||||
|
// until the OLDEST loaded day is on/older than the target (a correct
|
||||||
|
// dayISO boundary, NOT a fixed page count), or pagination is exhausted.
|
||||||
|
let items = fullItems;
|
||||||
|
let more = hasNextPage;
|
||||||
|
let guard = 0;
|
||||||
|
while (more && guard < 200) {
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const oldestDay = last
|
||||||
|
? isoDayInTz(new Date(last.createdAt), tz)
|
||||||
|
: null;
|
||||||
|
if (oldestDay && oldestDay <= targetDayISO) break;
|
||||||
|
const res = await fetchNextPage();
|
||||||
|
items = res.data?.pages.flatMap((p) => p.items) ?? items;
|
||||||
|
more = res.hasNextPage ?? false;
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let React paint the newly-loaded rows before scrolling to the anchor.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollToDay(targetDayISO)) {
|
||||||
|
// Unreachable day (exhausted pagination): explicit no-op + toast, never
|
||||||
|
// a silent hang.
|
||||||
|
notifications.show({
|
||||||
|
message: t("No revisions found for that day"),
|
||||||
|
color: "gray",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[scrollToDay, fullItems, hasNextPage, fetchNextPage, tz, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = useMemo(() => new Date(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.navPanel}>
|
||||||
|
<Group className={classes.navFilterRow} justify="space-between" wrap="nowrap">
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={onlyVersions}
|
||||||
|
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
||||||
|
label={t("Only versions")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<MiniCalendar
|
||||||
|
counts={counts}
|
||||||
|
selectedDayISO={selectedDayISO}
|
||||||
|
onPickDay={handlePickDay}
|
||||||
|
tz={tz}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} scrollbarSize={5}>
|
||||||
|
{/* F1 — explicit error/empty states instead of a blank panel. The
|
||||||
|
heatmap fails open independently; the list keeps its own states.
|
||||||
|
The empty state is gated on !isLoading so it doesn't flash the
|
||||||
|
"no history" text during the initial query, mirroring the right pane. */}
|
||||||
|
{isError ? (
|
||||||
|
<Center py="md" px="sm">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t("Error loading page history.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
) : !isLoading && groups.length === 0 ? (
|
||||||
|
<Center py="md" px="sm">
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{onlyVersions
|
||||||
|
? t("No saved versions yet.")
|
||||||
|
: t("No page history saved yet.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
) : null}
|
||||||
|
{!isError &&
|
||||||
|
groups.map((group) => (
|
||||||
|
<Box key={group.dayISO}>
|
||||||
|
<Text
|
||||||
|
className={classes.dayHeading}
|
||||||
|
data-day={group.dayISO}
|
||||||
|
data-testid="day-heading"
|
||||||
|
>
|
||||||
|
{dayGroupLabel(group, tz, now, t)}
|
||||||
|
</Text>
|
||||||
|
{group.rows.map((row) => (
|
||||||
|
<RevisionRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
selected={row.id === activeId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHover={onHover}
|
||||||
|
onHoverEnd={onHoverEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import MiniCalendar from "./mini-calendar";
|
||||||
|
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
const TZ = "UTC";
|
||||||
|
|
||||||
|
function renderCal(counts: Map<string, number>, onPickDay = vi.fn()) {
|
||||||
|
const todayISO = isoDayInTz(new Date(), TZ);
|
||||||
|
return {
|
||||||
|
todayISO,
|
||||||
|
onPickDay,
|
||||||
|
...render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MiniCalendar
|
||||||
|
counts={counts}
|
||||||
|
selectedDayISO={todayISO}
|
||||||
|
onPickDay={onPickDay}
|
||||||
|
tz={TZ}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MiniCalendar (#568 heatmap)", () => {
|
||||||
|
it("renders a 6x7 grid and highlights the selected day", () => {
|
||||||
|
const { todayISO } = renderCal(new Map());
|
||||||
|
const cells = screen.getAllByTestId("calendar-day");
|
||||||
|
expect(cells).toHaveLength(42);
|
||||||
|
const today = cells.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||||
|
expect(today.className).toContain("calDaySelected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a heat class scaled to the day's revision count", () => {
|
||||||
|
const day = isoDayInTz(new Date(), TZ);
|
||||||
|
const { todayISO } = renderCal(new Map([[day, 7]]));
|
||||||
|
const today = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||||
|
// 7 revisions → top tier (calHeat3).
|
||||||
|
expect(today.className).toContain("calHeat3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picking an in-month day delegates the dayISO to onPickDay", () => {
|
||||||
|
const { todayISO, onPickDay } = renderCal(new Map());
|
||||||
|
const today = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||||
|
fireEvent.click(today);
|
||||||
|
expect(onPickDay).toHaveBeenCalledWith(todayISO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter/Space activates a focusable day cell (F3 keyboard)", () => {
|
||||||
|
const { todayISO, onPickDay } = renderCal(new Map());
|
||||||
|
const today = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||||
|
// In-month cells are focusable buttons.
|
||||||
|
expect(today.getAttribute("role")).toBe("button");
|
||||||
|
expect(today.getAttribute("tabindex")).toBe("0");
|
||||||
|
fireEvent.keyDown(today, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(today, { key: " " });
|
||||||
|
expect(onPickDay).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("day cells carry a non-color aria/title cue for the count (F3)", () => {
|
||||||
|
const day = isoDayInTz(new Date(), TZ);
|
||||||
|
renderCal(new Map([[day, 3]]));
|
||||||
|
const today = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === day)!;
|
||||||
|
// A non-color cue is wired (the interpolated count is filled by the real
|
||||||
|
// i18next instance in-app; the test's key-fallback t returns the template).
|
||||||
|
const label = today.getAttribute("aria-label") ?? "";
|
||||||
|
expect(label).toContain("versions");
|
||||||
|
// The same text is mirrored to the native tooltip.
|
||||||
|
expect(today.getAttribute("title")).toBe(label);
|
||||||
|
// Outside-month cells are inert (no aria-label / not focusable).
|
||||||
|
const outside = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("role") !== "button");
|
||||||
|
expect(outside?.getAttribute("aria-label")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps count tiers to heat classes at the prototype boundaries", () => {
|
||||||
|
const cases: Array<[number, string]> = [
|
||||||
|
[0, "calHeat0"],
|
||||||
|
[2, "calHeat1"],
|
||||||
|
[3, "calHeat2"],
|
||||||
|
[4, "calHeat2"],
|
||||||
|
[5, "calHeat3"],
|
||||||
|
];
|
||||||
|
for (const [count, cls] of cases) {
|
||||||
|
const day = isoDayInTz(new Date(), TZ);
|
||||||
|
const { unmount } = renderCal(
|
||||||
|
count > 0 ? new Map([[day, count]]) : new Map(),
|
||||||
|
);
|
||||||
|
const today = screen
|
||||||
|
.getAllByTestId("calendar-day")
|
||||||
|
.find((c) => c.getAttribute("data-day") === day)!;
|
||||||
|
expect(today.className).toContain(cls);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { ActionIcon, Box, Button, Group, Text } from "@mantine/core";
|
||||||
|
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
heatLevel,
|
||||||
|
isoDayInTz,
|
||||||
|
} from "@/features/page-history/utils/revision-row";
|
||||||
|
import classes from "./css/history.module.css";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface CalendarCell {
|
||||||
|
date: Date;
|
||||||
|
dayISO: string;
|
||||||
|
label: string;
|
||||||
|
inMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a Monday-first 6×7 grid for `viewYear`/`viewMonth` (0-based month).
|
||||||
|
* Cells are dated at local noon so `isoDayInTz` never straddles a DST midnight. */
|
||||||
|
function buildGrid(
|
||||||
|
viewYear: number,
|
||||||
|
viewMonth: number,
|
||||||
|
tz: string,
|
||||||
|
): CalendarCell[] {
|
||||||
|
const first = new Date(viewYear, viewMonth, 1, 12);
|
||||||
|
// JS getDay(): 0=Sun..6=Sat → shift to Monday-first offset.
|
||||||
|
const offset = (first.getDay() + 6) % 7;
|
||||||
|
const cells: CalendarCell[] = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const date = new Date(viewYear, viewMonth, 1 - offset + i, 12);
|
||||||
|
cells.push({
|
||||||
|
date,
|
||||||
|
dayISO: isoDayInTz(date, tz),
|
||||||
|
label: String(date.getDate()),
|
||||||
|
inMonth: date.getMonth() === viewMonth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** dayISO → version-revision count (heatmap intensity). */
|
||||||
|
counts: Map<string, number>;
|
||||||
|
selectedDayISO: string | null;
|
||||||
|
onPickDay: (dayISO: string) => void;
|
||||||
|
tz: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — mini-calendar heatmap + date-jumper. Cell intensity = revisions that
|
||||||
|
* day (`counts`); month navigation is purely client-side (whole history already
|
||||||
|
* loaded). Picking a day delegates to `onPickDay(dayISO)` which scrolls/loads the
|
||||||
|
* dense list to that day. Empty `counts` (fail-open) simply renders a blank grid.
|
||||||
|
*/
|
||||||
|
export default function MiniCalendar({
|
||||||
|
counts,
|
||||||
|
selectedDayISO,
|
||||||
|
onPickDay,
|
||||||
|
tz,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const now = new Date();
|
||||||
|
const [view, setView] = useState({
|
||||||
|
year: now.getFullYear(),
|
||||||
|
month: now.getMonth(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cells = useMemo(
|
||||||
|
() => buildGrid(view.year, view.month, tz),
|
||||||
|
[view.year, view.month, tz],
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(view.year, view.month, 1)),
|
||||||
|
[view.year, view.month],
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekdays = useMemo(() => {
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||||
|
// 2024-01-01 is a Monday — build a Monday-first header.
|
||||||
|
return Array.from({ length: 7 }, (_, i) =>
|
||||||
|
fmt.format(new Date(2024, 0, 1 + i)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const step = (delta: number) =>
|
||||||
|
setView((v) => {
|
||||||
|
const m = v.month + delta;
|
||||||
|
return {
|
||||||
|
year: v.year + Math.floor(m / 12),
|
||||||
|
month: ((m % 12) + 12) % 12,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="10px 12px 8px" className={classes.navFilterRow} style={{ height: "auto" }}>
|
||||||
|
<Group gap={6} mb={6} wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("Previous month")}
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Text fz={12} fw={600} style={{ flex: 1, textAlign: "center" }}>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("Next month")}
|
||||||
|
onClick={() => step(1)}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setView({ year: now.getFullYear(), month: now.getMonth() })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Today")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
|
||||||
|
{weekdays.map((w, i) => (
|
||||||
|
<Text key={i} ta="center" fz={9} fw={600} c="dimmed">
|
||||||
|
{w}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(7,1fr)",
|
||||||
|
gap: 2,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map((cell) => {
|
||||||
|
const count = counts.get(cell.dayISO) ?? 0;
|
||||||
|
const level = cell.inMonth ? heatLevel(count) : 0;
|
||||||
|
const selected = cell.inMonth && cell.dayISO === selectedDayISO;
|
||||||
|
// Non-color cue (F3 a11y): the count is announced, not conveyed by the
|
||||||
|
// heat color alone — e.g. "12 Jul: 3 versions".
|
||||||
|
const dayLabel = new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
}).format(cell.date);
|
||||||
|
const ariaLabel = t("{{date}}: {{count}} versions", {
|
||||||
|
date: dayLabel,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={cell.dayISO}
|
||||||
|
data-testid="calendar-day"
|
||||||
|
data-day={cell.dayISO}
|
||||||
|
// Only in-month cells are interactive → focusable buttons with a
|
||||||
|
// title/aria-label and Enter/Space activation; outside cells inert.
|
||||||
|
role={cell.inMonth ? "button" : undefined}
|
||||||
|
tabIndex={cell.inMonth ? 0 : undefined}
|
||||||
|
aria-label={cell.inMonth ? ariaLabel : undefined}
|
||||||
|
aria-pressed={cell.inMonth ? selected : undefined}
|
||||||
|
title={cell.inMonth ? ariaLabel : undefined}
|
||||||
|
onClick={() => cell.inMonth && onPickDay(cell.dayISO)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (cell.inMonth && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
onPickDay(cell.dayISO);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
classes.calDay,
|
||||||
|
classes[`calHeat${level}` as keyof typeof classes],
|
||||||
|
{
|
||||||
|
[classes.calDaySelected]: selected,
|
||||||
|
[classes.calDayOutside]: !cell.inMonth,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import RevisionRow from "./revision-row";
|
||||||
|
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
// react-i18next with no initialized instance returns the key as the label, so
|
||||||
|
// t("Saved") renders "Saved" and t("via") renders "via".
|
||||||
|
|
||||||
|
function renderRow(row: RevisionRowData, onSelect = vi.fn()) {
|
||||||
|
return {
|
||||||
|
onSelect,
|
||||||
|
...render(
|
||||||
|
<MantineProvider>
|
||||||
|
<RevisionRow row={row} selected={false} onSelect={onSelect} />
|
||||||
|
</MantineProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: RevisionRowData = {
|
||||||
|
id: "1",
|
||||||
|
ts: 0,
|
||||||
|
atLabel: "5:35 AM",
|
||||||
|
dayISO: "2026-07-12",
|
||||||
|
isAgent: false,
|
||||||
|
authorName: "Alice",
|
||||||
|
authorAvatarUrl: "",
|
||||||
|
saved: false,
|
||||||
|
version: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RevisionRow (#568 dense row)", () => {
|
||||||
|
it("human manual version: avatar + name + time + SAVED badge", () => {
|
||||||
|
renderRow({ ...base, saved: true });
|
||||||
|
expect(screen.getByText("5:35 AM")).toBeDefined();
|
||||||
|
expect(screen.getByText("Alice")).toBeDefined();
|
||||||
|
// SAVED badge only when saved (kind === manual).
|
||||||
|
expect(screen.getByText("Saved")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("revision-agent-glyph")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autosave (not a version): dimmed, NO SAVED badge", () => {
|
||||||
|
const { container } = renderRow({ ...base, saved: false, version: false });
|
||||||
|
expect(screen.queryByText("Saved")).toBeNull();
|
||||||
|
const rowEl = container.querySelector(
|
||||||
|
'[data-testid="revision-row"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(rowEl.style.opacity).toBe("0.55");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent version: square role glyph + 'via <launcher>', NO SAVED badge", () => {
|
||||||
|
renderRow({
|
||||||
|
...base,
|
||||||
|
isAgent: true,
|
||||||
|
agentName: "Corrector",
|
||||||
|
agentEmoji: "🛠",
|
||||||
|
launcherName: "Bob",
|
||||||
|
saved: false,
|
||||||
|
});
|
||||||
|
// Square role glyph carries the emoji.
|
||||||
|
const glyph = screen.getByTestId("revision-agent-glyph");
|
||||||
|
expect(glyph.textContent).toBe("🛠");
|
||||||
|
expect(screen.getByText("Corrector")).toBeDefined();
|
||||||
|
// "· via Bob" provenance.
|
||||||
|
expect(screen.getByText(/via/)).toBeDefined();
|
||||||
|
expect(screen.getByText(/Bob/)).toBeDefined();
|
||||||
|
// Agent versions are marked by the glyph, not a duplicate SAVED badge.
|
||||||
|
expect(screen.queryByText("Saved")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the row selects it by id", () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { container } = renderRow(base, onSelect);
|
||||||
|
fireEvent.click(
|
||||||
|
container.querySelector('[data-testid="revision-row"]') as HTMLElement,
|
||||||
|
);
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a data-day anchor for jump-to-day scrolling", () => {
|
||||||
|
const { container } = renderRow(base);
|
||||||
|
const rowEl = container.querySelector('[data-testid="revision-row"]');
|
||||||
|
expect(rowEl?.getAttribute("data-day")).toBe("2026-07-12");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Badge, Box, Group, Text } from "@mantine/core";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
|
||||||
|
import classes from "./css/history.module.css";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: RevisionRowData;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onHover?: (id: string) => void;
|
||||||
|
onHoverEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — one revision = one dense row: `glyph · time · author · [SAVED]`.
|
||||||
|
* - Agent identity (`row.isAgent`) → a SQUARE role glyph (emoji or initial) +
|
||||||
|
* "via <launcher>"; a human → the round CustomAvatar. (Multi-contributor
|
||||||
|
* stacks are deliberately dropped in the dense list — one glyph per row.)
|
||||||
|
* - SAVED badge only when `row.saved` (kind === 'manual'); agent versions rely
|
||||||
|
* on the glyph, autosaves are dimmed (`!row.version`), neither gets a badge.
|
||||||
|
*/
|
||||||
|
const RevisionRow = memo(function RevisionRow({
|
||||||
|
row,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
onHoverEnd,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
h={30}
|
||||||
|
px={12}
|
||||||
|
data-testid="revision-row"
|
||||||
|
data-day={row.dayISO}
|
||||||
|
// Keyboard-accessible (F3 a11y): a focusable button-role row activated by
|
||||||
|
// Enter/Space, not just a mouse onClick.
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onSelect(row.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(row.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onHover?.(row.id)}
|
||||||
|
onMouseLeave={onHoverEnd}
|
||||||
|
className={clsx(classes.revisionRow, {
|
||||||
|
[classes.revisionRowActive]: selected,
|
||||||
|
})}
|
||||||
|
// #370 — dim autosnapshots so intentional versions stand out.
|
||||||
|
style={{ opacity: row.version ? 1 : 0.55, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{row.isAgent ? (
|
||||||
|
<Box
|
||||||
|
data-testid="revision-agent-glyph"
|
||||||
|
style={{
|
||||||
|
flex: "none",
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
background: "var(--mantine-color-violet-light)",
|
||||||
|
color: "var(--mantine-color-violet-filled)",
|
||||||
|
}}
|
||||||
|
aria-label={row.agentName}
|
||||||
|
>
|
||||||
|
{row.agentEmoji ?? row.agentName?.[0]?.toUpperCase() ?? "A"}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CustomAvatar
|
||||||
|
size={18}
|
||||||
|
avatarUrl={row.authorAvatarUrl}
|
||||||
|
name={row.authorName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
fz={12.5}
|
||||||
|
c="dimmed"
|
||||||
|
style={{ flex: "none", minWidth: 62 }}
|
||||||
|
data-testid="revision-time"
|
||||||
|
>
|
||||||
|
{row.atLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fz={12} fw={row.isAgent ? 600 : 400} truncate>
|
||||||
|
{row.isAgent ? row.agentName : row.authorName}
|
||||||
|
</Text>
|
||||||
|
{row.isAgent && row.launcherName && (
|
||||||
|
<Text fz={11} c="dimmed" truncate>
|
||||||
|
· {t("via")} {row.launcherName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{row.saved && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
tt="uppercase"
|
||||||
|
>
|
||||||
|
{t("Saved")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RevisionRow;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getPageHistoryDayCounts,
|
||||||
|
IPageHistoryDayCount,
|
||||||
|
} from "@/features/page-history/services/day-counts-service";
|
||||||
|
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
|
||||||
|
|
||||||
|
const DAY_COUNTS_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — revisions-per-day aggregate for the mini-calendar heatmap. The whole
|
||||||
|
* history is fetched in one request (no `month` param) so month navigation is
|
||||||
|
* purely client-side. `tz` is the SAME viewer zone the dense list groups days
|
||||||
|
* in, so a heatmap cell and its list rows can never drift onto different days.
|
||||||
|
*
|
||||||
|
* fail-open: the caller renders an empty grid on error and the list/restore keep
|
||||||
|
* working (the heatmap is navigational sugar, never a gate).
|
||||||
|
*/
|
||||||
|
export function usePageHistoryDayCounts(
|
||||||
|
pageId: string,
|
||||||
|
): UseQueryResult<IPageHistoryDayCount[], Error> {
|
||||||
|
const tz = viewerTimezone();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-history-day-counts", pageId, tz],
|
||||||
|
queryFn: () => getPageHistoryDayCounts(pageId, tz),
|
||||||
|
enabled: !!pageId,
|
||||||
|
staleTime: DAY_COUNTS_STALE_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
/** #568 — one calendar day of the page-history heatmap: the number of *version*
|
||||||
|
* revisions (manual/agent) on that day, keyed by 'YYYY-MM-DD' in the viewer tz.
|
||||||
|
* Same set as the "Only versions" filter, so a lit day always has a list row. */
|
||||||
|
export interface IPageHistoryDayCount {
|
||||||
|
dayISO: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageHistoryDayCounts(
|
||||||
|
pageId: string,
|
||||||
|
tz: string,
|
||||||
|
): Promise<IPageHistoryDayCount[]> {
|
||||||
|
const req = await api.post<IPageHistoryDayCount[]>(
|
||||||
|
"/pages/history/day-counts",
|
||||||
|
{ pageId, tz },
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* #370 — map a snapshot's intentionality tier to its badge metadata. `version:
|
||||||
|
* true` marks the intentional points (manual / agent); autosaves (boundary /
|
||||||
|
* idle / legacy null) are non-versions and get dimmed in the list.
|
||||||
|
*
|
||||||
|
* Extracted from history-item.tsx (#568) into a pure, UI-free module so the dense
|
||||||
|
* revision-row adapter and the "Only versions" filter reuse the EXACT same
|
||||||
|
* predicate as the badge — they can never drift when a new kind is added.
|
||||||
|
*/
|
||||||
|
export type HistoryKindMeta = {
|
||||||
|
labelKey: string;
|
||||||
|
color: string;
|
||||||
|
version: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
||||||
|
switch (kind) {
|
||||||
|
case "manual":
|
||||||
|
return { labelKey: "Saved", color: "blue", version: true };
|
||||||
|
case "agent":
|
||||||
|
return { labelKey: "Agent version", color: "violet", version: true };
|
||||||
|
case "boundary":
|
||||||
|
return { labelKey: "Boundary", color: "gray", version: false };
|
||||||
|
default: // "idle" | null | undefined (legacy autosave)
|
||||||
|
return { labelKey: "Autosave", color: "gray", version: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
dayGroupLabel,
|
||||||
|
groupRevisionsByDay,
|
||||||
|
heatLevel,
|
||||||
|
isoDayInTz,
|
||||||
|
toRevisionRow,
|
||||||
|
RevisionRowData,
|
||||||
|
} from "./revision-row";
|
||||||
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
|
||||||
|
function hist(partial: Partial<IPageHistory>): IPageHistory {
|
||||||
|
return {
|
||||||
|
id: "id",
|
||||||
|
createdAt: "2026-07-12T05:35:00Z",
|
||||||
|
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||||
|
...partial,
|
||||||
|
} as IPageHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("toRevisionRow — kind → badge/glyph mapping (#568/#370)", () => {
|
||||||
|
it("manual → SAVED badge (saved) and a version, human glyph", () => {
|
||||||
|
const row = toRevisionRow(
|
||||||
|
hist({ kind: "manual", lastUpdatedSource: "user" }),
|
||||||
|
"UTC",
|
||||||
|
);
|
||||||
|
expect(row.saved).toBe(true);
|
||||||
|
expect(row.version).toBe(true);
|
||||||
|
expect(row.isAgent).toBe(false);
|
||||||
|
expect(row.authorName).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent → NO saved badge but IS a version, agent glyph + launcher (via)", () => {
|
||||||
|
const row = toRevisionRow(
|
||||||
|
hist({
|
||||||
|
kind: "agent",
|
||||||
|
lastUpdatedSource: "agent",
|
||||||
|
agent: { name: "Corrector", emoji: "🛠" },
|
||||||
|
launcher: { name: "Bob" },
|
||||||
|
}),
|
||||||
|
"UTC",
|
||||||
|
);
|
||||||
|
// SAVED is only for manual — the agent version relies on its glyph.
|
||||||
|
expect(row.saved).toBe(false);
|
||||||
|
expect(row.version).toBe(true);
|
||||||
|
expect(row.isAgent).toBe(true);
|
||||||
|
expect(row.agentName).toBe("Corrector");
|
||||||
|
expect(row.agentEmoji).toBe("🛠");
|
||||||
|
expect(row.launcherName).toBe("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent AUTOSAVE keeps agent identity (glyph) but is not a version, no badge", () => {
|
||||||
|
// Two orthogonal signals: identity=agent, intentionality=idle (autosave).
|
||||||
|
const row = toRevisionRow(
|
||||||
|
hist({
|
||||||
|
kind: "idle",
|
||||||
|
lastUpdatedSource: "agent",
|
||||||
|
agent: { name: "Factchecker", emoji: null },
|
||||||
|
launcher: { name: "Bob" },
|
||||||
|
}),
|
||||||
|
"UTC",
|
||||||
|
);
|
||||||
|
expect(row.isAgent).toBe(true); // identity preserved
|
||||||
|
expect(row.saved).toBe(false);
|
||||||
|
expect(row.version).toBe(false); // dimmed autosave
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autosave (null kind, human) → not a version, dimmed, no badge, human glyph", () => {
|
||||||
|
const row = toRevisionRow(
|
||||||
|
hist({ kind: null, lastUpdatedSource: "user" }),
|
||||||
|
"UTC",
|
||||||
|
);
|
||||||
|
expect(row.saved).toBe(false);
|
||||||
|
expect(row.version).toBe(false);
|
||||||
|
expect(row.isAgent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("source=agent WITHOUT resolved agent → human glyph (no square glyph)", () => {
|
||||||
|
const row = toRevisionRow(
|
||||||
|
hist({ kind: "manual", lastUpdatedSource: "agent", agent: null }),
|
||||||
|
"UTC",
|
||||||
|
);
|
||||||
|
expect(row.isAgent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isoDayInTz — stable per-row day key in a tz", () => {
|
||||||
|
it("formats YYYY-MM-DD and shifts with the tz", () => {
|
||||||
|
// 02:00 UTC is the previous calendar day in America/New_York (22:00).
|
||||||
|
const d = new Date("2026-07-04T02:00:00Z");
|
||||||
|
expect(isoDayInTz(d, "UTC")).toBe("2026-07-04");
|
||||||
|
expect(isoDayInTz(d, "America/New_York")).toBe("2026-07-03");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupRevisionsByDay — contiguous day buckets, order preserved", () => {
|
||||||
|
it("buckets newest-first rows into day groups", () => {
|
||||||
|
const rows: RevisionRowData[] = [
|
||||||
|
{ dayISO: "2026-07-12", ts: 3 } as RevisionRowData,
|
||||||
|
{ dayISO: "2026-07-12", ts: 2 } as RevisionRowData,
|
||||||
|
{ dayISO: "2026-07-11", ts: 1 } as RevisionRowData,
|
||||||
|
];
|
||||||
|
const groups = groupRevisionsByDay(rows);
|
||||||
|
expect(groups.map((g) => g.dayISO)).toEqual(["2026-07-12", "2026-07-11"]);
|
||||||
|
expect(groups[0].rows).toHaveLength(2);
|
||||||
|
expect(groups[1].rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dayGroupLabel — relative Today/Yesterday else absolute", () => {
|
||||||
|
const now = new Date("2026-07-12T12:00:00Z");
|
||||||
|
it("Today / Yesterday are relative and i18n-able", () => {
|
||||||
|
expect(dayGroupLabel({ dayISO: "2026-07-12", ts: 0 }, "UTC", now)).toBe(
|
||||||
|
"Today",
|
||||||
|
);
|
||||||
|
expect(dayGroupLabel({ dayISO: "2026-07-11", ts: 0 }, "UTC", now)).toBe(
|
||||||
|
"Yesterday",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("older days fall back to an absolute label (not Today/Yesterday)", () => {
|
||||||
|
const label = dayGroupLabel(
|
||||||
|
{ dayISO: "2026-07-04", ts: Date.UTC(2026, 6, 4, 12) },
|
||||||
|
"UTC",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(label).not.toBe("Today");
|
||||||
|
expect(label).not.toBe("Yesterday");
|
||||||
|
expect(label).toMatch(/4/); // day number present
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("heatLevel — prototype heat() thresholds", () => {
|
||||||
|
it("maps counts to 0/1/2/3 tiers", () => {
|
||||||
|
expect(heatLevel(0)).toBe(0);
|
||||||
|
expect(heatLevel(1)).toBe(1);
|
||||||
|
expect(heatLevel(2)).toBe(1);
|
||||||
|
expect(heatLevel(3)).toBe(2);
|
||||||
|
expect(heatLevel(4)).toBe(2);
|
||||||
|
expect(heatLevel(5)).toBe(3);
|
||||||
|
expect(heatLevel(99)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
|
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #568 — pure `IPageHistory → dense-row` adapter for the redesigned page-history
|
||||||
|
* panel. No React / Mantine here so it is unit-tested without mounting.
|
||||||
|
*
|
||||||
|
* TWO orthogonal signals are honored (per #370/#300):
|
||||||
|
* - GLYPH is by AUTHOR IDENTITY: `lastUpdatedSource === 'agent'` with an
|
||||||
|
* `agent` present → a square role glyph + "via <launcher>"; otherwise the
|
||||||
|
* human's round avatar. (An agent AUTOSAVE keeps its agent identity.)
|
||||||
|
* - BADGE is by INTENTIONALITY, simplified to a single SAVED badge: only
|
||||||
|
* `kind === 'manual'`. Agent versions are distinguished by the glyph (no
|
||||||
|
* duplicate badge); autosaves are dimmed (`version === false`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD' for `date` in `tz` — the stable per-row key used for day
|
||||||
|
* grouping, jump-to-day and matching heatmap cells. Matches the server's
|
||||||
|
* `isoDay` format (en-CA yields ISO order), so both sides agree. */
|
||||||
|
export function isoDayInTz(date: Date, tz: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localized clock label for `date` in `tz` (e.g. "5:35 AM"). */
|
||||||
|
export function timeLabelInTz(date: Date, tz: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionRowData {
|
||||||
|
id: string;
|
||||||
|
/** epoch-ms, kept for jump-to-day boundary math. */
|
||||||
|
ts: number;
|
||||||
|
atLabel: string;
|
||||||
|
dayISO: string;
|
||||||
|
/** author-identity glyph: true → square agent role glyph + "via launcher". */
|
||||||
|
isAgent: boolean;
|
||||||
|
agentName?: string;
|
||||||
|
agentEmoji?: string | null;
|
||||||
|
launcherName?: string | null;
|
||||||
|
authorName?: string;
|
||||||
|
authorAvatarUrl?: string;
|
||||||
|
/** intentionality badge: SAVED shown only when true (kind === 'manual'). */
|
||||||
|
saved: boolean;
|
||||||
|
/** historyKindMeta().version — non-versions (autosaves) are dimmed. */
|
||||||
|
version: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRevisionRow(item: IPageHistory, tz: string): RevisionRowData {
|
||||||
|
const date = new Date(item.createdAt);
|
||||||
|
const isAgent = item.lastUpdatedSource === "agent" && !!item.agent;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
ts: date.getTime(),
|
||||||
|
atLabel: timeLabelInTz(date, tz),
|
||||||
|
dayISO: isoDayInTz(date, tz),
|
||||||
|
isAgent,
|
||||||
|
agentName: item.agent?.name,
|
||||||
|
agentEmoji: item.agent?.emoji,
|
||||||
|
launcherName: item.launcher?.name ?? null,
|
||||||
|
authorName: item.lastUpdatedBy?.name,
|
||||||
|
authorAvatarUrl: item.lastUpdatedBy?.avatarUrl,
|
||||||
|
saved: item.kind === "manual",
|
||||||
|
version: historyKindMeta(item.kind).version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionDayGroup {
|
||||||
|
dayISO: string;
|
||||||
|
/** Representative epoch-ms of the group (first row) for label formatting. */
|
||||||
|
ts: number;
|
||||||
|
rows: RevisionRowData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group already-ordered (newest-first) rows into contiguous day buckets. The
|
||||||
|
* grouping is PURELY presentational — diff/restore still resolve the previous
|
||||||
|
* snapshot from the FULL flat list, never from a group. Rows for one day always
|
||||||
|
* arrive contiguous because the list is time-ordered, so a single pass suffices.
|
||||||
|
*/
|
||||||
|
export function groupRevisionsByDay(
|
||||||
|
rows: RevisionRowData[],
|
||||||
|
): RevisionDayGroup[] {
|
||||||
|
const groups: RevisionDayGroup[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && last.dayISO === row.dayISO) {
|
||||||
|
last.rows.push(row);
|
||||||
|
} else {
|
||||||
|
groups.push({ dayISO: row.dayISO, ts: row.ts, rows: [row] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative day-group heading: "Today" / "Yesterday" (i18n via `t`) else an
|
||||||
|
* absolute "Mon 12 Jul" (locale-formatted, no year to stay compact). `now` and
|
||||||
|
* `tz` are injected so the function stays pure/testable.
|
||||||
|
*/
|
||||||
|
export function dayGroupLabel(
|
||||||
|
group: Pick<RevisionDayGroup, "dayISO" | "ts">,
|
||||||
|
tz: string,
|
||||||
|
now: Date,
|
||||||
|
t: (key: string) => string = (k) => k,
|
||||||
|
): string {
|
||||||
|
const todayISO = isoDayInTz(now, tz);
|
||||||
|
const yesterdayISO = isoDayInTz(
|
||||||
|
new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
tz,
|
||||||
|
);
|
||||||
|
if (group.dayISO === todayISO) return t("Today");
|
||||||
|
if (group.dayISO === yesterdayISO) return t("Yesterday");
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
}).format(new Date(group.ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** heatmap intensity tier for a day's revision count (thresholds from the
|
||||||
|
* prototype's `heat()`): 0 none, 1 few (≤2), 2 some (≤4), 3 many. */
|
||||||
|
export function heatLevel(count: number): 0 | 1 | 2 | 3 {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
if (count <= 2) return 1;
|
||||||
|
if (count <= 4) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatHeadline,
|
||||||
|
formatDayTotal,
|
||||||
|
formatGapMinutes,
|
||||||
|
} from "./format-work-time";
|
||||||
|
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||||
|
// assert the rounding + branch selection without depending on the i18n catalogue.
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||||
|
|
||||||
|
describe("formatHeadline", () => {
|
||||||
|
it("prefixes ≈ and rounds to a 5-minute step", () => {
|
||||||
|
expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m");
|
||||||
|
expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows hours only / minutes only cleanly", () => {
|
||||||
|
expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h");
|
||||||
|
expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors a tiny non-zero estimate to 5m, never 0", () => {
|
||||||
|
expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty string for zero (widget hidden)", () => {
|
||||||
|
expect(formatHeadline(0, t)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDayTotal", () => {
|
||||||
|
it('renders "h m" and shows — for empty days', () => {
|
||||||
|
expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m");
|
||||||
|
expect(formatDayTotal(0, t)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatGapMinutes", () => {
|
||||||
|
it("converts the tGap ms threshold to whole minutes", () => {
|
||||||
|
expect(formatGapMinutes(15 * MIN)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// #395 — display formatting for the work-time estimate. Pure functions that take
|
||||||
|
// a translator so ru-RU / en-US wording lives in the i18n catalogue and the
|
||||||
|
// rounding logic stays unit-testable.
|
||||||
|
|
||||||
|
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
|
||||||
|
function hm(totalMinutes: number): { hours: number; minutes: number } {
|
||||||
|
return {
|
||||||
|
hours: Math.floor(totalMinutes / 60),
|
||||||
|
minutes: totalMinutes % 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and
|
||||||
|
* prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than
|
||||||
|
* rounding down to "0" (which would read as "no work"). Zero → empty string
|
||||||
|
* (the caller hides the widget).
|
||||||
|
*/
|
||||||
|
export function formatHeadline(workMs: number, t: Translate): string {
|
||||||
|
if (workMs <= 0) return "";
|
||||||
|
let minutes = Math.round(workMs / MIN / 5) * 5;
|
||||||
|
if (minutes === 0) minutes = 5;
|
||||||
|
const { hours, minutes: m } = hm(minutes);
|
||||||
|
if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||||
|
if (hours > 0) return t("≈ {{hours}}h", { hours });
|
||||||
|
return t("≈ {{minutes}}m", { minutes: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */
|
||||||
|
export function formatDayTotal(activeMs: number, t: Translate): string {
|
||||||
|
if (activeMs <= 0) return "—";
|
||||||
|
const minutes = Math.max(1, Math.round(activeMs / MIN));
|
||||||
|
const { hours, minutes: m } = hm(minutes);
|
||||||
|
if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m });
|
||||||
|
if (hours > 0) return t("{{hours}}h", { hours });
|
||||||
|
return t("{{minutes}}m", { minutes: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inactivity threshold, for the "estimate · gap = N min" caption. */
|
||||||
|
export function formatGapMinutes(tGapMs: number): number {
|
||||||
|
return Math.round(tGapMs / MIN);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { IPageWorkTime } from "./work-time.types";
|
||||||
|
import { getPageWorkTime, viewerTimezone } from "./work-time-service";
|
||||||
|
|
||||||
|
const WORK_TIME_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #395 — the "time worked on this article" estimate + per-day punch-card
|
||||||
|
* buckets. The buckets are computed server-side in the viewer's timezone (so a
|
||||||
|
* midnight-crossing session lands on the right calendar day for the reader).
|
||||||
|
* `enabled` is opt-in so the (cheap but non-trivial) projection query only fires
|
||||||
|
* when the number is actually shown.
|
||||||
|
*/
|
||||||
|
export function usePageWorkTime(
|
||||||
|
pageId: string,
|
||||||
|
enabled = true,
|
||||||
|
): UseQueryResult<IPageWorkTime, Error> {
|
||||||
|
const tz = viewerTimezone();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-work-time", pageId, tz],
|
||||||
|
queryFn: () => getPageWorkTime(pageId, tz),
|
||||||
|
enabled: enabled && !!pageId,
|
||||||
|
staleTime: WORK_TIME_STALE_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildRows,
|
||||||
|
formatBlockTooltip,
|
||||||
|
summaryLabels,
|
||||||
|
toBlocks,
|
||||||
|
toTimelineDay,
|
||||||
|
EMPTY_RUN_COLLAPSE,
|
||||||
|
} from "./work-time-adapter";
|
||||||
|
import { IPageWorkTime, IPerDay } from "./work-time.types";
|
||||||
|
|
||||||
|
const MIN = 60 * 1000;
|
||||||
|
const HOUR = 60 * MIN;
|
||||||
|
const DAY_MS = 24 * HOUR;
|
||||||
|
|
||||||
|
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||||
|
// assert the mapping/branching without depending on the i18n catalogue.
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||||
|
|
||||||
|
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
|
||||||
|
// only lay out fractions of the day the server already bucketed).
|
||||||
|
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
|
||||||
|
|
||||||
|
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
|
||||||
|
return {
|
||||||
|
dayISO: new Date(over.day).toISOString().slice(0, 10),
|
||||||
|
activeMs: 0,
|
||||||
|
agentMs: 0,
|
||||||
|
windows: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: IPageWorkTime["config"] = {
|
||||||
|
tGap: 15 * MIN,
|
||||||
|
agentTGap: 15 * MIN,
|
||||||
|
pIn: 0,
|
||||||
|
pOut: 0,
|
||||||
|
pSingle: 30 * 1000,
|
||||||
|
excludeGit: false,
|
||||||
|
dedupRoundMs: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
|
||||||
|
return {
|
||||||
|
workMs: 0,
|
||||||
|
agentOnlyMs: 0,
|
||||||
|
perDay: [],
|
||||||
|
config,
|
||||||
|
tz: "UTC",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("toBlocks", () => {
|
||||||
|
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
|
||||||
|
const d = day({
|
||||||
|
day: DAY0,
|
||||||
|
activeMs: 90 * MIN,
|
||||||
|
windows: [
|
||||||
|
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
|
||||||
|
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const blocks = toBlocks(d);
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
|
||||||
|
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
|
||||||
|
// real epoch preserved for the DST-safe tooltip
|
||||||
|
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps out-of-day fractions to [0,24]", () => {
|
||||||
|
const d = day({
|
||||||
|
day: DAY0,
|
||||||
|
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
|
||||||
|
});
|
||||||
|
const [b] = toBlocks(d);
|
||||||
|
expect(b.start).toBe(0);
|
||||||
|
expect(b.end).toBe(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toTimelineDay", () => {
|
||||||
|
it("draws the now-line only on today's row", () => {
|
||||||
|
const today = day({ day: DAY0, activeMs: HOUR });
|
||||||
|
const noon = DAY0 + 12 * HOUR;
|
||||||
|
const asToday = toTimelineDay(today, t, noon);
|
||||||
|
expect(asToday.isToday).toBe(true);
|
||||||
|
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
|
||||||
|
|
||||||
|
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
|
||||||
|
const asPast = toTimelineDay(past, t, noon);
|
||||||
|
expect(asPast.isToday).toBe(false);
|
||||||
|
expect(asPast.nowFraction).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels an empty day and totals it as —", () => {
|
||||||
|
const empty = day({ day: DAY0 });
|
||||||
|
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
|
||||||
|
expect(d.isEmpty).toBe(true);
|
||||||
|
expect(d.totalLabel).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildRows (empty-run collapsing)", () => {
|
||||||
|
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
|
||||||
|
const perDay: IPerDay[] = [
|
||||||
|
day({ day: DAY0, activeMs: HOUR }),
|
||||||
|
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
|
||||||
|
day({ day: DAY0 + (i + 1) * DAY_MS }),
|
||||||
|
),
|
||||||
|
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
|
||||||
|
];
|
||||||
|
const rows = buildRows(perDay, t, 0);
|
||||||
|
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
|
||||||
|
const gap = rows[1];
|
||||||
|
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a short empty run as individual dimmed day rows", () => {
|
||||||
|
const perDay: IPerDay[] = [
|
||||||
|
day({ day: DAY0, activeMs: HOUR }),
|
||||||
|
day({ day: DAY0 + DAY_MS }),
|
||||||
|
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
|
||||||
|
];
|
||||||
|
const rows = buildRows(perDay, t, 0);
|
||||||
|
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summaryLabels", () => {
|
||||||
|
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
|
||||||
|
const { total, agent } = summaryLabels(
|
||||||
|
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(total).toBe("≈ 4h 25m");
|
||||||
|
expect(agent).toBe("≈ 1h 20m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
|
||||||
|
const { total, agent } = summaryLabels(
|
||||||
|
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(total).toBe("agent: ≈ 1h 20m");
|
||||||
|
expect(agent).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("human-only page: no agent line", () => {
|
||||||
|
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
|
||||||
|
expect(agent).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatBlockTooltip", () => {
|
||||||
|
it("formats start–end from the real epoch in the data tz + duration", () => {
|
||||||
|
const d = day({
|
||||||
|
day: DAY0,
|
||||||
|
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
|
||||||
|
});
|
||||||
|
const [b] = toBlocks(d);
|
||||||
|
const label = formatBlockTooltip(b, "UTC", "en-US", t);
|
||||||
|
expect(label).toBe("09:00 – 10:30 · 1h 30m");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
|
||||||
|
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
|
||||||
|
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
|
||||||
|
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
|
||||||
|
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
|
||||||
|
// unit-testable without React. No re-bucketing: the server already grouped the
|
||||||
|
// windows by the request timezone — we only lay out the windows it returned.
|
||||||
|
|
||||||
|
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||||
|
import { formatDayTotal, formatHeadline } from "./format-work-time";
|
||||||
|
|
||||||
|
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||||
|
// "× N days" separator (§6.2 long-range) — preserved from the original punch-card.
|
||||||
|
export const EMPTY_RUN_COLLAPSE = 8;
|
||||||
|
|
||||||
|
export interface TimelineBlock {
|
||||||
|
/** Hour fraction 0..24 within the day — drives left/width positioning. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "work" | "agent";
|
||||||
|
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
|
||||||
|
startEpoch: number;
|
||||||
|
endEpoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineDay {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
totalLabel: string;
|
||||||
|
blocks: TimelineBlock[];
|
||||||
|
isEmpty: boolean;
|
||||||
|
/** Today lives only in the last active bucket; drives the "now" boundary. */
|
||||||
|
isToday: boolean;
|
||||||
|
/** 0..1 position of "now" within today's track (undefined when not today). */
|
||||||
|
nowFraction?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineRow =
|
||||||
|
| { type: "day"; day: TimelineDay }
|
||||||
|
| { type: "gap"; count: number };
|
||||||
|
|
||||||
|
/** Weekday-day-month heading in the browser locale (matches the server tz
|
||||||
|
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
|
||||||
|
export function dayHeading(day: number): string {
|
||||||
|
return new Date(day).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
|
||||||
|
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
|
||||||
|
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
|
||||||
|
export function toBlocks(day: IPerDay): TimelineBlock[] {
|
||||||
|
return day.windows.map((w: IDayWindow) => {
|
||||||
|
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
|
||||||
|
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
kind: w.class === "work" ? "work" : "agent",
|
||||||
|
startEpoch: w.start,
|
||||||
|
endEpoch: w.end,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampHour(h: number): number {
|
||||||
|
return Math.max(0, Math.min(24, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a single render-ready day. `now` is injected for testability; the
|
||||||
|
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
|
||||||
|
* day (so it appears on today's row and only when today has edits). */
|
||||||
|
export function toTimelineDay(
|
||||||
|
day: IPerDay,
|
||||||
|
t: Translate,
|
||||||
|
now: number,
|
||||||
|
): TimelineDay {
|
||||||
|
const isToday = now >= day.day && now < day.day + DAY_MS;
|
||||||
|
return {
|
||||||
|
key: day.dayISO,
|
||||||
|
label: dayHeading(day.day),
|
||||||
|
totalLabel: formatDayTotal(day.activeMs, t),
|
||||||
|
blocks: toBlocks(day),
|
||||||
|
isEmpty: day.activeMs === 0 && day.agentMs === 0,
|
||||||
|
isToday,
|
||||||
|
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
|
||||||
|
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
|
||||||
|
* punch-card so a page edited over months does not render hundreds of rows. */
|
||||||
|
export function buildRows(
|
||||||
|
perDay: IPerDay[],
|
||||||
|
t: Translate,
|
||||||
|
now: number,
|
||||||
|
): TimelineRow[] {
|
||||||
|
const rows: TimelineRow[] = [];
|
||||||
|
let emptyRun: IPerDay[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||||
|
rows.push({ type: "gap", count: emptyRun.length });
|
||||||
|
} else {
|
||||||
|
for (const d of emptyRun) {
|
||||||
|
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emptyRun = [];
|
||||||
|
};
|
||||||
|
for (const d of perDay) {
|
||||||
|
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||||
|
emptyRun.push(d);
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
|
||||||
|
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
|
||||||
|
* estimate in the main slot, and only show the secondary `agent:` line when
|
||||||
|
* BOTH a human and an agent estimate exist. */
|
||||||
|
export function summaryLabels(
|
||||||
|
data: IPageWorkTime,
|
||||||
|
t: Translate,
|
||||||
|
): { total: string; agent?: string } {
|
||||||
|
const total =
|
||||||
|
data.workMs > 0
|
||||||
|
? formatHeadline(data.workMs, t)
|
||||||
|
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
|
||||||
|
const agent =
|
||||||
|
data.workMs > 0 && data.agentOnlyMs > 0
|
||||||
|
? formatHeadline(data.agentOnlyMs, t)
|
||||||
|
: undefined;
|
||||||
|
return { total, agent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block hover label "start – end · duration". Times come from the REAL epoch
|
||||||
|
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
|
||||||
|
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
|
||||||
|
* here, so never "—"). */
|
||||||
|
export function formatBlockTooltip(
|
||||||
|
block: TimelineBlock,
|
||||||
|
tz: string,
|
||||||
|
locale: string,
|
||||||
|
t: Translate,
|
||||||
|
): string {
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale || undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: tz,
|
||||||
|
});
|
||||||
|
return t("{{start}} – {{end}} · {{duration}}", {
|
||||||
|
start: fmt.format(block.startEpoch),
|
||||||
|
end: fmt.format(block.endEpoch),
|
||||||
|
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
|
||||||
|
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
|
||||||
|
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
|
||||||
|
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
|
||||||
|
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
|
||||||
|
// change). Positioning math, empty-run collapsing and the formatters are reused.
|
||||||
|
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { IPageWorkTime } from "./work-time.types";
|
||||||
|
import { formatGapMinutes } from "./format-work-time";
|
||||||
|
import {
|
||||||
|
buildRows,
|
||||||
|
formatBlockTooltip,
|
||||||
|
summaryLabels,
|
||||||
|
TimelineBlock,
|
||||||
|
TimelineDay,
|
||||||
|
} from "./work-time-adapter";
|
||||||
|
import classes from "./work-time.module.css";
|
||||||
|
|
||||||
|
// Minimum visible width so a very short session neither vanishes nor fakes dense
|
||||||
|
// work (§6.2); kept from the original punch-card.
|
||||||
|
const MIN_BLOCK_WIDTH_PCT = 0.6;
|
||||||
|
|
||||||
|
function ActivityBlock({
|
||||||
|
block,
|
||||||
|
tz,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
block: TimelineBlock;
|
||||||
|
tz: string;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const left = (block.start / 24) * 100;
|
||||||
|
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
|
||||||
|
const cls = [
|
||||||
|
classes.window,
|
||||||
|
block.kind === "work" ? classes.windowWork : classes.windowAgent,
|
||||||
|
].join(" ");
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={formatBlockTooltip(block, tz, locale, t)}
|
||||||
|
withArrow
|
||||||
|
openDelay={120}
|
||||||
|
fz={11}
|
||||||
|
>
|
||||||
|
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayTrack({
|
||||||
|
day,
|
||||||
|
tz,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
day: TimelineDay;
|
||||||
|
tz: string;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={classes.row}>
|
||||||
|
<span className={classes.dayLabel}>{day.label}</span>
|
||||||
|
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
|
||||||
|
{[25, 50, 75].map((p) => (
|
||||||
|
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
|
||||||
|
))}
|
||||||
|
{day.blocks.map((b, i) => (
|
||||||
|
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
|
||||||
|
))}
|
||||||
|
{day.isToday && day.nowFraction != null && (
|
||||||
|
<div
|
||||||
|
className={classes.nowLine}
|
||||||
|
style={{ left: `${day.nowFraction * 100}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={classes.daySum}
|
||||||
|
data-empty={day.totalLabel === "—" ? true : undefined}
|
||||||
|
>
|
||||||
|
{day.totalLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IPageWorkTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkTimePunchCard({ data }: Props) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.language;
|
||||||
|
const now = Date.now();
|
||||||
|
const rows = useMemo(
|
||||||
|
() => buildRows(data.perDay, t, now),
|
||||||
|
// `now` intentionally re-read on each open; excluded so the memo tracks data.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[data.perDay, t],
|
||||||
|
);
|
||||||
|
const { total, agent } = summaryLabels(data, t);
|
||||||
|
const gapMin = formatGapMinutes(data.config.tGap);
|
||||||
|
|
||||||
|
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" py="md">
|
||||||
|
{t("No editing activity recorded yet.")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{/* summary */}
|
||||||
|
<Group align="baseline" gap="md">
|
||||||
|
<Text fz={22} fw={700}>
|
||||||
|
{total}
|
||||||
|
</Text>
|
||||||
|
{agent && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("agent: {{value}}", { value: agent })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* legend */}
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<span
|
||||||
|
className={`${classes.legendSwatch} ${classes.windowWork}`}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
{t("Work")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<span
|
||||||
|
className={`${classes.legendSwatch} ${classes.windowAgent}`}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
{t("Agent")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* sticky hour axis */}
|
||||||
|
<div className={`${classes.row} ${classes.axisRow}`}>
|
||||||
|
<span />
|
||||||
|
<div className={classes.axis}>
|
||||||
|
{[
|
||||||
|
["0%", "00", "start"],
|
||||||
|
["25%", "06", "center"],
|
||||||
|
["50%", "12", "center"],
|
||||||
|
["75%", "18", "center"],
|
||||||
|
["100%", "24", "end"],
|
||||||
|
].map(([l, label, align]) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className={classes.axisTick}
|
||||||
|
data-align={align}
|
||||||
|
style={{ left: l }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* day rows */}
|
||||||
|
<ScrollArea.Autosize mah="60vh" type="hover">
|
||||||
|
{rows.map((row, i) =>
|
||||||
|
row.type === "day" ? (
|
||||||
|
<DayTrack
|
||||||
|
key={row.day.key}
|
||||||
|
day={row.day}
|
||||||
|
tz={data.tz}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box key={`gap-${i}`} className={classes.gapRow}>
|
||||||
|
{t("× {{count}} days without edits", { count: row.count })}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||||
|
tz: data.tz,
|
||||||
|
gap: gapMin,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { IPageWorkTime } from "./work-time.types";
|
||||||
|
|
||||||
|
/** The viewer's IANA timezone (browser locale) — the punch-card lays days out
|
||||||
|
* in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */
|
||||||
|
export function viewerTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageWorkTime(
|
||||||
|
pageId: string,
|
||||||
|
tz: string,
|
||||||
|
): Promise<IPageWorkTime> {
|
||||||
|
const req = await api.post<IPageWorkTime>("/pages/history/time", {
|
||||||
|
pageId,
|
||||||
|
tz,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconClockHour4 } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePageWorkTime } from "./use-page-work-time";
|
||||||
|
import { formatGapMinutes, formatHeadline } from "./format-work-time";
|
||||||
|
import WorkTimePunchCard from "./work-time-punch-card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #395 — the clickable "time worked on this article" headline (§6.1). Renders
|
||||||
|
* the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip
|
||||||
|
* (it is an estimate, not a stopwatch). Clicking opens the daily punch-card
|
||||||
|
* (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a
|
||||||
|
* brand-new / never-edited page shows no widget. For an agent-only-edited page
|
||||||
|
* (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled
|
||||||
|
* `agent:`, matching the punch-card) so the punch-card stays reachable (#395:
|
||||||
|
* "how much a HUMAN and separately the AGENT").
|
||||||
|
*/
|
||||||
|
export default function WorkTimeStat({ pageId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const { data } = usePageWorkTime(pageId);
|
||||||
|
|
||||||
|
if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null;
|
||||||
|
|
||||||
|
const agentOnly = data.workMs <= 0;
|
||||||
|
const label = agentOnly
|
||||||
|
? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })
|
||||||
|
: formatHeadline(data.workMs, t);
|
||||||
|
const gapMin = formatGapMinutes(data.config.tGap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
label={t("Estimated time worked (inactivity gap {{gap}} min)", {
|
||||||
|
gap: gapMin,
|
||||||
|
})}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={open}
|
||||||
|
aria-label={t("Show time worked on this page")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<IconClockHour4 size={14} />
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={t("Time worked on this article")}
|
||||||
|
size="46rem"
|
||||||
|
>
|
||||||
|
<WorkTimePunchCard data={data} />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* #566 — time-of-day timeline. Custom CSS segments on a fixed 24-hour track
|
||||||
|
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
|
||||||
|
Night hours (0–6, 21–24) are shaded so the eye reads morning-vs-evening; a
|
||||||
|
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr 64px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabel {
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
--wt-night: rgba(90, 100, 130, 0.16);
|
||||||
|
--wt-day: rgba(90, 100, 130, 0.03);
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--wt-night) 0 25%,
|
||||||
|
var(--wt-day) 25% 87.5%,
|
||||||
|
var(--wt-night) 87.5% 100%
|
||||||
|
),
|
||||||
|
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
|
||||||
|
.trackEmpty {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quarter-day grid divisions (06/12/18) inside the track. */
|
||||||
|
.hourTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-3),
|
||||||
|
var(--mantine-color-dark-4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 3px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windowWork {
|
||||||
|
background-color: var(--mantine-color-blue-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.windowAgent {
|
||||||
|
background-color: var(--mantine-color-grape-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "now" boundary on today's row. */
|
||||||
|
.nowLine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: var(--mantine-color-red-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySum {
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySum[data-empty] {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky hour axis header aligned to the track column. */
|
||||||
|
.axisRow {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--mantine-color-body);
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axisTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axisTick[data-align="center"] {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axisTick[data-align="end"] {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gapRow {
|
||||||
|
padding: 6px 0 6px 108px;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSwatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// #395 — client-side mirror of the server work-time payload
|
||||||
|
// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time.
|
||||||
|
|
||||||
|
export type WorkSessionClass = "work" | "agent_only";
|
||||||
|
|
||||||
|
export interface IDayWindow {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
class: WorkSessionClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPerDay {
|
||||||
|
day: number;
|
||||||
|
dayISO: string;
|
||||||
|
activeMs: number;
|
||||||
|
agentMs: number;
|
||||||
|
windows: IDayWindow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkTimeConfig {
|
||||||
|
tGap: number;
|
||||||
|
agentTGap: number;
|
||||||
|
pIn: number;
|
||||||
|
pOut: number;
|
||||||
|
pSingle: number;
|
||||||
|
excludeGit: boolean;
|
||||||
|
burstCapMs?: number;
|
||||||
|
dedupRoundMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPageWorkTime {
|
||||||
|
workMs: number;
|
||||||
|
agentOnlyMs: number;
|
||||||
|
perDay: IPerDay[];
|
||||||
|
config: IWorkTimeConfig;
|
||||||
|
tz: string;
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
|
import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import {
|
import {
|
||||||
useFavoriteIds,
|
useFavoriteIds,
|
||||||
@@ -265,6 +266,8 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{page?.id && <WorkTimeStat pageId={page.id} />}
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
shadow="xl"
|
shadow="xl"
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
|
|||||||
@@ -665,6 +665,13 @@ export function updateCacheOnMovePage(
|
|||||||
pageData: Partial<IPage>,
|
pageData: Partial<IPage>,
|
||||||
) {
|
) {
|
||||||
invalidatePageTree();
|
invalidatePageTree();
|
||||||
|
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
|
||||||
|
// guard removes the moved node from the local tree when its new parent is an
|
||||||
|
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
|
||||||
|
// falls back to the server `["breadcrumbs", pageId]` query — which this move
|
||||||
|
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
|
||||||
|
// refocus/navigation.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
|
||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import type { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||||
|
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
|
||||||
|
vi.mock("@/main.tsx", async () => {
|
||||||
|
const { QueryClient } = await import("@tanstack/react-query");
|
||||||
|
return { queryClient: new QueryClient() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { updateCacheOnMovePage } from "./page-query";
|
||||||
|
|
||||||
|
// #523: the tree-side child-loss guard removes the moved node from the local
|
||||||
|
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
|
||||||
|
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
|
||||||
|
// query. That query MUST be invalidated by a move, or the crumbs keep showing
|
||||||
|
// the OLD parent until a refocus/navigation.
|
||||||
|
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
|
||||||
|
const spy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
|
||||||
|
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
|
||||||
|
id: "moved-page",
|
||||||
|
} as Partial<IPage>);
|
||||||
|
|
||||||
|
const invalidatedBreadcrumbs = spy.mock.calls.some(
|
||||||
|
([arg]) =>
|
||||||
|
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
|
||||||
|
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
|
||||||
|
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
|
||||||
|
);
|
||||||
|
expect(invalidatedBreadcrumbs).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,7 +55,14 @@ type Props<T extends object> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DRAG_TYPE = 'doc-tree-item';
|
const DRAG_TYPE = 'doc-tree-item';
|
||||||
const AUTO_EXPAND_MS = 500;
|
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
|
||||||
|
// so merely dragging the cursor THROUGH the tree never expands rows — only a
|
||||||
|
// deliberate hold does (#523).
|
||||||
|
const AUTO_EXPAND_MS = 2000;
|
||||||
|
// How long the "a page just moved in here" cue stays on a collapsed target after
|
||||||
|
// a make-child drop. Long enough to notice at a glance during frequent
|
||||||
|
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
|
||||||
|
const DROP_LANDED_HIGHLIGHT_MS = 1800;
|
||||||
|
|
||||||
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||||
const {
|
const {
|
||||||
@@ -93,7 +100,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
const rowRef = useRef<HTMLElement>(null);
|
const rowRef = useRef<HTMLElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
||||||
|
// Transient "just received a child" cue: a make-child drop no longer expands
|
||||||
|
// the (collapsed) target, so flash the row instead so the move isn't invisible.
|
||||||
|
const [landedChild, setLandedChild] = useState(false);
|
||||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelAutoExpand = useCallback(() => {
|
const cancelAutoExpand = useCallback(() => {
|
||||||
if (autoExpandTimerRef.current) {
|
if (autoExpandTimerRef.current) {
|
||||||
@@ -249,11 +260,24 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
? getDragLabel(sourceNode)
|
? getDragLabel(sourceNode)
|
||||||
: 'item';
|
: 'item';
|
||||||
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
||||||
// After a make-child drop, expand this row so the user sees the
|
// Do NOT auto-expand the target on drop: a drop must leave the node
|
||||||
// just-dropped child — especially important when the row had no
|
// collapsed. Intentional expansion is handled solely by the
|
||||||
// children before (chevron just appeared) so the drop would
|
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
|
||||||
// otherwise be invisible.
|
// given by the post-move flash + landed-cue highlight + live-region
|
||||||
if (op.kind === 'make-child') onToggle(node.id, true);
|
// announce above. When the make-child target is collapsed, flash a
|
||||||
|
// distinct "child moved in here" cue on the row (it stays collapsed).
|
||||||
|
if (op.kind === 'make-child' && !isOpen) {
|
||||||
|
if (landedChildTimerRef.current) {
|
||||||
|
clearTimeout(landedChildTimerRef.current);
|
||||||
|
}
|
||||||
|
setLandedChild(true);
|
||||||
|
landedChildTimerRef.current = setTimeout(() => {
|
||||||
|
setLandedChild(false);
|
||||||
|
landedChildTimerRef.current = null;
|
||||||
|
}, DROP_LANDED_HIGHLIGHT_MS);
|
||||||
|
}
|
||||||
|
// Restore the openness of the MOVED page itself (source) — untouched
|
||||||
|
// by the above; the target is never expanded here.
|
||||||
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -281,6 +305,17 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
|
|
||||||
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
||||||
|
|
||||||
|
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (landedChildTimerRef.current) {
|
||||||
|
clearTimeout(landedChildTimerRef.current);
|
||||||
|
landedChildTimerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const effectiveInst =
|
const effectiveInst =
|
||||||
instruction?.type === 'instruction-blocked'
|
instruction?.type === 'instruction-blocked'
|
||||||
? instruction.desired
|
? instruction.desired
|
||||||
@@ -317,6 +352,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
className={styles.node}
|
className={styles.node}
|
||||||
data-dragging={isDragging || undefined}
|
data-dragging={isDragging || undefined}
|
||||||
data-selected={isSelected || undefined}
|
data-selected={isSelected || undefined}
|
||||||
|
data-landed-child={landedChild || undefined}
|
||||||
data-receiving-drop={
|
data-receiving-drop={
|
||||||
receivingDrop === 'make-child'
|
receivingDrop === 'make-child'
|
||||||
? blocked
|
? blocked
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
|
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
|
|
||||||
|
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
|
||||||
|
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
|
||||||
|
// child-loss guard is exercised end-to-end.
|
||||||
|
const moveMutate = vi.fn().mockResolvedValue({});
|
||||||
|
const updateCacheOnMovePageMock = vi.fn();
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
|
||||||
|
updateCacheOnMovePage: (...args: unknown[]) =>
|
||||||
|
updateCacheOnMovePageMock(...args),
|
||||||
|
}));
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (s: string) => s }),
|
||||||
|
}));
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER mocks so the hook binds to them.
|
||||||
|
import { useTreeMutation } from "./use-tree-mutation";
|
||||||
|
|
||||||
|
function node(
|
||||||
|
id: string,
|
||||||
|
over: Partial<SpaceTreeNode> = {},
|
||||||
|
): SpaceTreeNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id}`,
|
||||||
|
name: id.toUpperCase(),
|
||||||
|
position: "a0",
|
||||||
|
spaceId: "space-1",
|
||||||
|
parentPageId: null as unknown as string,
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(before: SpaceTreeNode[]) {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(treeDataAtom, before);
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
|
||||||
|
return { store, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
moveMutate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
|
||||||
|
// F has children on the server but none are loaded here (canonical unloaded
|
||||||
|
// form: hasChildren + children:[]). X sits at root.
|
||||||
|
const before = [
|
||||||
|
node("F", { position: "a0", hasChildren: true, children: [] }),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "F",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const f = treeModel.find(tree, "F");
|
||||||
|
// The guard leaves F unloaded (children stay []), so a later expand fetches
|
||||||
|
// the FULL server set (incl. X) instead of showing a misleading partial [X].
|
||||||
|
// MUTATION: dropping the guard (using the `move` result) would put children
|
||||||
|
// === [X] here and redden this.
|
||||||
|
expect(f?.children).toEqual([]);
|
||||||
|
expect(f?.hasChildren).toBe(true);
|
||||||
|
// X is removed from its old (root) slot; it reappears on expand/load of F.
|
||||||
|
expect(treeModel.find(tree, "X")).toBeNull();
|
||||||
|
// The server move is still persisted.
|
||||||
|
expect(moveMutate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
|
||||||
|
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
|
||||||
|
// append X — this path does NOT hit the guard.
|
||||||
|
const before = [
|
||||||
|
node("F", {
|
||||||
|
position: "a0",
|
||||||
|
hasChildren: true,
|
||||||
|
children: [node("c1", { position: "a1", parentPageId: "F" })],
|
||||||
|
}),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "F",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const f = treeModel.find(tree, "F");
|
||||||
|
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
|
||||||
|
expect(f?.hasChildren).toBe(true);
|
||||||
|
// X now lives under F.
|
||||||
|
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
|
||||||
|
// Leaf L has no server children (hasChildren:false). Dropping X in should
|
||||||
|
// show X immediately — the guard must NOT fire here.
|
||||||
|
const before = [
|
||||||
|
node("L", { position: "a0", hasChildren: false, children: [] }),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "L",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const l = treeModel.find(tree, "L");
|
||||||
|
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
|
||||||
|
expect(l?.hasChildren).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,11 +61,48 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
if (!source) return;
|
if (!source) return;
|
||||||
const oldParentId = source.parentPageId ?? null;
|
const oldParentId = source.parentPageId ?? null;
|
||||||
|
|
||||||
// optimistic apply with the new position from the payload
|
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
|
||||||
let optimistic = treeModel.update(after, sourceId, {
|
// We no longer auto-expand the make-child target on drop, so the old
|
||||||
position: payload.position,
|
// `onToggle(target, true)` — which was ALSO the only trigger of the
|
||||||
parentPageId: payload.parentPageId,
|
// corrective lazy-load — is gone. `treeModel.move` materialized
|
||||||
} as Partial<SpaceTreeNode>);
|
// `target.children = [source]` (only the moved node); if the target is an
|
||||||
|
// UNLOADED branch (server has children but none are loaded here), keeping
|
||||||
|
// that partial `[source]` list would defeat the lazy-load gate and hide the
|
||||||
|
// target's OTHER server children (the #159 #1 data-loss class). So for an
|
||||||
|
// unloaded make-child target, build the optimistic tree WITHOUT
|
||||||
|
// materializing source under it: just remove source from its old parent and
|
||||||
|
// flag the target `hasChildren`. The gate stays armed and a later manual
|
||||||
|
// expand fetches the FULL set (incl. the moved page, which the awaited
|
||||||
|
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
|
||||||
|
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
|
||||||
|
const target =
|
||||||
|
op.kind === "make-child"
|
||||||
|
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
|
||||||
|
: null;
|
||||||
|
const unloadedMakeChild =
|
||||||
|
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
|
||||||
|
|
||||||
|
let optimistic: SpaceTreeNode[];
|
||||||
|
if (unloadedMakeChild) {
|
||||||
|
// Do NOT materialize [source] into the unloaded target.
|
||||||
|
optimistic = treeModel.remove(before, sourceId);
|
||||||
|
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||||
|
hasChildren: true,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
} else {
|
||||||
|
// optimistic apply with the new position from the payload
|
||||||
|
optimistic = treeModel.update(after, sourceId, {
|
||||||
|
position: payload.position,
|
||||||
|
parentPageId: payload.parentPageId,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
// For make-child onto a previously-childless (loaded) target: flip
|
||||||
|
// hasChildren on so the new parent shows its chevron.
|
||||||
|
if (op.kind === "make-child") {
|
||||||
|
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||||
|
hasChildren: true,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the old parent has no children left, mark hasChildren: false so the
|
// If the old parent has no children left, mark hasChildren: false so the
|
||||||
// chevron disappears. Without this, the empty parent keeps rendering an
|
// chevron disappears. Without this, the empty parent keeps rendering an
|
||||||
@@ -79,14 +116,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For make-child onto a previously-childless target: flip hasChildren on
|
|
||||||
// so the new parent shows its chevron.
|
|
||||||
if (op.kind === "make-child") {
|
|
||||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
|
||||||
hasChildren: true,
|
|
||||||
} as Partial<SpaceTreeNode>);
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(optimistic);
|
setData(optimistic);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -150,6 +150,45 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
|
||||||
|
the collapsed target, so the moved page is momentarily invisible; pulse the
|
||||||
|
target row in a distinct teal (NOT the blue make-child highlight, NOT the
|
||||||
|
neutral post-move flash) so the landing is noticeable while the node stays
|
||||||
|
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
|
||||||
|
which the row clears the attribute and the animation stops. */
|
||||||
|
@keyframes landedChildPulse {
|
||||||
|
0% {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-teal-2),
|
||||||
|
rgba(45, 212, 191, 0.30)
|
||||||
|
);
|
||||||
|
outline-color: light-dark(
|
||||||
|
var(--mantine-color-teal-6),
|
||||||
|
var(--mantine-color-teal-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
outline-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node[data-landed-child="true"] {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
animation: landedChildPulse 0.9s ease-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.node[data-landed-child="true"] {
|
||||||
|
animation: none;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-teal-1),
|
||||||
|
rgba(45, 212, 191, 0.18)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropLine {
|
.dropLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--drop-line-indent, 0);
|
left: var(--drop-line-indent, 0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import { Group, VisuallyHidden } from "@mantine/core";
|
import { Group, Text, VisuallyHidden } from "@mantine/core";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -84,6 +84,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
spaceId={spaceId}
|
spaceId={spaceId}
|
||||||
/>
|
/>
|
||||||
|
{/* #529: operator hint — matches ANY word by default; "…" for an exact
|
||||||
|
phrase, +term to require, -term to exclude. */}
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('Tip: "exact phrase", +required, -excluded')}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VisuallyHidden role="status" aria-live="polite">
|
<VisuallyHidden role="status" aria-live="polite">
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
|||||||
|
|
||||||
export interface IPageSearch {
|
export interface IPageSearch {
|
||||||
id: string;
|
id: string;
|
||||||
|
// #529 A7 superset: `pageId` aliases `id`; `rank`/`highlight` are null for
|
||||||
|
// substring-only hits (the UI already falls back to the title/snippet).
|
||||||
|
pageId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
parentPageId: string;
|
parentPageId: string;
|
||||||
@@ -12,9 +15,36 @@ export interface IPageSearch {
|
|||||||
creatorId: string;
|
creatorId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
rank: string;
|
rank: string | number | null;
|
||||||
highlight: string;
|
highlight: string | null;
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
|
// New #529 fields (present from the native Postgres search driver).
|
||||||
|
snippet?: string;
|
||||||
|
score?: number;
|
||||||
|
path?: string[];
|
||||||
|
matchedFields?: string[];
|
||||||
|
matchedTerms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// #529 A5 pagination envelope returned by POST /search (native driver). The web
|
||||||
|
// list helpers read `items`; these travel alongside for pagination + diagnostics.
|
||||||
|
export interface IPageSearchResponse {
|
||||||
|
items: IPageSearch[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
truncatedAtCap: boolean;
|
||||||
|
offset: number;
|
||||||
|
query?: {
|
||||||
|
raw: string;
|
||||||
|
parsed: {
|
||||||
|
positive: string[];
|
||||||
|
required: string[];
|
||||||
|
excluded: string[];
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
mode: "or" | "and";
|
||||||
|
match: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchSuggestionParams {
|
export interface SearchSuggestionParams {
|
||||||
@@ -37,6 +67,10 @@ export interface IPageSearchParams {
|
|||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
|
// #529 A9: match mode (auto default) + pagination.
|
||||||
|
match?: "auto" | "word" | "prefix" | "substring";
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAttachmentSearch {
|
export interface IAttachmentSearch {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, act, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Mocks for the dirty shell's side-effecting collaborators.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||||
|
vi.mock("@/lib/reload-guard", () => ({
|
||||||
|
hasAutoReloaded: vi.fn(() => false),
|
||||||
|
markAutoReloaded: vi.fn(() => true),
|
||||||
|
recordReloadBreadcrumb: vi.fn(),
|
||||||
|
takeReloadBreadcrumb: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
__resetGuardedReloadForTests,
|
||||||
|
} from "./guarded-reload";
|
||||||
|
|
||||||
|
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
let reload: ReturnType<typeof vi.fn>;
|
||||||
|
let visibility: DocumentVisibilityState;
|
||||||
|
|
||||||
|
// Test harness mounted inside a router: it installs the navigation hook and
|
||||||
|
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||||
|
let doNavigate: (to: string) => void;
|
||||||
|
function Harness() {
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
doNavigate = navigate;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountHarness() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/start"]}>
|
||||||
|
<Harness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
act(() => {
|
||||||
|
doNavigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockHasAutoReloaded.mockReturnValue(false);
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(true);
|
||||||
|
|
||||||
|
vi.stubGlobal("APP_VERSION", "test-A");
|
||||||
|
|
||||||
|
reload = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload },
|
||||||
|
});
|
||||||
|
|
||||||
|
visibility = "visible";
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => visibility,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("triggerGuardedReload (variant C)", () => {
|
||||||
|
it("noop when versions match: no banner, no reload", () => {
|
||||||
|
triggerGuardedReload("test-A");
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
triggerGuardedReload("");
|
||||||
|
triggerGuardedReload(undefined);
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
expect(show.mock.calls[0][0]).toMatchObject({
|
||||||
|
id: "app-version-reload",
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second navigation must NOT reload again (one-shot was consumed).
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
navigateTo("/again");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload merely from the tab going to the background", () => {
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
visibility = "hidden";
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||||
|
visibility = "hidden";
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the banner's Update button reloads immediately", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
const message = show.mock.calls[0][0].message as {
|
||||||
|
props: { onClick: () => void };
|
||||||
|
};
|
||||||
|
message.props.onClick();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||||
|
mockHasAutoReloaded.mockReturnValue(true);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||||
|
mockMarkAutoReloaded.mockReturnValue(false);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||||
|
expect(show).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
triggerGuardedReload("test-C");
|
||||||
|
expect(show).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||||
|
|
||||||
|
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||||
|
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||||
|
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||||
|
// separate from the pure module so the decision stays unit-testable without a
|
||||||
|
// DOM.
|
||||||
|
|
||||||
|
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||||
|
// single banner instead of stacking a new one each time.
|
||||||
|
const BANNER_ID = "app-version-reload";
|
||||||
|
|
||||||
|
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||||
|
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||||
|
// subsequent app-version emits.
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||||
|
// the background (that would silently drop a half-written comment/form). Instead
|
||||||
|
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||||
|
// the user is already leaving the current page, so an in-app navigation would
|
||||||
|
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||||
|
let pendingNavReload = false;
|
||||||
|
|
||||||
|
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||||
|
// the (already-visible) banner.
|
||||||
|
let lastServerVersion = "";
|
||||||
|
let lastClientVersion = "";
|
||||||
|
|
||||||
|
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||||
|
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||||
|
// where Vite's `define` did not run) — an unknown client version makes the
|
||||||
|
// pure decision no-op (fail-safe).
|
||||||
|
function readClientVersion(): string {
|
||||||
|
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual reload — but only after the shared one-shot flag is
|
||||||
|
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||||
|
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||||
|
// to the manual banner so the user can still recover.
|
||||||
|
function performAutoReload(): void {
|
||||||
|
if (!markAutoReloaded()) {
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Trace right before the reload (which clears the console): a persistent
|
||||||
|
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: lastServerVersion,
|
||||||
|
clientVersion: lastClientVersion,
|
||||||
|
});
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||||
|
);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReloadBanner(): void {
|
||||||
|
notifications.show({
|
||||||
|
id: BANNER_ID,
|
||||||
|
title: i18n.t("A new version is available"),
|
||||||
|
message: (
|
||||||
|
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||||
|
{i18n.t("Update")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||||
|
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||||
|
* the next in-app navigation (variant C).
|
||||||
|
*
|
||||||
|
* - real mismatch (window budget available) → banner + arm navigation reload.
|
||||||
|
* The banner's "Update" button reloads immediately (same shared window guard).
|
||||||
|
* The tab is NOT reloaded on visibility change.
|
||||||
|
* - auto-reload already used this window / storage error → banner only (no arm),
|
||||||
|
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
||||||
|
* safety).
|
||||||
|
* - in sync / unknown version → noop (fail-safe).
|
||||||
|
*/
|
||||||
|
export function triggerGuardedReload(
|
||||||
|
rawServerVersion: string | undefined | null,
|
||||||
|
): void {
|
||||||
|
const serverVersion = (rawServerVersion ?? "").trim();
|
||||||
|
const clientVersion = readClientVersion();
|
||||||
|
|
||||||
|
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||||
|
// reloading (banner only).
|
||||||
|
const autoReloadUsed = hasAutoReloaded();
|
||||||
|
|
||||||
|
const action = decideVersionAction({
|
||||||
|
serverVersion,
|
||||||
|
clientVersion,
|
||||||
|
autoReloadUsed,
|
||||||
|
});
|
||||||
|
if (action === "noop") return;
|
||||||
|
|
||||||
|
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||||
|
// emits (reconnects) once we've already acted.
|
||||||
|
if (handled) return;
|
||||||
|
handled = true;
|
||||||
|
|
||||||
|
lastServerVersion = serverVersion;
|
||||||
|
lastClientVersion = clientVersion;
|
||||||
|
|
||||||
|
if (action === "banner") {
|
||||||
|
// Entered banner-only (permanent skew, node oscillation, or the window's
|
||||||
|
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
||||||
|
console.warn(
|
||||||
|
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||||
|
"auto-reload budget already spent this window — showing manual banner",
|
||||||
|
);
|
||||||
|
showReloadBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||||
|
// to the next in-app navigation instead of reloading now / on visibility.
|
||||||
|
showReloadBanner();
|
||||||
|
pendingNavReload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume the armed one-shot navigation reload, if any. Called by
|
||||||
|
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||||
|
*/
|
||||||
|
export function consumeNavigationReload(): void {
|
||||||
|
if (!pendingNavReload) return;
|
||||||
|
pendingNavReload = false;
|
||||||
|
performAutoReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||||
|
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||||
|
* render so it only reacts to real navigations, not the first location.
|
||||||
|
*/
|
||||||
|
export function useVersionReloadOnNavigation(): void {
|
||||||
|
const location = useLocation();
|
||||||
|
const firstRender = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstRender.current) {
|
||||||
|
firstRender.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumeNavigationReload();
|
||||||
|
}, [location.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||||
|
* report diagnosable. Call once on app startup.
|
||||||
|
*/
|
||||||
|
export function surfacePreviousReloadBreadcrumb(): void {
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
if (!crumb) return;
|
||||||
|
console.info(
|
||||||
|
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||||
|
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||||
|
`at=${new Date(crumb.at).toISOString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: reset module-level latches between cases.
|
||||||
|
export function __resetGuardedReloadForTests(): void {
|
||||||
|
handled = false;
|
||||||
|
pendingNavReload = false;
|
||||||
|
lastServerVersion = "";
|
||||||
|
lastClientVersion = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, act, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
||||||
|
// the reactive chunk-load boundary and the proactive version-coherence path both
|
||||||
|
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
||||||
|
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
||||||
|
// side-effecting collaborators are mocked — the reload guard is intentionally
|
||||||
|
// REAL so this exercises the actual shared sessionStorage budget.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||||
|
|
||||||
|
import { handleError } from "@/components/chunk-load-error-boundary";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
__resetGuardedReloadForTests,
|
||||||
|
} from "./guarded-reload";
|
||||||
|
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
||||||
|
|
||||||
|
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
||||||
|
const T0 = 1_000_000_000_000;
|
||||||
|
|
||||||
|
let reload: ReturnType<typeof vi.fn>;
|
||||||
|
let nowMock: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
// Harness mounted inside a router: installs the navigation hook and exposes
|
||||||
|
// `navigate` so a test can drive an in-app router navigation (the point where the
|
||||||
|
// proactive path fires its armed reload).
|
||||||
|
let doNavigate: (to: string) => void;
|
||||||
|
function Harness() {
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
doNavigate = useNavigate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function mountHarness() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/start"]}>
|
||||||
|
<Harness />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
act(() => {
|
||||||
|
doNavigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setNow(t: number) {
|
||||||
|
nowMock.mockReturnValue(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
||||||
|
vi.stubGlobal("APP_VERSION", "test-A");
|
||||||
|
reload = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared window-based reload budget (invariant a)", () => {
|
||||||
|
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
||||||
|
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
||||||
|
// is spent, so the version mismatch degrades to the banner and never reloads.
|
||||||
|
setNow(T0 + 60_000);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
||||||
|
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
||||||
|
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
||||||
|
setNow(T0 + 2 * 60_000);
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
||||||
|
// First auto-reload (reactive) stamps the window.
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
reload.mockClear();
|
||||||
|
|
||||||
|
// A second deploy arrives after the window has fully elapsed → the proactive
|
||||||
|
// path is allowed to reload again (window, not a permanent one-shot).
|
||||||
|
__resetGuardedReloadForTests();
|
||||||
|
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
||||||
|
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
||||||
|
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
||||||
|
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
||||||
|
// returns false and that guard MUST bail — otherwise the reactive path would
|
||||||
|
// reload on every stale-chunk error with no persisted budget (an unguarded
|
||||||
|
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
||||||
|
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
||||||
|
// fails".
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("quota exceeded");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
||||||
|
// The real guard fails toward NOT reloading when storage throws.
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
handleError(CHUNK_ERROR);
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mountHarness();
|
||||||
|
triggerGuardedReload("test-B");
|
||||||
|
navigateTo("/next");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
import {
|
||||||
|
triggerGuardedReload,
|
||||||
|
useVersionReloadOnNavigation,
|
||||||
|
surfacePreviousReloadBreadcrumb,
|
||||||
|
} from "@/features/user/guarded-reload.tsx";
|
||||||
|
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||||
|
|
||||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
// fetch collab token on load
|
// fetch collab token on load
|
||||||
const { data: collab } = useCollabToken();
|
const { data: collab } = useCollabToken();
|
||||||
|
|
||||||
|
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||||
|
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||||
|
useVersionReloadOnNavigation();
|
||||||
|
|
||||||
|
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||||
|
// (the reload cleared the console) so a field report stays diagnosable.
|
||||||
|
useEffect(() => {
|
||||||
|
surfacePreviousReloadBreadcrumb();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isError) {
|
if (isLoading || isError) {
|
||||||
return;
|
return;
|
||||||
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
handleConnect();
|
handleConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||||
|
// connects: the server emits `app-version` immediately in handleConnection,
|
||||||
|
// so a listener attached after connect would miss it on a fast localhost
|
||||||
|
// connect. On a version mismatch the client shows a banner and defers the
|
||||||
|
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||||
|
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||||
|
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||||
|
triggerGuardedReload(payload?.version);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("ws disconnected");
|
console.log("ws disconnected");
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideVersionAction } from "./version-coherence";
|
||||||
|
|
||||||
|
describe("decideVersionAction", () => {
|
||||||
|
it("noop when the server version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when the client version is empty (fail-safe)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noop when versions are equal (in sync)", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload on a real mismatch the first time this session", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: false,
|
||||||
|
}),
|
||||||
|
).toBe("reload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||||
|
expect(
|
||||||
|
decideVersionAction({
|
||||||
|
serverVersion: "v1",
|
||||||
|
clientVersion: "v1",
|
||||||
|
autoReloadUsed: true,
|
||||||
|
}),
|
||||||
|
).toBe("noop");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||||
|
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||||
|
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||||
|
// `operation`), so it never touches use-query-subscription.
|
||||||
|
export type AppVersionSocketPayload = { version: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision for the version-coherence guard.
|
||||||
|
*
|
||||||
|
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||||
|
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||||
|
*
|
||||||
|
* - `autoReloadUsed` = an automatic reload has already happened within the
|
||||||
|
* current ~5-min window, so we must not auto-reload again (loop safety,
|
||||||
|
* shared window budget with the reactive chunk-load boundary).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||||
|
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||||
|
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||||
|
*/
|
||||||
|
export function decideVersionAction(args: {
|
||||||
|
serverVersion: string;
|
||||||
|
clientVersion: string;
|
||||||
|
autoReloadUsed: boolean;
|
||||||
|
}): "reload" | "banner" | "noop" {
|
||||||
|
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||||
|
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||||
|
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||||
|
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
||||||
|
return "reload"; // real mismatch, window budget available
|
||||||
|
}
|
||||||
@@ -14,3 +14,19 @@ export function execCommandCopy(text: string): void {
|
|||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stateless one-shot copy: write `text` to the clipboard without ever storing it
|
||||||
|
// in React state (unlike useClipboard, which keeps a `copied` flag AND holds the
|
||||||
|
// last value). Used by the api-key reveal/copy flow, where the secret must touch
|
||||||
|
// nothing but the clipboard — no component state, no cache, no localStorage.
|
||||||
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the execCommand fallback (e.g. insecure context).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execCommandCopy(text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
shouldAutoReload,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
takeReloadBreadcrumb,
|
||||||
|
RELOAD_WINDOW_MS,
|
||||||
|
} from "./reload-guard";
|
||||||
|
|
||||||
|
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
||||||
|
// reactive chunk-load boundary and the proactive version-coherence path read and
|
||||||
|
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
||||||
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
describe("reload-guard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||||
|
expect(markAutoReloaded(NOW)).toBe(true);
|
||||||
|
// Same key both paths share; stores the reload timestamp, not a flag.
|
||||||
|
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
||||||
|
// Inside the window → budget spent → true (fall through to manual UI).
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(true);
|
||||||
|
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
||||||
|
markAutoReloaded(NOW);
|
||||||
|
// Strictly older than the window → a new deploy's mismatch may reload again.
|
||||||
|
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(hasAutoReloaded()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
||||||
|
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(markAutoReloaded()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||||
|
recordReloadBreadcrumb({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
const crumb = takeReloadBreadcrumb();
|
||||||
|
expect(crumb).toMatchObject({
|
||||||
|
path: "proactive",
|
||||||
|
serverVersion: "test-B",
|
||||||
|
clientVersion: "test-A",
|
||||||
|
});
|
||||||
|
expect(typeof crumb?.at).toBe("number");
|
||||||
|
// Cleared on read → a second take returns null.
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||||
|
expect(takeReloadBreadcrumb()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("storage disabled");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(() =>
|
||||||
|
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
||||||
|
// across several deploys in one tab (each > window apart) while still stopping an
|
||||||
|
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
||||||
|
// < window). Moved here from the chunk-load boundary now that it is the shared
|
||||||
|
// guard both paths route through.
|
||||||
|
describe("shouldAutoReload", () => {
|
||||||
|
const WINDOW = RELOAD_WINDOW_MS;
|
||||||
|
|
||||||
|
it("allows a reload when we have never auto-reloaded", () => {
|
||||||
|
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Shared, window-based auto-reload budget.
|
||||||
|
//
|
||||||
|
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||||
|
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||||
|
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||||
|
// they share ONE window-scoped reload budget: at most a single automatic
|
||||||
|
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
||||||
|
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
||||||
|
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
||||||
|
// chunk still degrades to a manual banner/UI after the first reload instead of
|
||||||
|
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
||||||
|
// manual UI — no unguarded reload.
|
||||||
|
|
||||||
|
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
||||||
|
// (shared by both paths).
|
||||||
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
|
|
||||||
|
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||||
|
// by a single reload, so anything inside the window is treated as a reload loop
|
||||||
|
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
||||||
|
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
||||||
|
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
||||||
|
* than the window. Anything inside the window is suppressed to break an infinite
|
||||||
|
* reload loop.
|
||||||
|
*/
|
||||||
|
export function shouldAutoReload(
|
||||||
|
now: number,
|
||||||
|
lastReloadAt: number | null,
|
||||||
|
windowMs: number,
|
||||||
|
): boolean {
|
||||||
|
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||||
|
return now - lastReloadAt > windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has an automatic reload already happened within the current window (so the
|
||||||
|
* shared budget is spent right now)? Both paths check this before reloading; a
|
||||||
|
* `true` return means fall through to the manual banner/UI instead of reloading.
|
||||||
|
*
|
||||||
|
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||||
|
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||||
|
* tab the user can reload manually. Note a window (not a permanent flag): once
|
||||||
|
* the window elapses a later deploy's mismatch is allowed to reload again.
|
||||||
|
*/
|
||||||
|
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||||
|
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||||
|
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp the shared window as consumed now — record that an automatic reload is
|
||||||
|
* being performed within the current RELOAD_WINDOW_MS window.
|
||||||
|
*
|
||||||
|
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||||
|
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
||||||
|
* the reload could loop.
|
||||||
|
*/
|
||||||
|
export function markAutoReloaded(now: number = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||||
|
// window.location.reload() (which clears the console) and read back on the next
|
||||||
|
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||||
|
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||||
|
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||||
|
// survives a same-tab reload, unlike the console.
|
||||||
|
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||||
|
|
||||||
|
export type ReloadBreadcrumb = {
|
||||||
|
path: "proactive" | "chunk-boundary";
|
||||||
|
serverVersion?: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||||
|
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||||
|
* block or alter the reload decision.
|
||||||
|
*/
|
||||||
|
export function recordReloadBreadcrumb(
|
||||||
|
entry: Omit<ReloadBreadcrumb, "at">,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
RELOAD_BREADCRUMB_KEY,
|
||||||
|
JSON.stringify({ ...entry, at: Date.now() }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort diagnostics only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||||
|
* load. Cleared on read so it surfaces exactly once per reload.
|
||||||
|
*/
|
||||||
|
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||||
|
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ const STATIC_ROUTES = new Set<string>([
|
|||||||
'/setup/register',
|
'/setup/register',
|
||||||
'/settings/account/profile',
|
'/settings/account/profile',
|
||||||
'/settings/account/preferences',
|
'/settings/account/preferences',
|
||||||
|
'/settings/account/api-keys',
|
||||||
'/settings/workspace',
|
'/settings/workspace',
|
||||||
'/settings/ai',
|
'/settings/ai',
|
||||||
'/settings/members',
|
'/settings/members',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
|
import ApiKeysManager from "@/features/api-key/components/api-keys-manager";
|
||||||
|
import { getAppName } from "@/lib/config.ts";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AccountApiKeys() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("API keys")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
<SettingsTitle title={t("API keys")} />
|
||||||
|
|
||||||
|
<ApiKeysManager />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
* Anchor the top toast containers to the very top edge of the viewport (a small
|
||||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
* 8px gap), above the header/search chrome, per product request. The toast
|
||||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
* (z-index 10000) therefore renders over the header/toolbar (both z-index 99)
|
||||||
|
* for the few seconds it is visible — intentional, since it is the top-most,
|
||||||
|
* in-the-line-of-sight surface.
|
||||||
*
|
*
|
||||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||||
* containers simultaneously (`position` only routes toasts into one via the
|
* containers simultaneously (`position` only routes toasts into one via the
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
* (the :where() contributes 0), regardless of stylesheet order.
|
* (the :where() contributes 0), regardless of stylesheet order.
|
||||||
*/
|
*/
|
||||||
.mantine-Notifications-root[data-position^='top'] {
|
.mantine-Notifications-root[data-position^='top'] {
|
||||||
top: 96px;
|
top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||||
|
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||||
|
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||||
|
// global are identical by construction — the single source of truth (no
|
||||||
|
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
let outDir = "dist";
|
||||||
|
return {
|
||||||
|
name: "emit-version-json",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
outDir = config.build.outDir;
|
||||||
|
},
|
||||||
|
writeBundle() {
|
||||||
|
const root = path.resolve(process.cwd(), outDir);
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "version.json"),
|
||||||
|
JSON.stringify({ version }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
const appVersion = resolveAppVersion(envPath);
|
||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(appVersion),
|
||||||
// Emit .br and .gz next to every built asset so the server can serve the
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
compression({
|
compression({
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ vi.stubGlobal("matchMedia", (query: string) => ({
|
|||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mantine's ScrollArea (used by Table.ScrollContainer, ScrollArea, etc.) reads
|
||||||
|
// `ResizeObserver` in a layout effect on mount, which jsdom does not implement.
|
||||||
|
// A no-op stub lets any test rendering those components mount cleanly.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build && pnpm --filter @docmost/mcp build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
|
|||||||
@@ -141,7 +141,57 @@ export function htmlToJson(html: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToText(tiptapJson: JSONContent) {
|
/**
|
||||||
|
* Deterministic text-serializer overrides for the `format:"text"` page read
|
||||||
|
* (#502). Non-text nodes render to a STABLE placeholder instead of their
|
||||||
|
* (structure-dependent) inner text, so a machine diff of two text reads is
|
||||||
|
* driven only by the page's actual prose — output stability across package
|
||||||
|
* versions IS the contract (pinned by a snapshot test). Returning a string from
|
||||||
|
* a `textSerializer` also stops `generateText` descending into the node, so a
|
||||||
|
* table renders as ONE token rather than its flattened cell text.
|
||||||
|
*
|
||||||
|
* Only nodes with no meaningful flat-text form are overridden; every other node
|
||||||
|
* (paragraph/heading/list/code/blockquote/callout/…) keeps its natural text so
|
||||||
|
* a config written as markdown reads back byte-identical.
|
||||||
|
*/
|
||||||
|
const TEXT_READ_SERIALIZERS: Record<string, (props: { node: any }) => string> =
|
||||||
|
{
|
||||||
|
// Image atom: no inner text -> a fixed placeholder.
|
||||||
|
image: () => '[image]',
|
||||||
|
// Table: `[table RxC]` where R = row count, C = the first row's cell count
|
||||||
|
// (a table's columns are uniform per the schema). Computed from the PM node,
|
||||||
|
// so it is independent of cell contents.
|
||||||
|
table: ({ node }) => {
|
||||||
|
const rows = node?.childCount ?? 0;
|
||||||
|
const cols = rows > 0 ? (node.child(0)?.childCount ?? 0) : 0;
|
||||||
|
return `[table ${rows}x${cols}]`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a ProseMirror/TipTap document to plain text.
|
||||||
|
*
|
||||||
|
* Default (no options): the long-standing search-index behavior — bare
|
||||||
|
* concatenated node text with `generateText`'s default `\n\n` block separator.
|
||||||
|
* This feeds the page `textContent` tsvector and MUST NOT change.
|
||||||
|
*
|
||||||
|
* `deterministic:true` (#502 `format:"text"` page read): a flat, machine-diffable
|
||||||
|
* rendering — one line per block (`\n` block separator; `hardBreak` already
|
||||||
|
* serializes to `\n`), inline marks/anchors/autoformat dropped, and non-text
|
||||||
|
* nodes replaced by the stable placeholders above (`[image]`, `[table RxC]`).
|
||||||
|
*/
|
||||||
|
export function jsonToText(
|
||||||
|
// `any` (like jsonToHtml/jsonToMarkdown) so a loosely-typed DB `page.content`
|
||||||
|
// (JsonValue) can be passed straight through, as the controller does.
|
||||||
|
tiptapJson: any,
|
||||||
|
options?: { deterministic?: boolean },
|
||||||
|
) {
|
||||||
|
if (options?.deterministic) {
|
||||||
|
return generateText(tiptapJson, tiptapExtensions, {
|
||||||
|
blockSeparator: '\n',
|
||||||
|
textSerializers: TEXT_READ_SERIALIZERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
return generateText(tiptapJson, tiptapExtensions);
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -736,6 +736,53 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
||||||
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls
|
||||||
|
// save_page_version on a blank page): the version tx short-circuits with
|
||||||
|
// nothing to pin. The handler MUST still broadcast a TERMINAL reply
|
||||||
|
// (version.skipped, reason:'empty') so the client resolves at once instead of
|
||||||
|
// waiting out its 20s ack timeout and misreporting a healthy server as
|
||||||
|
// unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal
|
||||||
|
// reply is sent → this reddens.
|
||||||
|
it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => {
|
||||||
|
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||||
|
const document = ydocFor(emptyDoc);
|
||||||
|
pageRepo.findById.mockResolvedValue({
|
||||||
|
...persistedHumanPage('IGNORED'),
|
||||||
|
content: emptyDoc,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitSave(document, 'agent');
|
||||||
|
|
||||||
|
// Nothing pinned.
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
||||||
|
// But a terminal reply WAS sent so the client never times out. The flush
|
||||||
|
// (onStoreDocument) emits its own `page.updated`; the version.skipped is the
|
||||||
|
// LAST broadcast (dropping the skip branch leaves page.updated last → reds).
|
||||||
|
const calls = (document as any).broadcastStateless.mock.calls;
|
||||||
|
const msg = JSON.parse(calls[calls.length - 1][0]);
|
||||||
|
expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// #370 F2 — the page row is gone (deleted / never persisted). Same rule: a
|
||||||
|
// terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the
|
||||||
|
// client surfaces a truthful "not found" immediately rather than a health
|
||||||
|
// timeout. onStoreDocument's own `!page` guard returns early without throwing,
|
||||||
|
// so the handler reaches the version tx and its `!page` skip branch.
|
||||||
|
it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => {
|
||||||
|
const document = ydocFor(doc('GONE'));
|
||||||
|
pageRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await emitSave(document, 'agent');
|
||||||
|
|
||||||
|
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||||
|
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
|
||||||
|
const msg = JSON.parse(
|
||||||
|
(document as any).broadcastStateless.mock.calls[0][0],
|
||||||
|
);
|
||||||
|
expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
|||||||
*/
|
*/
|
||||||
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #370 F2 — wire format of the server→client REPLY to a save-version signal, sent
|
||||||
|
* over the same stateless channel. `version.saved` means a version was created or
|
||||||
|
* promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two
|
||||||
|
* reachable no-op cases (an effectively-empty page, or the page row is gone) so the
|
||||||
|
* client resolves at once instead of waiting out its ack timeout and misreporting a
|
||||||
|
* healthy server as unreachable. EXACTLY ONE of these is broadcast per handled
|
||||||
|
* save. The MCP client duplicates these literals (it cannot import server code) —
|
||||||
|
* keep the two in sync (see packages/mcp/src/lib/collaboration.ts).
|
||||||
|
*/
|
||||||
|
export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved';
|
||||||
|
export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||||
@@ -643,6 +656,10 @@ export class PersistenceExtension implements Extension {
|
|||||||
let result:
|
let result:
|
||||||
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
// #370 F2 — set when there is nothing to version (empty page / page gone), so
|
||||||
|
// the tail broadcasts a terminal `version.skipped` instead of staying silent
|
||||||
|
// and forcing the client to time out. Mutually exclusive with `result`.
|
||||||
|
let skipped: 'empty' | 'page-not-found' | undefined;
|
||||||
|
|
||||||
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
||||||
// must be restored if the version row does not durably land. The inner
|
// must be restored if the version row does not durably land. The inner
|
||||||
@@ -668,11 +685,20 @@ export class PersistenceExtension implements Extension {
|
|||||||
includeContent: true,
|
includeContent: true,
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
if (!page) return;
|
if (!page) {
|
||||||
|
// The page row is gone (deleted/never persisted). Nothing to version —
|
||||||
|
// record it so the tail sends a terminal skip reply (#370 F2).
|
||||||
|
skipped = 'page-not-found';
|
||||||
|
return;
|
||||||
|
}
|
||||||
versionedPageId = page.id;
|
versionedPageId = page.id;
|
||||||
// Never version an effectively-empty page (mirrors the processor's
|
// Never version an effectively-empty page (mirrors the processor's
|
||||||
// first-history guard); there is nothing intentional to pin.
|
// first-history guard); there is nothing intentional to pin. Record the
|
||||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
// skip so the client gets a terminal reply rather than a timeout (#370 F2).
|
||||||
|
if (isEmptyParagraphDoc(page.content as any)) {
|
||||||
|
skipped = 'empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
page.id,
|
page.id,
|
||||||
@@ -747,15 +773,26 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
this.idleBurstStart.delete(documentName);
|
this.idleBurstStart.delete(documentName);
|
||||||
|
|
||||||
|
// EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote
|
||||||
|
// broadcasts `version.saved`; the two no-op early returns (empty page / page
|
||||||
|
// gone) broadcast `version.skipped` so the client never waits out its ack
|
||||||
|
// timeout. A genuine failure threw above and rejected before reaching here.
|
||||||
if (result) {
|
if (result) {
|
||||||
document.broadcastStateless(
|
document.broadcastStateless(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'version.saved',
|
type: VERSION_SAVED_MESSAGE_TYPE,
|
||||||
historyId: result.historyId,
|
historyId: result.historyId,
|
||||||
kind: result.kind,
|
kind: result.kind,
|
||||||
alreadySaved: result.alreadySaved,
|
alreadySaved: result.alreadySaved,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (skipped) {
|
||||||
|
document.broadcastStateless(
|
||||||
|
JSON.stringify({
|
||||||
|
type: VERSION_SKIPPED_MESSAGE_TYPE,
|
||||||
|
reason: skipped,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { jsonToText } from './collaboration.util';
|
||||||
|
|
||||||
|
// #502 READ contract: `jsonToText(json, { deterministic: true })` — the flat,
|
||||||
|
// machine-diffable text rendering behind getPage `format:"text"`. Block-per-line
|
||||||
|
// (`\n` separator), inline marks/anchors dropped, hardBreak -> `\n`, and non-text
|
||||||
|
// nodes replaced by STABLE placeholders (`[image]`, `[table RxC]`). Output
|
||||||
|
// stability across package versions IS the contract, so it is pinned by a
|
||||||
|
// snapshot below. The DEFAULT (no options) path is the search-index serializer
|
||||||
|
// and MUST be unchanged — asserted separately.
|
||||||
|
|
||||||
|
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||||
|
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||||
|
const text = (t: string, marks?: any[]) =>
|
||||||
|
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||||
|
|
||||||
|
describe('jsonToText — default (search index) behavior is unchanged', () => {
|
||||||
|
it('uses the `\\n\\n` block separator and drops non-text nodes to empty', () => {
|
||||||
|
const d = doc(para(text('alpha')), para(text('beta')));
|
||||||
|
expect(jsonToText(d)).toBe('alpha\n\nbeta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an image contributes no text in the default (tsvector) mode', () => {
|
||||||
|
const d = doc(para(text('cap')), { type: 'image', attrs: { src: 's' } });
|
||||||
|
// No `[image]` placeholder leaks into the search index.
|
||||||
|
expect(jsonToText(d)).not.toContain('[image]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('jsonToText — deterministic:true (getPage format:"text")', () => {
|
||||||
|
it('renders one line per block with `\\n` separators, marks dropped', () => {
|
||||||
|
const d = doc(
|
||||||
|
{ type: 'heading', attrs: { level: 2 }, content: [text('Title')] },
|
||||||
|
para(
|
||||||
|
text('hello ', [{ type: 'bold' }]),
|
||||||
|
text('world', [{ type: 'italic' }]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('Title\nhello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a hardBreak renders as a newline', () => {
|
||||||
|
const d = doc(para(text('a'), { type: 'hardBreak' }, text('b')));
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('a\nb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an image node -> the stable `[image]` placeholder', () => {
|
||||||
|
const d = doc(para(text('before')), { type: 'image', attrs: { src: 's' } });
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe('before\n[image]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a table -> `[table RxC]` (rows x columns) and its cell text is NOT flattened in', () => {
|
||||||
|
const cell = (t: string) => ({
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [para(text(t))],
|
||||||
|
});
|
||||||
|
const row = (...cells: any[]) => ({ type: 'tableRow', content: cells });
|
||||||
|
const table = {
|
||||||
|
type: 'table',
|
||||||
|
content: [
|
||||||
|
row(cell('CELLONE'), cell('CELLTWO'), cell('CELLTHREE')),
|
||||||
|
row(cell('CELLFOUR'), cell('CELLFIVE'), cell('CELLSIX')),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = doc(para(text('grid:')), table);
|
||||||
|
const out = jsonToText(d, { deterministic: true });
|
||||||
|
expect(out).toBe('grid:\n[table 2x3]');
|
||||||
|
expect(out).not.toContain('CELL'); // cell text is not flattened into the read
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SNAPSHOT: a mixed document renders to a stable, deterministic string', () => {
|
||||||
|
const cell = (t: string) => ({
|
||||||
|
type: 'tableCell',
|
||||||
|
content: [para(text(t))],
|
||||||
|
});
|
||||||
|
const d = doc(
|
||||||
|
{ type: 'heading', attrs: { level: 1 }, content: [text('Config')] },
|
||||||
|
para(text('key = ', [{ type: 'bold' }]), text('value')),
|
||||||
|
{
|
||||||
|
type: 'bulletList',
|
||||||
|
content: [
|
||||||
|
{ type: 'listItem', content: [para(text('one'))] },
|
||||||
|
{ type: 'listItem', content: [para(text('two'))] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'image', attrs: { src: 's' } },
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
content: [{ type: 'tableRow', content: [cell('x'), cell('y')] }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toMatchInlineSnapshot(`
|
||||||
|
"Config
|
||||||
|
key = value
|
||||||
|
|
||||||
|
|
||||||
|
one
|
||||||
|
|
||||||
|
two
|
||||||
|
[image]
|
||||||
|
[table 1x2]"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SCENARIO: a config written as a code block reads back byte-identical (diff empty)', () => {
|
||||||
|
const config =
|
||||||
|
'export TICKET_LIFETIME=$2592000\nservers:\n - www.internal.host';
|
||||||
|
const d = doc({
|
||||||
|
type: 'codeBlock',
|
||||||
|
attrs: { language: 'yaml' },
|
||||||
|
content: [text(config)],
|
||||||
|
});
|
||||||
|
// The code block is one block; its text (dollars, bare domain, newlines) is
|
||||||
|
// preserved verbatim, so a read-as-text of a stored config diffs empty.
|
||||||
|
expect(jsonToText(d, { deterministic: true })).toBe(config);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,9 @@ export const AuditEvent = {
|
|||||||
API_KEY_CREATED: 'api_key.created',
|
API_KEY_CREATED: 'api_key.created',
|
||||||
API_KEY_UPDATED: 'api_key.updated',
|
API_KEY_UPDATED: 'api_key.updated',
|
||||||
API_KEY_DELETED: 'api_key.deleted',
|
API_KEY_DELETED: 'api_key.deleted',
|
||||||
|
// A copyable key was re-minted and returned to its owner under a password
|
||||||
|
// step-up (see ApiKeyController.reveal). Durable via DatabaseAuditService.
|
||||||
|
API_KEY_REVEALED: 'api_key.revealed',
|
||||||
|
|
||||||
// SCIM Tokens
|
// SCIM Tokens
|
||||||
SCIM_TOKEN_CREATED: 'scim_token.created',
|
SCIM_TOKEN_CREATED: 'scim_token.created',
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { readClientBuildVersion } from './client-version';
|
||||||
|
|
||||||
|
describe('readClientBuildVersion', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeVersionJson = (content: string) =>
|
||||||
|
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||||
|
|
||||||
|
it('returns the version from a valid version.json', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace in the version', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when version.json is missing', () => {
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" on malformed JSON', () => {
|
||||||
|
writeVersionJson('{ not json');
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is absent', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the version field is not a string', () => {
|
||||||
|
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||||
|
expect(readClientBuildVersion(dir)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "" when the path does not exist at all', () => {
|
||||||
|
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the absolute path to the built client bundle directory
|
||||||
|
* (`apps/client/dist`) shipped into the runtime image.
|
||||||
|
*
|
||||||
|
* The `../` depth is anchored on THIS module's compiled location
|
||||||
|
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||||
|
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||||
|
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||||
|
* module at a different depth would silently resolve to the wrong directory.
|
||||||
|
*/
|
||||||
|
export function resolveClientDistPath(): string {
|
||||||
|
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the build version the client bundle was compiled with, from
|
||||||
|
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||||
|
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||||
|
*
|
||||||
|
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||||
|
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||||
|
* the whole version-coherence feature stays silently inert — existing deploys
|
||||||
|
* without the file keep working unchanged.
|
||||||
|
*/
|
||||||
|
export function readClientBuildVersion(clientDistPath: string): string {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||||
|
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||||
|
return typeof version === 'string' ? version.trim() : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
|||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './security-headers';
|
export * from './security-headers';
|
||||||
|
export * from './client-version';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
|
import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
@@ -43,6 +44,8 @@ import {
|
|||||||
AiChatRunHooks,
|
AiChatRunHooks,
|
||||||
AiChatService,
|
AiChatService,
|
||||||
AiChatStreamBody,
|
AiChatStreamBody,
|
||||||
|
rowHasInlineParts,
|
||||||
|
hydrateAssistantParts,
|
||||||
} from './ai-chat.service';
|
} from './ai-chat.service';
|
||||||
import { AiChatRunService } from './ai-chat-run.service';
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
@@ -129,8 +132,39 @@ export class AiChatController {
|
|||||||
// production. Only touched on the resumable-stream (flag-on) path.
|
// production. Only touched on the resumable-stream (flag-on) path.
|
||||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||||
private readonly environment?: EnvironmentService,
|
private readonly environment?: EnvironmentService,
|
||||||
|
// #492: reconstruct a #492 mid-run record's parts from the steps table before
|
||||||
|
// returning rows to the client / export. OPTIONAL so positional controller
|
||||||
|
// specs compile unchanged; when absent, hydration is skipped (old-era rows
|
||||||
|
// already carry inline parts, so nothing to reconstruct).
|
||||||
|
private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct parts for any assistant rows that don't carry them INLINE — a
|
||||||
|
* #492 mid-run record whose per-step parts live in `ai_chat_run_steps` (the
|
||||||
|
* append-persist backend). Every FINISHED row (old-era + #492) and every old-era
|
||||||
|
* streaming snapshot already has inline `metadata.parts`, so the common path
|
||||||
|
* fetches NOTHING and returns the rows untouched; only an actively-streaming
|
||||||
|
* new-style row triggers the batch step fetch. Consumers (seed/poll/export) read
|
||||||
|
* `metadata.parts` off the returned rows exactly as before — the era switch is
|
||||||
|
* invisible to them (reconstructRunParts contract).
|
||||||
|
*/
|
||||||
|
private async withReconstructedParts(
|
||||||
|
rows: AiChatMessage[],
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatMessage[]> {
|
||||||
|
if (!this.aiChatRunStepRepo) return rows;
|
||||||
|
const needy = rows.filter(
|
||||||
|
(r) => r.role === 'assistant' && !rowHasInlineParts(r),
|
||||||
|
);
|
||||||
|
if (needy.length === 0) return rows;
|
||||||
|
const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
|
||||||
|
needy.map((r) => r.id),
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
return hydrateAssistantParts(rows, stepsByMessage);
|
||||||
|
}
|
||||||
|
|
||||||
/** List the requesting user's chats in this workspace (paginated). */
|
/** List the requesting user's chats in this workspace (paginated). */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('chats')
|
@Post('chats')
|
||||||
@@ -184,11 +218,17 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
return this.aiChatMessageRepo.findByChat(
|
const page = await this.aiChatMessageRepo.findByChat(
|
||||||
dto.chatId,
|
dto.chatId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
|
// #492: reconstruct parts for any active new-style row so the client seed sees
|
||||||
|
// `metadata.parts` unchanged (a no-op for the finished rows that fill a page).
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: await this.withReconstructedParts(page.items, workspace.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +265,10 @@ export class AiChatController {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
rows,
|
// #492: the delta of an actively-streaming new-style row carries its parts
|
||||||
|
// reconstructed from the steps table, so the degraded poll shows persisted
|
||||||
|
// progress exactly as the pre-#492 full-row snapshot did.
|
||||||
|
rows: await this.withReconstructedParts(rows, workspace.id),
|
||||||
cursor,
|
cursor,
|
||||||
run: run ? { id: run.id, status: run.status } : null,
|
run: run ? { id: run.id, status: run.status } : null,
|
||||||
};
|
};
|
||||||
@@ -247,8 +290,10 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<{ markdown: string }> {
|
): Promise<{ markdown: string }> {
|
||||||
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
const rows = await this.aiChatMessageRepo.findAllByChat(
|
const rows = await this.withReconstructedParts(
|
||||||
dto.chatId,
|
await this.aiChatMessageRepo.findAllByChat(dto.chatId, workspace.id),
|
||||||
|
// #492: an interrupted-but-still-active turn exports its persisted steps
|
||||||
|
// (reconstructed from the steps table) just like the pre-#492 full row did.
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
const markdown = buildChatMarkdown({
|
const markdown = buildChatMarkdown({
|
||||||
@@ -288,7 +333,13 @@ export class AiChatController {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
return { run, message: message ?? null };
|
// #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
|
||||||
|
// from the steps table (the row itself carries only the step marker mid-run);
|
||||||
|
// a finished run's row already has inline parts, so this is a no-op.
|
||||||
|
const [hydrated] = message
|
||||||
|
? await this.withReconstructedParts([message], workspace.id)
|
||||||
|
: [undefined];
|
||||||
|
return { run, message: hydrated ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -370,10 +370,12 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #490 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is classified,
|
// #490/#520 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is
|
||||||
// records a distinguishable cause, and stamps metadata.replayOverflow so the NEXT
|
// classified, records a distinguishable cause, and stamps the consecutive-overflow
|
||||||
// turn's budgeter trims aggressively (the recovery that un-bricks the chat).
|
// COUNTER (metadata.replayOverflowCount) so the NEXT turn's budgeter trims with
|
||||||
it('#490: a context-overflow 400 stamps replayOverflow on the finalized row', async () => {
|
// escalating aggression (the recovery that un-bricks the chat). This is a fresh
|
||||||
|
// chat (empty history -> prior streak 0), so the first overflow stamps count 1.
|
||||||
|
it('#490/#520: a context-overflow 400 stamps replayOverflowCount=1 on the finalized row', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(Logger.prototype, 'error')
|
.spyOn(Logger.prototype, 'error')
|
||||||
.mockImplementation(() => undefined as never);
|
.mockImplementation(() => undefined as never);
|
||||||
@@ -397,11 +399,14 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(patch.status).toBe('error');
|
expect(patch.status).toBe('error');
|
||||||
expect(patch.metadata.replayOverflow).toBe(true);
|
// First overflow on a fresh chat -> k = prior(0) + 1 = 1.
|
||||||
|
expect(patch.metadata.replayOverflowCount).toBe(1);
|
||||||
|
// The legacy boolean is no longer written (the counter supersedes it).
|
||||||
|
expect('replayOverflow' in patch.metadata).toBe(false);
|
||||||
expect(patch.metadata.error).toContain('контекстное окно');
|
expect(patch.metadata.error).toContain('контекстное окно');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#490: a non-overflow error does NOT stamp replayOverflow', async () => {
|
it('#490/#520: a non-overflow error does NOT stamp the overflow counter', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(Logger.prototype, 'error')
|
.spyOn(Logger.prototype, 'error')
|
||||||
.mockImplementation(() => undefined as never);
|
.mockImplementation(() => undefined as never);
|
||||||
@@ -412,6 +417,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
status: string;
|
status: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
expect('replayOverflowCount' in patch.metadata).toBe(false);
|
||||||
expect('replayOverflow' in patch.metadata).toBe(false);
|
expect('replayOverflow' in patch.metadata).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ import {
|
|||||||
STEP_LIMIT_NO_ANSWER_MARKER,
|
STEP_LIMIT_NO_ANSWER_MARKER,
|
||||||
OUTPUT_DEGENERATION_ERROR,
|
OUTPUT_DEGENERATION_ERROR,
|
||||||
lastAssistantContextTokens,
|
lastAssistantContextTokens,
|
||||||
lastAssistantReplayOverflow,
|
lastAssistantReplayOverflowCount,
|
||||||
seedActivatedTools,
|
seedActivatedTools,
|
||||||
} from './ai-chat.service';
|
} from './ai-chat.service';
|
||||||
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
import { resolveEffectiveReplayThreshold } from './history-budget';
|
import {
|
||||||
|
resolveEffectiveReplayThreshold,
|
||||||
|
REPLAY_MIN_FLOOR_TOKENS,
|
||||||
|
} from './history-budget';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
||||||
@@ -554,49 +557,129 @@ describe('seedActivatedTools', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('lastAssistantReplayOverflow', () => {
|
describe('lastAssistantReplayOverflowCount', () => {
|
||||||
const row = (
|
const row = (
|
||||||
role: string,
|
role: string,
|
||||||
metadata: Record<string, unknown> | null,
|
metadata: Record<string, unknown> | null,
|
||||||
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
||||||
|
|
||||||
it('is true only when the LAST assistant turn overflowed', () => {
|
it('reads the consecutive-overflow count from the LAST assistant turn', () => {
|
||||||
expect(
|
expect(
|
||||||
lastAssistantReplayOverflow([
|
lastAssistantReplayOverflowCount([
|
||||||
row('assistant', { replayOverflow: true }),
|
row('assistant', { replayOverflowCount: 3 }),
|
||||||
row('user', null),
|
row('user', null),
|
||||||
]),
|
]),
|
||||||
).toBe(true);
|
).toBe(3);
|
||||||
// A recovered (later, non-overflow) assistant turn clears it.
|
// A recovered (later, non-overflow) assistant turn resets it to 0 — the read
|
||||||
|
// stops at the most recent assistant row, which carries no count.
|
||||||
expect(
|
expect(
|
||||||
lastAssistantReplayOverflow([
|
lastAssistantReplayOverflowCount([
|
||||||
row('assistant', { replayOverflow: true }),
|
row('assistant', { replayOverflowCount: 3 }),
|
||||||
row('user', null),
|
row('user', null),
|
||||||
row('assistant', { contextTokens: 5 }),
|
row('assistant', { contextTokens: 5 }),
|
||||||
]),
|
]),
|
||||||
).toBe(false);
|
).toBe(0);
|
||||||
expect(lastAssistantReplayOverflow([])).toBe(false);
|
expect(lastAssistantReplayOverflowCount([])).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #490 reactive recovery: a prior turn stamped `replayOverflow` must make the
|
// BACK-COMPAT (#520): an in-flight row written by the pre-#520 boolean stamp
|
||||||
// NEXT turn's effective budget the AGGRESSIVE 0.5x cut — that harder trim is
|
// (`replayOverflow: true`, no count) reads as k=1 — the old single 0.5× behavior —
|
||||||
// what un-bricks a chat that just 400'd on the context window. This exercises
|
// so a chat mid-recovery across the deploy does not regress.
|
||||||
// the exact wiring the service uses: read the stamp, then scale the threshold.
|
it('#520 back-compat: a legacy boolean replayOverflow reads as k=1', () => {
|
||||||
it('#490: a prior replayOverflow drives the next turn to the 0.5x aggressive budget', () => {
|
expect(
|
||||||
|
lastAssistantReplayOverflowCount([
|
||||||
|
row('assistant', { replayOverflow: true }),
|
||||||
|
row('user', null),
|
||||||
|
]),
|
||||||
|
).toBe(1);
|
||||||
|
// A legacy row with the flag absent/false is k=0.
|
||||||
|
expect(
|
||||||
|
lastAssistantReplayOverflowCount([row('assistant', { contextTokens: 5 })]),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A corrupt/negative persisted count never yields a negative k.
|
||||||
|
it('clamps a corrupt negative count to 0', () => {
|
||||||
|
expect(
|
||||||
|
lastAssistantReplayOverflowCount([
|
||||||
|
row('assistant', { replayOverflowCount: -4 }),
|
||||||
|
]),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490/#520 reactive recovery: the prior consecutive-overflow count `k` drives
|
||||||
|
// the next turn's effective budget to an ESCALATING cut (0.5**k) — each further
|
||||||
|
// consecutive 400 tightens it, which is what un-bricks a chat that keeps
|
||||||
|
// overflowing. This exercises the exact wiring the service uses: read the count,
|
||||||
|
// then scale the threshold.
|
||||||
|
it('#490/#520: the prior count drives the next turn to the escalating aggressive budget', () => {
|
||||||
const history = [
|
const history = [
|
||||||
row('assistant', { replayOverflow: true }),
|
row('assistant', { replayOverflowCount: 1 }),
|
||||||
row('user', null),
|
row('user', null),
|
||||||
];
|
];
|
||||||
const priorOverflowed = lastAssistantReplayOverflow(history);
|
const k = lastAssistantReplayOverflowCount(history);
|
||||||
expect(priorOverflowed).toBe(true);
|
expect(k).toBe(1);
|
||||||
// Base budget 100k -> aggressive recovery halves it to 50k this turn.
|
// Base budget 100k -> first-overflow recovery halves it to 50k this turn.
|
||||||
expect(resolveEffectiveReplayThreshold(100_000, priorOverflowed)).toBe(50_000);
|
expect(resolveEffectiveReplayThreshold(100_000, k)).toBe(50_000);
|
||||||
|
// A second consecutive overflow (k=2) quarters it.
|
||||||
|
expect(resolveEffectiveReplayThreshold(100_000, 2)).toBe(25_000);
|
||||||
// Odd base floors, not rounds.
|
// Odd base floors, not rounds.
|
||||||
expect(resolveEffectiveReplayThreshold(99_999, true)).toBe(49_999);
|
expect(resolveEffectiveReplayThreshold(99_999, 1)).toBe(49_999);
|
||||||
// No prior overflow -> the base budget is used verbatim (no aggressive cut).
|
// No prior overflow (k=0) -> the base budget is used verbatim (no cut).
|
||||||
expect(resolveEffectiveReplayThreshold(100_000, false)).toBe(100_000);
|
expect(resolveEffectiveReplayThreshold(100_000, 0)).toBe(100_000);
|
||||||
// An explicit off-switch (null) is never overridden, even on recovery.
|
// An explicit off-switch (null) is never overridden, even on recovery.
|
||||||
expect(resolveEffectiveReplayThreshold(null, true)).toBeNull();
|
expect(resolveEffectiveReplayThreshold(null, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// #520 escalation table + convergence: the cut deepens each consecutive overflow
|
||||||
|
// and is CLAMPED at the floor so it converges (un-bricks even against a small
|
||||||
|
// real window), instead of the old fixed single 0.5× that stuck at 50k forever.
|
||||||
|
it('#520: escalates and converges to the floor, un-bricking a small real window', () => {
|
||||||
|
const base = 100_000;
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, 0)).toBe(base);
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, 1)).toBe(50_000);
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, 2)).toBe(25_000);
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, 3)).toBe(12_500);
|
||||||
|
|
||||||
|
// Residual-brick regression (#520): with the flat-default base (100k) and a real
|
||||||
|
// model window of ~40k, the OLD fixed 0.5× stuck at 50k forever (> 40k -> 400s
|
||||||
|
// again, never recovers). The escalating cut drops BELOW 40k after enough
|
||||||
|
// consecutive overflows -> the history finally fits -> the chat un-bricks.
|
||||||
|
const realWindow = 40_000;
|
||||||
|
// k=1 (50k) still exceeds the window — the old behavior's terminal state.
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, 1)).toBeGreaterThan(realWindow);
|
||||||
|
// But escalation converges under the window within a couple more turns.
|
||||||
|
const converged = [2, 3, 4, 5].some(
|
||||||
|
(k) => (resolveEffectiveReplayThreshold(base, k) as number) < realWindow,
|
||||||
|
);
|
||||||
|
expect(converged).toBe(true);
|
||||||
|
|
||||||
|
// Convergence is bounded BELOW by the floor: a large k never trims below it.
|
||||||
|
for (const k of [4, 8, 20, 100]) {
|
||||||
|
expect(resolveEffectiveReplayThreshold(base, k)).toBe(REPLAY_MIN_FLOOR_TOKENS);
|
||||||
|
expect(
|
||||||
|
resolveEffectiveReplayThreshold(base, k) as number,
|
||||||
|
).toBeGreaterThanOrEqual(REPLAY_MIN_FLOOR_TOKENS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The floor never RAISES a legitimately small configured budget above itself —
|
||||||
|
// that would re-overflow the very window it was configured for. Under #520 Option B
|
||||||
|
// recovery MAY cut it BELOW itself (down to floor(0.5×base) = the old 0.5× cut).
|
||||||
|
it('#520: never inflates a small configured budget above itself (may cut below)', () => {
|
||||||
|
const small = 5_000; // below the floor
|
||||||
|
const floor = Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(small * 0.5)); // 2500
|
||||||
|
expect(resolveEffectiveReplayThreshold(small, 0)).toBe(small);
|
||||||
|
// Even under escalation the effective threshold never exceeds the base, and never
|
||||||
|
// drops below the absolute floor.
|
||||||
|
for (const k of [1, 2, 3, 10]) {
|
||||||
|
const t = resolveEffectiveReplayThreshold(small, k) as number;
|
||||||
|
expect(t).toBeLessThanOrEqual(small);
|
||||||
|
expect(t).toBeGreaterThanOrEqual(floor);
|
||||||
|
}
|
||||||
|
// Option B: on overflow it DOES cut below the configured budget (old floor==budget
|
||||||
|
// behavior would keep this at `small`).
|
||||||
|
expect(resolveEffectiveReplayThreshold(small, 1)).toBe(floor);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -930,21 +1013,50 @@ describe('flushAssistant', () => {
|
|||||||
expect(flushed.metadata.error).toBe('boom');
|
expect(flushed.metadata.error).toBe('boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #490 observability: the replay budgeter's decision is stamped on the turn.
|
// #490/#520 observability: the replay budgeter's decision is stamped on the turn,
|
||||||
it('records replayTrimmedToTokens + replayOverflow when provided', () => {
|
// now including the consecutive-overflow COUNTER (#520) the next turn escalates on.
|
||||||
|
it('records replayTrimmedToTokens + replayOverflowCount when provided', () => {
|
||||||
const f = flushAssistant([], '', 'error', {
|
const f = flushAssistant([], '', 'error', {
|
||||||
error: 'ctx',
|
error: 'ctx',
|
||||||
replayTrimmedToTokens: 42_000,
|
replayTrimmedToTokens: 42_000,
|
||||||
replayOverflow: true,
|
replayOverflowCount: 2,
|
||||||
});
|
});
|
||||||
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
|
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
|
||||||
expect(f.metadata.replayOverflow).toBe(true);
|
expect(f.metadata.replayOverflowCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the replay metadata when not provided', () => {
|
it('omits the replay metadata when not provided', () => {
|
||||||
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
|
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
|
||||||
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
|
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
|
||||||
expect('replayOverflow' in f.metadata).toBe(false);
|
expect('replayOverflowCount' in f.metadata).toBe(false);
|
||||||
|
expect('replayBelowConfiguredBudget' in f.metadata).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #520 Option B observability: the turn is marked when recovery replayed BELOW the
|
||||||
|
// admin-configured budget, so the UI/telemetry can surface "config too large".
|
||||||
|
it('stamps replayBelowConfiguredBudget when recovery cut below the configured budget', () => {
|
||||||
|
const f = flushAssistant([], '', 'error', {
|
||||||
|
error: 'ctx',
|
||||||
|
replayOverflowCount: 2,
|
||||||
|
replayBelowConfiguredBudget: true,
|
||||||
|
});
|
||||||
|
// MUTATION SENTINEL: dropping the metadata stamp in flushAssistant reddens this.
|
||||||
|
expect(f.metadata.replayBelowConfiguredBudget).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits replayBelowConfiguredBudget on a normal in-budget replay', () => {
|
||||||
|
// false/omitted must NOT leave the flag on the turn (only the below-budget case).
|
||||||
|
const off = flushAssistant([], '', 'completed', {
|
||||||
|
replayBelowConfiguredBudget: false,
|
||||||
|
});
|
||||||
|
expect('replayBelowConfiguredBudget' in off.metadata).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A clean finalize (no overflow -> count 0/omitted) leaves NO counter, which the
|
||||||
|
// next turn reads as k=0 — the reset that ends a recovery streak.
|
||||||
|
it('omits replayOverflowCount for a zero/absent count (reset semantics)', () => {
|
||||||
|
const zero = flushAssistant([], '', 'completed', { replayOverflowCount: 0 });
|
||||||
|
expect('replayOverflowCount' in zero.metadata).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #274 observability: the page-change diff the agent saw this turn is persisted
|
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user