Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 670a627129 | |||
| 8c83e2d201 | |||
| 7062bae516 | |||
| 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 | |||
| d36021a111 | |||
| 1f2999e5ad | |||
| c674db2b2f | |||
| 44cdb5a4c3 | |||
| 03af65dd06 | |||
| dead5fa8a8 | |||
| 5b17503df7 | |||
| 46bb55dbd1 | |||
| 8b34a428f4 | |||
| e236782260 | |||
| 12ed3a0332 | |||
| b3cc6a7ff8 | |||
| c42cb42413 | |||
| ae3dfd8de6 | |||
| 41030b2c78 | |||
| 515a1aaf1d | |||
| 03eafa6c68 | |||
| a42f1ead48 | |||
| b7a3ec227d | |||
| 74387bb047 | |||
| 6ec2981743 | |||
| dd1fe90515 | |||
| 846341d7d4 | |||
| 32e10ca6d3 | |||
| 74d212cfd3 | |||
| c96fafc4ad | |||
| 9a6389133b | |||
| 64566e9327 | |||
| 10a4326fbf | |||
| 68409a8ae9 | |||
| fc624f5a4b | |||
| 0b8497e496 | |||
| 433252bdb1 | |||
| be70bd2e8e | |||
| 2f8c5d9a98 | |||
| 6f81067f4d | |||
| 55e8f61b3c | |||
| 69e04349a0 | |||
| ab133fa0d0 | |||
| d42ca8dc57 | |||
| 25a31e6c0d | |||
| 0af7eb30c3 | |||
| 9a94c8df18 | |||
| 3550bfa411 | |||
| a0ecf21cb5 | |||
| d81781aa27 | |||
| daca5ce8d6 | |||
| daeeb1f3f2 | |||
| a919c79cb2 | |||
| 798a81abfe | |||
| 8abde99611 | |||
| d608311cae | |||
| 6dad309b51 | |||
| f34542c881 | |||
| b038d96708 | |||
| f5bbfdb2d4 | |||
| 875f6ba9c5 | |||
| 7741821ee3 | |||
| 48a074e0d2 | |||
| 17e3b3d882 | |||
| 801add63e8 | |||
| 7ad072ba2c | |||
| 216499d57b | |||
| b041944a23 | |||
| 401d62119c | |||
| 4f8563b5b5 | |||
| 5bd5995ef0 | |||
| 575125a5dc | |||
| bb5abf29a6 | |||
| 27d51303ba | |||
| 51260793c0 | |||
| c765483947 | |||
| 45ff922dd4 | |||
| e3ca2dc1d5 | |||
| f919ced8c9 | |||
| 4109b2ef7f | |||
| 6b27ff0652 | |||
| e133b86982 | |||
| 579c82617b | |||
| 173f35e473 | |||
| 696b96ac18 | |||
| a96ca8e26b | |||
| f84386f24a | |||
| c0c44fddb9 | |||
| 91e58e3c9f | |||
| 8d062728e5 | |||
| 0ddeaadeee | |||
| bc632f9d56 | |||
| d3a049d176 | |||
| f1cffc2d0f | |||
| bb9a6fd765 | |||
| 1c083fbd3d | |||
| 52763998d3 | |||
| d738780370 | |||
| daf728676f | |||
| bc433d12e6 | |||
| 1d8e3444f4 | |||
| c50f5b66bb | |||
| 51d44b6061 | |||
| 6247585b66 | |||
| 141ebb4864 | |||
| 045a0afaad | |||
| be433d40f0 | |||
| e56a05926d | |||
| eae7640f30 | |||
| 0099ba272d | |||
| 826bb491ca | |||
| 7be49c1280 | |||
| c7073b62d1 | |||
| 11b2c55485 | |||
| bfb6a52eea | |||
| 0503e8b4b1 | |||
| b97cad0ebe | |||
| 080dd82023 | |||
| 5adcd2f08b | |||
| 1e7bd1f9d2 | |||
| 803bbd40b4 | |||
| c7a38e274e | |||
| 791a707eb6 | |||
| 60205398bb | |||
| 1685b32333 | |||
| 4b78e336a2 | |||
| 77e20ecb41 | |||
| d533daa45f | |||
| ba58493909 | |||
| 947796adef | |||
| 38a09c5ca1 | |||
| 1b05224b27 | |||
| b50b32bf64 | |||
| 1d704d4ec5 | |||
| 2cb07ef7fb | |||
| 9ba97b6d2b | |||
| 0b9de2c25e | |||
| 123cba7de1 | |||
| 001eb1c923 | |||
| 002c931c6b | |||
| 307ad49324 | |||
| 1031ed1ae8 | |||
| 3b6634c6be | |||
| fdc37de3e8 | |||
| 4a750c1e7f | |||
| ea7c4d7cd2 | |||
| 255024fbe2 | |||
| b934fb2292 | |||
| 14da295185 | |||
| de8f9c804c | |||
| 8ebdfe156f | |||
| 31176d5f93 | |||
| 5f0c49d47c | |||
| b2e5b51420 | |||
| 26d9a70a1c | |||
| e1ebd79f30 | |||
| fc83ea28ab | |||
| ee15278c8f | |||
| 09ab92eccf |
+101
-3
@@ -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
|
||||||
@@ -225,11 +261,26 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
|
|
||||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||||
# ~1 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
# ~1 min instead of 15. It cuts a legitimately long but byte-silent single tool
|
||||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
# call (a slow crawl that emits nothing until done) on the HTTP (streamable)
|
||||||
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
|
# transport, which opens a fresh request per call. The SSE transport — one
|
||||||
|
# long-lived body across many calls — is NO LONGER governed by this timeout
|
||||||
|
# (as of #489): its idle-BETWEEN-calls window has its own, raised bodyTimeout,
|
||||||
|
# AI_MCP_SSE_BODY_TIMEOUT_MS below. Default 60000 (1 min).
|
||||||
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
# bodyTimeout (ms) for the EXTERNAL-MCP SSE transport ONLY (#489). The SSE
|
||||||
|
# transport holds ONE response body open across many tool calls, so undici's
|
||||||
|
# bodyTimeout (time between body bytes) counts the LEGITIMATE silence BETWEEN the
|
||||||
|
# model's tool calls, not just a hung single call. At the tight 1-min silence
|
||||||
|
# timeout above, a normal >1-min gap between calls would break the SSE socket and
|
||||||
|
# the cache would serve a dead client until TTL — so the SSE transport gets its
|
||||||
|
# OWN, RAISED bodyTimeout. A single stuck call is still bounded by the per-call
|
||||||
|
# cap (AI_MCP_CALL_TIMEOUT_MS), and a socket that does break is healed by the
|
||||||
|
# in-run transport-error retry. The HTTP (streamable) transport keeps the tight
|
||||||
|
# timeout. Default 600000 (10 min).
|
||||||
|
# AI_MCP_SSE_BODY_TIMEOUT_MS=600000
|
||||||
|
|
||||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||||
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||||
# but never returns a result — which the silence timeout above never breaks.
|
# but never returns a result — which the silence timeout above never breaks.
|
||||||
@@ -287,6 +338,39 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||||
# registry is process-local).
|
# registry is process-local).
|
||||||
# AI_CHAT_RESUMABLE_STREAM=false
|
# AI_CHAT_RESUMABLE_STREAM=false
|
||||||
|
#
|
||||||
|
# Per-run replay ring cap (#491), in BYTES, for the resumable-stream registry
|
||||||
|
# above. The registry buffers the run's recent SSE tail so a reopened tab can
|
||||||
|
# attach and continue from the step it already persisted; the ring is bounded and
|
||||||
|
# rotates on every confirmed step-persist. This caps the un-persisted tail between
|
||||||
|
# rotations — an overflow evicts the oldest frames and a late attach falls back to
|
||||||
|
# 204 -> degraded poll, so correctness never depends on the size. Default 4194304
|
||||||
|
# (4MB); a 0/invalid value falls back to the default. The per-subscriber backpressure
|
||||||
|
# cap is derived as 2x this value. Only meaningful with AI_CHAT_RESUMABLE_STREAM on.
|
||||||
|
# AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES=4194304
|
||||||
|
|
||||||
|
# --- Run lifecycle tunables (#487) ---
|
||||||
|
# These govern the universal run machinery (every turn is now a first-class run,
|
||||||
|
# both modes) and rarely need changing.
|
||||||
|
#
|
||||||
|
# How long a server-side SUPERSEDE ("interrupt and send now") waits for the target
|
||||||
|
# run to settle after issuing Stop before it degrades to a 409 SUPERSEDE_TIMEOUT
|
||||||
|
# (nothing sent, the composer keeps the user's text). 10s is generous under a
|
||||||
|
# healthy DB; do NOT raise it to paper over a slow DB — a SUPERSEDE_TIMEOUT is the
|
||||||
|
# honest signal. Default 10000 (10s).
|
||||||
|
# AI_CHAT_SUPERSEDE_TIMEOUT_MS=10000
|
||||||
|
#
|
||||||
|
# How often the periodic bidirectional reconcile job runs (heals runs/messages
|
||||||
|
# left dangling by a crash or a lost terminal write). Default 120000 (2 min).
|
||||||
|
# AI_CHAT_RECONCILE_INTERVAL_MS=120000
|
||||||
|
#
|
||||||
|
# Wall-clock cap for a SINGLE in-app tool call (a long paginated read, or a content
|
||||||
|
# write whose collab commit hangs) — the per-call half of the composite abort
|
||||||
|
# signal every in-app tool is wrapped with (the other half is the turn's Stop).
|
||||||
|
# The reconcile staleness floor is derived as max(2 x this cap, 15min), so a very
|
||||||
|
# high value delays stale-run recovery (the server boot-warns above 30min). Default
|
||||||
|
# 120000 (2 min).
|
||||||
|
# AI_CHAT_INAPP_TOOL_CALL_CAP_MS=120000
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
@@ -335,6 +419,20 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
# METRICS_PORT=9464
|
# METRICS_PORT=9464
|
||||||
#
|
#
|
||||||
|
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
|
||||||
|
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
|
||||||
|
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
|
||||||
|
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
|
||||||
|
# and/or keep the port on a private network, since /metrics is otherwise open.
|
||||||
|
# METRICS_BIND=127.0.0.1
|
||||||
|
#
|
||||||
|
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
|
||||||
|
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
|
||||||
|
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
|
||||||
|
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
|
||||||
|
# when the endpoint is bound to loopback or an otherwise-trusted network.
|
||||||
|
# METRICS_TOKEN=
|
||||||
|
#
|
||||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ jobs:
|
|||||||
needs: [test, e2e-server, e2e-mcp, build]
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
# Image boot-smoke (issue #476): every other job tests code from the working
|
||||||
|
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
||||||
|
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
||||||
|
# crash-loop, runtime module missing from the image, wrong static-asset
|
||||||
|
# headers). The services below back a smoke boot of the exact image right
|
||||||
|
# before it is pushed; a smoke failure blocks the push.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -82,6 +114,37 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Load the image into the local docker daemon so it can be booted (the
|
||||||
|
# push step below exports straight to the registry and leaves nothing
|
||||||
|
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
||||||
|
# IDENTICAL to the push step's build-args — same cache scope + same args
|
||||||
|
# means the layers are reused and the image we smoke IS the image we push.
|
||||||
|
- name: Build image for smoke (load, no push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: gitmost:smoke
|
||||||
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
|
||||||
|
# Boot-smoke the exact image against the job services (see the comment on
|
||||||
|
# `services:` above): health (startup migrator), auth/setup, client dist
|
||||||
|
# served, immutable + brotli asset headers. Fails the job (and therefore
|
||||||
|
# the push) on any miss.
|
||||||
|
- name: Smoke the built image
|
||||||
|
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
||||||
|
|
||||||
|
# The smoke script leaves the container running on failure precisely so
|
||||||
|
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
||||||
|
- name: Dump smoke container log on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
||||||
|
|
||||||
- name: Build and push develop image
|
- name: Build and push develop image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -163,6 +226,13 @@ jobs:
|
|||||||
- name: Build mcp
|
- name: Build mcp
|
||||||
run: pnpm --filter @docmost/mcp build
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
|
# apps/server imports @docmost/token-estimate at runtime (history-budget.ts,
|
||||||
|
# #490); its dist/ is gitignored and `test:e2e` type-checks + runs the code,
|
||||||
|
# so build it here or tsc fails with TS2307 Cannot find module
|
||||||
|
# '@docmost/token-estimate' (mirrors the editor-ext / mcp build steps above).
|
||||||
|
- name: Build token-estimate
|
||||||
|
run: pnpm --filter @docmost/token-estimate build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,17 @@ jobs:
|
|||||||
exit "$FAILED"
|
exit "$FAILED"
|
||||||
|
|
||||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
# reproducing seed into property-output.txt. File a dedup-guarded issue.
|
||||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
#
|
||||||
# the next step under a different title) can never poison this dedup.
|
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
||||||
|
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
||||||
|
# single open issue swallow every OTHER counterexample (a different bug B whose
|
||||||
|
# title shares the prefix would be treated as a duplicate and stay silent until
|
||||||
|
# the first issue is closed). Hashing the shrunk example instead means two
|
||||||
|
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
||||||
|
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
||||||
|
# step (below) still keys on its own distinct title, so it can never poison
|
||||||
|
# this dedup either.
|
||||||
- name: File counterexample issue
|
- name: File counterexample issue
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
@@ -146,25 +154,48 @@ jobs:
|
|||||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
||||||
|
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
||||||
|
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
||||||
|
# that reach the same bug — unlike the seed, path, or shrink count (which
|
||||||
|
# precede/follow this block and vary run-to-run) and unlike the whole
|
||||||
|
# output (which embeds those varying parts). Hashing THIS is what makes the
|
||||||
|
# dedup identity the bug itself rather than an incidental run detail.
|
||||||
|
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
||||||
|
if [ -z "$CE_TEXT" ]; then
|
||||||
|
# No parseable shrunk block (unexpected — the signature check above
|
||||||
|
# already confirmed fast-check output). Fall back to the reproducing
|
||||||
|
# seed so we still emit a stable identity instead of silently deduping.
|
||||||
|
CE_TEXT="seed:${FAIL_SEED}"
|
||||||
|
fi
|
||||||
|
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
||||||
|
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
||||||
|
# Machine-readable marker embedded in the issue body; the open-issue search
|
||||||
|
# below matches on it (and on the hash in the title) so identity travels
|
||||||
|
# with the issue regardless of any human title edits.
|
||||||
|
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
||||||
|
export CE_HASH CE_MARKER
|
||||||
|
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
||||||
|
|
||||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
||||||
# prefix already exists. A failure of this check must NOT block creation.
|
# carries this exact hash (in its title or its body marker). A different
|
||||||
|
# counterexample has a different hash and is NOT deduped. A failure of this
|
||||||
|
# check must NOT block creation.
|
||||||
EXISTING=""
|
EXISTING=""
|
||||||
if EXISTING=$(curl -sS \
|
if EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
if printf '%s' "$EXISTING" \
|
if printf '%s' "$EXISTING" \
|
||||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
||||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
echo "An open issue for counterexample ${CE_HASH} already exists — skipping creation."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||||
# interpolate the counterexample into JSON).
|
# interpolate the counterexample into JSON).
|
||||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- counterexample hash: `%s`\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n\n%s\n' \
|
||||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
'{title: $title, body: $body}' > payload.json
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|||||||
+49
-13
@@ -25,37 +25,65 @@ jobs:
|
|||||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
# the PR so the migration is renamed to a current timestamp before merge.
|
||||||
# runs for pull_request events (needs a base branch to diff against).
|
# Runs for pull_request (diff against the base branch) AND for push (#476
|
||||||
|
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
||||||
|
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
||||||
|
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
||||||
|
# nothing to diff against and still skips the job.
|
||||||
migration-order:
|
migration-order:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history for the base-branch diff)
|
- name: Checkout (full history for the base diff)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Added migrations must sort after the newest on the base branch
|
- name: Added migrations must sort after the newest on the base
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.base_ref }}
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
BEFORE_SHA: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
MIG_DIR="apps/server/src/database/migrations"
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
# break the merge-base when the base has moved ahead of the PR merge —
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
BASE="origin/${TARGET_BRANCH}"
|
||||||
|
else
|
||||||
|
# push event: compare against the pre-push tip of the branch.
|
||||||
|
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "::notice::branch creation push — nothing to compare"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# The before-SHA is not in the clone (a force-push rewrote history).
|
||||||
|
# One recovery attempt — refresh every remote head (cheap: the
|
||||||
|
# checkout is already fetch-depth:0); a fetch failure aborts via
|
||||||
|
# `set -e`, which is fail-closed too.
|
||||||
|
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
||||||
|
# ordering against, and a gate whose job is to BLOCK must not guess.
|
||||||
|
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BASE="$BEFORE_SHA"
|
||||||
|
fi
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
||||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
# `set -e` above already aborts on a non-zero diff exit.
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
||||||
bad=0
|
bad=0
|
||||||
for f in $added; do
|
for f in $added; do
|
||||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -131,6 +159,14 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
|
# @docmost/token-estimate is a shared workspace package the client vitest
|
||||||
|
# suite resolves via its dist build (main: ./dist/index.js); dist/ is
|
||||||
|
# gitignored and `pnpm -r test` does NOT honour nx `dependsOn: ^build`, so
|
||||||
|
# build it before the recursive test run or the client suite fails with
|
||||||
|
# "Failed to resolve import '@docmost/token-estimate'" (#490).
|
||||||
|
- name: Build token-estimate
|
||||||
|
run: pnpm --filter @docmost/token-estimate build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ packages/mcp/build/
|
|||||||
# is a build artifact like build/ — never committed, always fresh.
|
# is a build artifact like build/ — never committed, always fresh.
|
||||||
packages/mcp/src/registry-stamp.generated.ts
|
packages/mcp/src/registry-stamp.generated.ts
|
||||||
|
|
||||||
|
# token-estimate compiled output (#490; built in CI/Docker via `pnpm build` /
|
||||||
|
# the server `pretest`, never committed, so src/ and prod can never diverge).
|
||||||
|
packages/token-estimate/dist/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **every agent turn is now a first-class server-side RUN** (`#184`, universalized in `#487`): its lifecycle is tracked in `ai_chat_runs` in **both** modes, and the single-active-run-per-chat concurrency gate is enforced universally (a legacy second tab now gets a clean `409 A_RUN_ALREADY_ACTIVE` instead of a second parallel stream that interleaved history). The per-workspace `settings.ai.autonomousRuns` flag (off by default) **no longer gates whether a turn is a run** — it now controls **only the browser-disconnect semantics**: when ON the run is *detached* (a disconnect leaves it executing server-side; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`); when OFF (legacy) a disconnect ends the turn by stopping its run via the run's stop lever. `#487` also adds a server-side **supersede** CAS ("interrupt and send now") to `POST /ai-chat/stream` (`supersede: { runId }`): it atomically stops the chat's currently-active run and waits for it to settle before the new turn claims the slot, returning `SUPERSEDE_INVALID` / `SUPERSEDE_TARGET_MISMATCH` / `SUPERSEDE_TIMEOUT` on the non-proceed branches. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -470,7 +471,9 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
|
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
||||||
|
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
||||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
+215
@@ -115,8 +115,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||||
breaking window so external configs break exactly once. (#413)
|
breaking window so external configs break exactly once. (#413)
|
||||||
|
|
||||||
|
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
|
||||||
|
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
|
||||||
|
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
|
||||||
|
cross-container scraping breaks silently otherwise:** if your scraper runs in a
|
||||||
|
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
|
||||||
|
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
|
||||||
|
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
|
||||||
|
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
|
||||||
|
scraper can no longer connect and metrics go dark with no error. See the
|
||||||
|
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
|
||||||
|
Same-host (loopback) scrapers need no change. (#486)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **A drifted comment suggestion can be re-synced instead of failing forever
|
||||||
|
with a 409.** A suggestion whose stored anchor no longer matched the live
|
||||||
|
document used to reject every apply attempt with an unrecoverable conflict; a
|
||||||
|
new resync path re-reads the live anchor so the suggestion applies against the
|
||||||
|
current text, and orphaned anchors (whose marked run was deleted) are
|
||||||
|
reconciled rather than left blocking. (#496)
|
||||||
|
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
|
||||||
|
to save a named version of a page. The history panel now distinguishes
|
||||||
|
intentional versions (a "Saved" / "Agent version" badge) from automatic
|
||||||
|
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
|
||||||
|
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
|
||||||
|
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
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
@@ -190,6 +228,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||||
not yet reliable); the server warns at startup on a horizontally-scaled
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
deployment. (#184)
|
deployment. (#184)
|
||||||
|
- **Server-side "interrupt and send now" (supersede) for AI chat.** `POST
|
||||||
|
/ai-chat/stream` now accepts a `supersede: { runId }` field: when the user sends
|
||||||
|
a new message while a run is active, the server atomically stops that run and
|
||||||
|
waits for it to settle before the new turn claims the chat's single run slot,
|
||||||
|
instead of the send being rejected as concurrent. The compare-and-set surfaces
|
||||||
|
three codes on its non-proceed branches — `SUPERSEDE_INVALID` (the targeted run
|
||||||
|
is malformed / belongs to another chat), `SUPERSEDE_TARGET_MISMATCH` (a
|
||||||
|
different run is now active; carries the current `activeRunId`), and
|
||||||
|
`SUPERSEDE_TIMEOUT` (the previous run did not stop within the settle window, so
|
||||||
|
nothing was sent and the composer keeps the text). Tunable via
|
||||||
|
`AI_CHAT_SUPERSEDE_TIMEOUT_MS` (default 10s). (#487)
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -270,6 +319,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Every AI-chat turn is now a first-class server-side run, and one run per chat
|
||||||
|
is enforced in both modes.** The run machinery from `#184` was universalized: a
|
||||||
|
turn is tracked in `ai_chat_runs` and gated by the single-active-run-per-chat
|
||||||
|
index regardless of the `settings.ai.autonomousRuns` flag. **Behavior change:**
|
||||||
|
a second tab (or a double-submit) that starts a turn while one is already active
|
||||||
|
on the chat is now rejected up front with `409 A_RUN_ALREADY_ACTIVE` (carrying
|
||||||
|
the `activeRunId`); previously, on the legacy path, it opened a second parallel
|
||||||
|
stream on the same chat that interleaved history. The `autonomousRuns` flag no
|
||||||
|
longer controls whether a turn is a run — it now governs **only** the
|
||||||
|
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
||||||
|
disconnect stops the run). (#487)
|
||||||
|
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
||||||
|
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
||||||
|
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
||||||
|
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
||||||
|
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
||||||
|
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
||||||
|
alignment is called out as an install-gated plan rather than a bare version
|
||||||
|
bump. No runtime change.
|
||||||
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||||
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||||
generator, and the AI-chat markdown renderer all now use
|
generator, and the AI-chat markdown renderer all now use
|
||||||
@@ -302,6 +370,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **MCP write tools no longer report a false failure that provokes a duplicate
|
||||||
|
write.** `drawioCreate` used to throw when the diagram landed as a NESTED block
|
||||||
|
(anchored inside a callout or table cell) because there is no `#<index>` handle
|
||||||
|
for it — but the diagram was already written, so a retry-prone agent re-created
|
||||||
|
it and produced a duplicate. It now returns success with `nodeId: null` plus a
|
||||||
|
warning that explains the write landed and how to re-read it (via
|
||||||
|
`getOutline` / `getPageJson` by `attachmentId`). Separately, when the live
|
||||||
|
collaboration-session cache hits its LRU entry cap, evicting a session whose
|
||||||
|
write is still in flight no longer rejects that write as a hard failure — it is
|
||||||
|
reported as INDETERMINATE ("the update may already have persisted; verify
|
||||||
|
before retry") so the agent re-reads instead of blind-retrying, and a
|
||||||
|
still-connecting session is no longer picked as an idle eviction victim by a
|
||||||
|
parallel acquire. (#494)
|
||||||
|
- **A long AI chat no longer bricks on the model's context window, and each turn
|
||||||
|
stops re-persisting the whole tool-output history.** Tool outputs are now
|
||||||
|
stored ONCE, in `metadata.parts`; the `tool_calls` trace keeps only per-step
|
||||||
|
outcome flags (a v2 trace shape), ending the O(N²) write amplification that
|
||||||
|
re-wrote every prior output on every step (measured on a live Postgres via the
|
||||||
|
`pg_current_wal_lsn()` delta: the trace column shrank ~3200×, the full
|
||||||
|
assistant row ~51%). The persisted record is unchanged in content — the full
|
||||||
|
history still lives in `metadata.parts`. At REPLAY time only, the history sent
|
||||||
|
to the provider is now bounded by a deterministic, prompt-cache-friendly token
|
||||||
|
budget: `floor(0.7 × chatContextWindow)` when a window is configured (no cap —
|
||||||
|
anti-brick protection, not a cost limiter), a flat 100k fallback for installs
|
||||||
|
with no window set (exactly the ones that hit terminal overflow), or off when
|
||||||
|
the window is explicitly `0`. Trimming truncates old tool outputs first, then
|
||||||
|
mechanically collapses the oldest turns, always keeping the recent turns full
|
||||||
|
and the tool-call/result pairing balanced. A provider context-overflow 400 is
|
||||||
|
now classified and used as a reactive signal: the row is stamped so the NEXT
|
||||||
|
turn re-trims aggressively (0.5×), which un-bricks a chat that just 400'd. The
|
||||||
|
client token badge and the server budgeter now share one estimator (new
|
||||||
|
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
||||||
|
activation is also cached in the chat metadata to avoid re-resolving it each
|
||||||
|
turn. (#490)
|
||||||
|
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
|
||||||
|
when a diagram is opened in the draw.io editor.** Agent-created diagrams
|
||||||
|
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
|
||||||
|
SVG's `content=` attribute as base64; the draw.io editor decodes that via
|
||||||
|
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
|
||||||
|
`ё`, `—`) split into garbage and the editor's autosave then persisted the
|
||||||
|
corrupted model, breaking the page preview too. Both write paths
|
||||||
|
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
|
||||||
|
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
|
||||||
|
DOM as UTF-8 — so labels open intact. The decoder reads both the new
|
||||||
|
entity-encoded form and the old base64 form, so existing diagrams still open.
|
||||||
|
*Healing pre-fix diagrams:* only a diagram that still holds its original
|
||||||
|
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
|
||||||
|
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
|
||||||
|
same XML (rewrites the attachment in the new form); no migration script is
|
||||||
|
needed. A diagram that was already opened in the editor persisted the
|
||||||
|
mojibake at rest, so `drawioGet` reads the already-corrupted text and
|
||||||
|
`drawioUpdate` faithfully rewrites it — that text is lost and is not
|
||||||
|
recoverable by a rewrite. (#507)
|
||||||
|
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||||
|
failed send no longer duplicates the user's message.** Incoming client parts
|
||||||
|
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||||
|
the persisted history or the model context), and the turn is converted BEFORE
|
||||||
|
the user row is inserted, so a mid-flight failure cannot leave a duplicate
|
||||||
|
user row that a retry then compounds. A single part that still fails to convert
|
||||||
|
degrades to a `[tool context omitted]` marker on that one row instead of
|
||||||
|
bricking the whole chat. (#489)
|
||||||
|
- **A transport drop to an external MCP server now heals within the same turn.**
|
||||||
|
On an undici transport error, a read-only MCP tool reconnects its server and
|
||||||
|
retries once within the run; a write is never auto-retried (it may already have
|
||||||
|
applied). One flapping server no longer nulls the shared client cache, so other
|
||||||
|
servers' cached clients are untouched. The SSE transport also gets a raised
|
||||||
|
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||||
|
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||||
|
10 min; see `.env.example`). (#489)
|
||||||
|
- **Decisions on comment suggestions now leave a durable audit record.**
|
||||||
|
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
||||||
|
subject comment, so the only surviving trace of who decided what is the audit
|
||||||
|
event — but the audit trail was wired to a Noop service that silently
|
||||||
|
swallowed every event. The trail is now DB-backed, so
|
||||||
|
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
||||||
|
comment-decision events) persist to the `audit` table and can be reviewed
|
||||||
|
after the comment is gone. A persistence failure is still swallowed with a
|
||||||
|
warning so it never breaks the originating request. (#496)
|
||||||
|
- **Applying a comment suggestion no longer strips the replaced run's inline
|
||||||
|
formatting.** The suggested text was re-inserted carrying only the comment
|
||||||
|
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||||
|
prevailing formatting of the replaced run is now carried onto the applied
|
||||||
|
text. (#496)
|
||||||
- **Markdown round-trips no longer silently drop a line that opens with a block
|
- **Markdown round-trips no longer silently drop a line that opens with a block
|
||||||
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
||||||
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
||||||
@@ -319,6 +470,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
|
|
||||||
|
- **The server no longer leaks a hung stream pipe on every mid-run client
|
||||||
|
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
|
||||||
|
`writeToServerResponse`, which awaited only a `"drain"` event under
|
||||||
|
backpressure: when a client disconnected mid-write the socket never drained, so
|
||||||
|
the write loop parked forever, `response.end()` was unreachable, and the stream
|
||||||
|
reader plus buffered chunks were pinned until process restart (every mid-run
|
||||||
|
disconnect in autonomous mode leaked one). The patch races `"drain"` against
|
||||||
|
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
|
||||||
|
swallows the fire-and-forget read rejection instead of crashing on an
|
||||||
|
unhandledRejection. (#486)
|
||||||
|
|
||||||
|
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
|
||||||
|
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
|
||||||
|
the turn previously continued with NO run row — invisible to `/stop`, not
|
||||||
|
aborted on disconnect, and able to slip a second run past the one-run-per-chat
|
||||||
|
gate, leaving an unstoppable run until restart. The turn now fails fast with an
|
||||||
|
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
|
||||||
|
client shows a "temporary — please try again" message instead of a misleading
|
||||||
|
"provider not configured". (#486)
|
||||||
|
|
||||||
|
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
|
||||||
|
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
|
||||||
|
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
|
||||||
|
churned — and the old `setTimeout` "timeout" could never fire because the same
|
||||||
|
thread was blocked. Layout now runs in a worker thread with the timeout enforced
|
||||||
|
by `worker.terminate()`; the main loop stays responsive. (#486)
|
||||||
|
|
||||||
|
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
|
||||||
|
is down.** It built a new `ioredis` client per probe and disconnected it only on
|
||||||
|
success, so during an outage each health tick added another forever-reconnecting
|
||||||
|
client (an unbounded handle leak). A single long-lived probe client is now
|
||||||
|
reused and closed on shutdown. (#486)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
@@ -394,6 +578,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
through that exact share (its own share or an ancestor `includeSubPages`
|
through that exact share (its own share or an ancestor `includeSubPages`
|
||||||
share); any other value now returns the generic "not found" instead of
|
share); any other value now returns the generic "not found" instead of
|
||||||
serving the page. (#218)
|
serving the page. (#218)
|
||||||
|
- **MCP tool-allowlist semantics flipped: an empty `[]` now means deny-all
|
||||||
|
(previously it was coerced to "no restrictions").** For an external MCP server,
|
||||||
|
a stored `tool_allowlist` of `[]` now denies **every** tool of that server
|
||||||
|
(zero tools reach the agent) instead of being treated as an empty/unset filter
|
||||||
|
that allowed all of them. A corrupt or non-array stored value now **fails
|
||||||
|
closed** to deny-all rather than silently allowing everything. The admin form
|
||||||
|
no longer silently widens an existing deny-all server: leaving its tag field
|
||||||
|
empty preserves `[]` (deny-all) on save instead of NULL-ing the column to
|
||||||
|
allow-all, so a routine rename/toggle can no longer grant the agent every tool.
|
||||||
|
"No restrictions" is still expressible — a genuinely unrestricted server stores
|
||||||
|
NULL, and clearing the field on such a server keeps it NULL. Operationally
|
||||||
|
significant: audit any server that was created or left with a literal `[]`, as
|
||||||
|
it now exposes no tools until an explicit allowlist (or NULL) is set. (#476)
|
||||||
|
|
||||||
|
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||||
|
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||||
|
internal page title or a stack fragment) and a provider error (which bundles the
|
||||||
|
provider `statusCode` and response body — potentially the internal baseUrl or
|
||||||
|
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
|
||||||
|
now sanitized at the source: the share toolset collapses any unclassified tool
|
||||||
|
error to a safe generic string (safe, classified tool messages still pass
|
||||||
|
through for the model's self-correction), and the anonymous stream `onError`
|
||||||
|
maps provider failures to a fixed set of neutral strings — the full detail goes
|
||||||
|
only to the server log. A UI render gate is layered on top. (closes #394)
|
||||||
|
|
||||||
|
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
|
||||||
|
is loopback-bound by default.** Previously it listened on all interfaces with no
|
||||||
|
auth. Setting `METRICS_TOKEN` requires every scrape to present
|
||||||
|
`Authorization: Bearer <token>` (compared in constant time), and the listener
|
||||||
|
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
|
||||||
|
migration). (#486)
|
||||||
|
|
||||||
## [0.94.0] - 2026-06-26
|
## [0.94.0] - 2026-06-26
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
|||||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
|
# apps/server imports @docmost/token-estimate (workspace:*) at runtime
|
||||||
|
# (history-budget.ts, #490). tsc emits only dist/ and dist/ is gitignored, so the
|
||||||
|
# prod install would resolve a broken workspace symlink and the server would die
|
||||||
|
# with ERR_MODULE_NOT_FOUND on the first history-budget call. Ship the built
|
||||||
|
# package + its manifest, mirroring prosemirror-markdown above.
|
||||||
|
COPY --from=builder /app/packages/token-estimate/dist /app/packages/token-estimate/dist
|
||||||
|
COPY --from=builder /app/packages/token-estimate/package.json /app/packages/token-estimate/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
COPY --from=builder /app/pnpm*.yaml /app/
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
|
|||||||
@@ -206,6 +206,137 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
existing pages are indexed on their next edit. pgvector is still required for the migration to
|
||||||
apply at all.
|
apply at all.
|
||||||
|
|
||||||
|
## Local embeddings server
|
||||||
|
|
||||||
|
The AI agent's semantic (RAG) search needs an **embeddings model**. Instead of paying a cloud
|
||||||
|
provider (e.g. OpenAI `text-embedding-3-*`) to embed every page, you can run a small open-weights
|
||||||
|
model yourself with Hugging Face
|
||||||
|
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI), which
|
||||||
|
serves an OpenAI-compatible `/v1/embeddings` endpoint. `intfloat/multilingual-e5-small` is a good
|
||||||
|
default: multilingual, 384-dim, and comfortable on CPU (~1–2 GB RAM, 1–2 vCPU). Point Gitmost at it
|
||||||
|
under **Workspace settings → AI → Embeddings**.
|
||||||
|
|
||||||
|
### Option A — local (same Docker network as Gitmost)
|
||||||
|
|
||||||
|
Run TEI as a container on the network Gitmost is already on. The port is never published, so the
|
||||||
|
endpoint stays internal and needs no authentication.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- gitmost_net # same network Gitmost is on
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data # weights are downloaded once and cached here
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gitmost_net:
|
||||||
|
external: true # the network Gitmost already uses
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------------------|-----------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `http://embeddings:80/v1/` |
|
||||||
|
| Embedding API key | — (leave empty) |
|
||||||
|
|
||||||
|
> `embeddings` is the container name — Gitmost resolves it over DNS inside the Docker network.
|
||||||
|
> The port is not published, so the endpoint is reachable only by containers on that network and
|
||||||
|
> no authorization is required.
|
||||||
|
|
||||||
|
### Option B — separate host (public via Traefik + Let's Encrypt)
|
||||||
|
|
||||||
|
This assumes the host already runs Traefik with an ACME resolver (the example below uses
|
||||||
|
`letsEncrypt`, the `websecure` entrypoint and a shared `docker_main_net` network). Replace the
|
||||||
|
domain / network / resolver with your own.
|
||||||
|
|
||||||
|
**DNS:** add an A record `embeddings.example.com` → the IP of your Traefik host (same
|
||||||
|
challenge / port 80 as the rest of your sites).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- docker_main_net # the network Traefik is attached to
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate"
|
||||||
|
- "--api-key"
|
||||||
|
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||||
|
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||||
|
traefik.http.routers.embeddings.tls: "true"
|
||||||
|
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||||
|
traefik.http.routers.embeddings.service: "embeddings"
|
||||||
|
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||||
|
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||||
|
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_main_net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitmost settings (**Workspace settings → AI → Embeddings**):
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------------------|---------------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `https://embeddings.example.com/v1/` |
|
||||||
|
| Embedding API key | your `sk-emb-…` |
|
||||||
|
|
||||||
|
Check it from outside:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://embeddings.example.com/v1/embeddings \
|
||||||
|
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||||
|
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
# -> dims: 384
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embeddings server notes
|
||||||
|
|
||||||
|
- **Vector dimension is 384.** If this Gitmost was previously embedded with a different model
|
||||||
|
(e.g. `text-embedding-3-large` = 3072-dim), the old pgvector rows won't match the new dimension —
|
||||||
|
clear the existing embeddings / re-index before switching. Gitmost only compares vectors of the
|
||||||
|
same dimension, so mixed-dimension rows are silently ignored rather than searched.
|
||||||
|
- **First start downloads the weights** (hundreds of MB) from `huggingface.co` into the
|
||||||
|
`tei-models` volume; every start after that reads from the volume.
|
||||||
|
- **Pin the version.** Pin the image, and optionally the model: add `--revision <commit-sha>` to
|
||||||
|
`command` (the sha is on the model's page on Hugging Face).
|
||||||
|
- **Air-gapped / no egress:** seed the `tei-models` volume ahead of time and add
|
||||||
|
`environment: [HF_HUB_OFFLINE=1]`.
|
||||||
|
- **GPU:** use the cuda tag of the same release (e.g.
|
||||||
|
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) and start the container with `gpus: all`.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
|
|||||||
+131
@@ -193,6 +193,137 @@ dump/restore, существующий каталог данных переис
|
|||||||
> неизменным и бэкапьте вместе с базой данных.
|
> неизменным и бэкапьте вместе с базой данных.
|
||||||
|
|
||||||
|
|
||||||
|
## Локальный сервер эмбеддингов
|
||||||
|
|
||||||
|
Семантическому (RAG) поиску AI-агента нужна **модель эмбеддингов**. Вместо оплаты облачного
|
||||||
|
провайдера (например, OpenAI `text-embedding-3-*`) за эмбеддинг каждой страницы можно запустить
|
||||||
|
небольшую open-weights модель у себя через Hugging Face
|
||||||
|
[Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) (TEI) — он
|
||||||
|
отдаёт OpenAI-совместимый эндпоинт `/v1/embeddings`. Хороший дефолт — `intfloat/multilingual-e5-small`:
|
||||||
|
многоязычная, 384-мерная, комфортно работает на CPU (~1–2 ГБ RAM, 1–2 vCPU). Пропишите её в
|
||||||
|
**Настройки воркспейса → AI → Эмбеддинги**.
|
||||||
|
|
||||||
|
### Вариант A — локально (та же Docker-сеть, что и Gitmost)
|
||||||
|
|
||||||
|
Запустите TEI контейнером в той же сети, где уже работает Gitmost. Порт наружу не публикуется,
|
||||||
|
поэтому эндпоинт остаётся внутренним и не требует авторизации.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; use a cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- gitmost_net # same network Gitmost is on
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate" # clamp over-long inputs instead of returning 413
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data # weights are downloaded once and cached here
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gitmost_net:
|
||||||
|
external: true # the network Gitmost already uses
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|-------------------|-----------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `http://embeddings:80/v1/` |
|
||||||
|
| Embedding API key | — (оставить пустым) |
|
||||||
|
|
||||||
|
> `embeddings` — имя контейнера, Gitmost резолвит его по DNS внутри Docker-сети.
|
||||||
|
> Наружу порт не публикуется, эндпоинт доступен только контейнерам этой сети, поэтому
|
||||||
|
> авторизация не нужна.
|
||||||
|
|
||||||
|
### Вариант B — на отдельном хосте (наружу через Traefik + Let's Encrypt)
|
||||||
|
|
||||||
|
Предполагается, что на хосте уже есть Traefik с ACME-резолвером (в примере ниже — `letsEncrypt`,
|
||||||
|
entrypoint `websecure`, общая сеть `docker_main_net`). Замените домен / сеть / резолвер на свои.
|
||||||
|
|
||||||
|
**DNS:** заведите A-запись `embeddings.example.com` → IP хоста с Traefik (тот же challenge / порт 80,
|
||||||
|
что и у остальных сайтов).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
embeddings:
|
||||||
|
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 # pin version; cuda-* tag for GPU
|
||||||
|
container_name: embeddings
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- docker_main_net # the network Traefik is attached to
|
||||||
|
command:
|
||||||
|
- "--model-id"
|
||||||
|
- "intfloat/multilingual-e5-small"
|
||||||
|
- "--auto-truncate"
|
||||||
|
- "--api-key"
|
||||||
|
- "sk-emb-REPLACE_WITH_YOUR_KEY"
|
||||||
|
volumes:
|
||||||
|
- tei-models:/data
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.http.routers.embeddings.rule: "Host(`embeddings.example.com`)"
|
||||||
|
traefik.http.routers.embeddings.entrypoints: "websecure"
|
||||||
|
traefik.http.routers.embeddings.tls: "true"
|
||||||
|
traefik.http.routers.embeddings.tls.certresolver: "letsEncrypt"
|
||||||
|
traefik.http.routers.embeddings.service: "embeddings"
|
||||||
|
traefik.http.services.embeddings.loadbalancer.server.port: "80"
|
||||||
|
# TEI enforces the Bearer key itself; Traefik only rate-limits to protect the CPU
|
||||||
|
traefik.http.routers.embeddings.middlewares: "embeddings-rl"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.average: "20"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.burst: "40"
|
||||||
|
traefik.http.middlewares.embeddings-rl.ratelimit.period: "1s"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker_main_net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tei-models:
|
||||||
|
```
|
||||||
|
|
||||||
|
Настройки Gitmost (**Настройки воркспейса → AI → Эмбеддинги**):
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|-------------------|---------------------------------------|
|
||||||
|
| Model | `intfloat/multilingual-e5-small` |
|
||||||
|
| Base URL | `https://embeddings.example.com/v1/` |
|
||||||
|
| Embedding API key | ваш `sk-emb-…` |
|
||||||
|
|
||||||
|
Проверка снаружи:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://embeddings.example.com/v1/embeddings \
|
||||||
|
-H "Authorization: Bearer sk-emb-REPLACE_WITH_YOUR_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"intfloat/multilingual-e5-small","input":"query: hello"}' \
|
||||||
|
| python3 -c 'import sys,json;print("dims:",len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
# -> dims: 384
|
||||||
|
```
|
||||||
|
|
||||||
|
### Заметки про сервер эмбеддингов
|
||||||
|
|
||||||
|
- **Размерность вектора — 384.** Если раньше этот Gitmost эмбеддился другой моделью
|
||||||
|
(например, `text-embedding-3-large` = 3072-dim), старые строки в pgvector не совпадут по
|
||||||
|
размерности — очистите существующие эмбеддинги / переиндексируйте перед переключением. Gitmost
|
||||||
|
сравнивает только вектора одной размерности, поэтому строки другой размерности не участвуют в
|
||||||
|
поиске, а не ломают его.
|
||||||
|
- **Первый старт тянет веса** (сотни МБ) с `huggingface.co` в том `tei-models`; дальше — из тома.
|
||||||
|
- **Пин версии.** Пиньте образ, а при желании и модель: добавьте в `command` `--revision <commit-sha>`
|
||||||
|
(sha берётся со страницы модели на Hugging Face).
|
||||||
|
- **Без egress (air-gapped):** засейте том `tei-models` заранее и добавьте
|
||||||
|
`environment: [HF_HUB_OFFLINE=1]`.
|
||||||
|
- **GPU:** возьмите cuda-тег того же релиза (например,
|
||||||
|
`ghcr.io/huggingface/text-embeddings-inference:cuda-1.9`) и запустите контейнер с `gpus: all`.
|
||||||
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Совместная работа в реальном времени
|
- Совместная работа в реальном времени
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@casl/react": "5.0.1",
|
"@casl/react": "5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@docmost/token-estimate": "workspace:*",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "8.3.18",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "8.3.18",
|
"@mantine/dates": "8.3.18",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -239,6 +273,8 @@
|
|||||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||||
"Failed to resolve comment": "Failed to resolve comment",
|
"Failed to resolve comment": "Failed to resolve comment",
|
||||||
|
"Failed to re-open comment": "Failed to re-open comment",
|
||||||
|
"Comment no longer exists": "Comment no longer exists",
|
||||||
"Resolve comment": "Resolve comment",
|
"Resolve comment": "Resolve comment",
|
||||||
"Unresolve comment": "Unresolve comment",
|
"Unresolve comment": "Unresolve comment",
|
||||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||||
@@ -423,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",
|
||||||
@@ -1418,5 +1457,39 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Suggestion dismissed": "Suggestion dismissed",
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
|
||||||
|
"Save version": "Save version",
|
||||||
|
"Ctrl+S": "Ctrl+S",
|
||||||
|
"Version saved": "Version saved",
|
||||||
|
"Already saved as the latest version": "Already saved as the latest version",
|
||||||
|
"Agent version": "Agent version",
|
||||||
|
"Boundary": "Boundary",
|
||||||
|
"Autosave": "Autosave",
|
||||||
|
"Only versions": "Only versions",
|
||||||
|
"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": "Настройки рабочего пространства",
|
||||||
@@ -239,6 +275,8 @@
|
|||||||
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
||||||
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
||||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||||
|
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
|
||||||
|
"Comment no longer exists": "Комментарий больше не существует",
|
||||||
"Resolve comment": "Решить комментарий",
|
"Resolve comment": "Решить комментарий",
|
||||||
"Unresolve comment": "Снять статус решённого с комментария",
|
"Unresolve comment": "Снять статус решённого с комментария",
|
||||||
"Resolve Comment Thread": "Решить ветку комментариев",
|
"Resolve Comment Thread": "Решить ветку комментариев",
|
||||||
@@ -256,6 +294,9 @@
|
|||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
|
"Copy chat": "Копировать чат",
|
||||||
|
"Dock to sidebar": "Закрепить в боковой панели",
|
||||||
|
"Undock": "Открепить",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
"Failed to export chat": "Не удалось экспортировать чат",
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
@@ -285,6 +326,9 @@
|
|||||||
"Alt text": "Альтернативный текст",
|
"Alt text": "Альтернативный текст",
|
||||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||||
"Add a description": "Добавить описание",
|
"Add a description": "Добавить описание",
|
||||||
|
"Caption": "Подпись",
|
||||||
|
"Add a caption": "Добавить подпись",
|
||||||
|
"Shown below the image.": "Отображается под изображением.",
|
||||||
"Justify": "По ширине",
|
"Justify": "По ширине",
|
||||||
"Merge cells": "Объединить ячейки",
|
"Merge cells": "Объединить ячейки",
|
||||||
"Split cell": "Разделить ячейку",
|
"Split cell": "Разделить ячейку",
|
||||||
@@ -388,22 +432,6 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
"Transcribe": "Транскрибировать",
|
|
||||||
"Transcribing…": "Транскрибация…",
|
|
||||||
"No speech detected": "Речь не распознана",
|
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
|
||||||
"Start dictation": "Начать диктовку",
|
|
||||||
"Stop recording": "Остановить запись",
|
|
||||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
|
||||||
"No microphone found": "Микрофон не найден",
|
|
||||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
|
||||||
"Could not start recording": "Не удалось начать запись",
|
|
||||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
|
||||||
"Dictation": "Диктовка",
|
|
||||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
|
||||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
|
||||||
"This page is read-only": "Страница открыта только для чтения",
|
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -419,9 +447,6 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
"Back to references": "Вернуться к ссылкам",
|
|
||||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
|
||||||
"Empty footnote": "Пустая сноска",
|
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формулы",
|
"Math block": "Блок формулы",
|
||||||
@@ -442,11 +467,17 @@
|
|||||||
"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_few": "Найдено {{count}} результата",
|
||||||
|
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||||
"Equal columns": "Равные столбцы",
|
"Equal columns": "Равные столбцы",
|
||||||
"Left sidebar": "Левая боковая панель",
|
"Left sidebar": "Левая боковая панель",
|
||||||
"Right sidebar": "Правая боковая панель",
|
"Right sidebar": "Правая боковая панель",
|
||||||
@@ -456,6 +487,7 @@
|
|||||||
"Names do not match": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||||
|
"now": "сейчас",
|
||||||
"Space created successfully": "Пространство успешно создано",
|
"Space created successfully": "Пространство успешно создано",
|
||||||
"Space updated successfully": "Пространство успешно обновлено",
|
"Space updated successfully": "Пространство успешно обновлено",
|
||||||
"Space deleted successfully": "Пространство успешно удалено",
|
"Space deleted successfully": "Пространство успешно удалено",
|
||||||
@@ -559,6 +591,7 @@
|
|||||||
"Add 2FA method": "Добавить метод 2FA",
|
"Add 2FA method": "Добавить метод 2FA",
|
||||||
"Backup codes": "Резервные коды",
|
"Backup codes": "Резервные коды",
|
||||||
"Disable": "Отключить",
|
"Disable": "Отключить",
|
||||||
|
"disabled": "отключено",
|
||||||
"Invalid verification code": "Недействительный код подтверждения",
|
"Invalid verification code": "Недействительный код подтверждения",
|
||||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||||
@@ -702,62 +735,6 @@
|
|||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI agent": "AI-агент",
|
|
||||||
"Take a look at the current document": "Посмотри текущий документ",
|
|
||||||
"Start automatically": "Запускать автоматически",
|
|
||||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
|
||||||
"Launch message": "Стартовое сообщение",
|
|
||||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
|
||||||
"AI agent is typing…": "AI-агент печатает…",
|
|
||||||
"{{name}} is typing…": "{{name}} печатает…",
|
|
||||||
"Thinking…": "Думаю…",
|
|
||||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
|
||||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
|
||||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
|
||||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
|
||||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
|
||||||
"Agent role": "Роль агента",
|
|
||||||
"AI chat": "AI-чат",
|
|
||||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
|
||||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
|
||||||
"Ask a question…": "Задайте вопрос…",
|
|
||||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
|
||||||
"Ask the AI agent…": "Спросите AI-агента…",
|
|
||||||
"Copy chat": "Копировать чат",
|
|
||||||
"Dock to sidebar": "Закрепить в боковой панели",
|
|
||||||
"Undock": "Открепить",
|
|
||||||
"Created successfully": "Успешно создано",
|
|
||||||
"Context size / model limit": "Размер контекста / лимит модели",
|
|
||||||
"Context window (tokens)": "Окно контекста (токены)",
|
|
||||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
|
||||||
"Deleted successfully": "Успешно удалено",
|
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
|
||||||
"Failed": "Ошибка",
|
|
||||||
"OK · {{n}}": "OK · {{n}}",
|
|
||||||
"Test": "Тест",
|
|
||||||
"No tools available": "Инструменты недоступны",
|
|
||||||
"Available tools": "Доступные инструменты",
|
|
||||||
"Minimize": "Свернуть",
|
|
||||||
"No chats yet.": "Чатов пока нет.",
|
|
||||||
"Send": "Отправить",
|
|
||||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
|
||||||
"Queue message": "Поставить в очередь",
|
|
||||||
"Remove queued message": "Убрать из очереди",
|
|
||||||
"Send now": "Отправить сейчас",
|
|
||||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
|
||||||
"Something went wrong": "Что-то пошло не так",
|
|
||||||
"Stop": "Стоп",
|
|
||||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
|
||||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
|
||||||
"Universal assistant": "Универсальный ассистент",
|
|
||||||
"You": "Вы",
|
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Thinking": "Думаю",
|
"Thinking": "Думаю",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
@@ -784,8 +761,40 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||||
"Instructions": "Инструкции",
|
"AI / Models": "ИИ / Модели",
|
||||||
|
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||||
|
"Add server": "Добавить сервер",
|
||||||
|
"Edit server": "Изменить сервер",
|
||||||
|
"Delete server": "Удалить сервер",
|
||||||
|
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||||
|
"No external servers configured": "Внешние серверы не настроены",
|
||||||
|
"Server name": "Имя сервера",
|
||||||
|
"Transport": "Транспорт",
|
||||||
|
"URL": "URL",
|
||||||
|
"Authorization header": "Заголовок авторизации",
|
||||||
|
"Tool allowlist": "Список разрешённых инструментов",
|
||||||
|
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||||
|
"Test": "Тест",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Created successfully": "Успешно создано",
|
||||||
|
"Deleted successfully": "Успешно удалено",
|
||||||
|
"Clear": "Очистить",
|
||||||
|
"Provider": "Провайдер",
|
||||||
|
"•••• set": "•••• задан",
|
||||||
|
"Clear key": "Очистить ключ",
|
||||||
|
"Base URL": "Базовый URL",
|
||||||
|
"Chat model": "Модель чата",
|
||||||
|
"Embedding model": "Модель эмбеддингов",
|
||||||
|
"System message": "Системное сообщение",
|
||||||
|
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Test connection": "Проверить соединение",
|
||||||
|
"Connection successful": "Соединение установлено",
|
||||||
|
"Connection failed": "Не удалось установить соединение",
|
||||||
|
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -1013,6 +1022,7 @@
|
|||||||
"Try again": "Попробовать снова",
|
"Try again": "Попробовать снова",
|
||||||
"Untitled chat": "Чат без названия",
|
"Untitled chat": "Чат без названия",
|
||||||
"No document": "Без документа",
|
"No document": "Без документа",
|
||||||
|
"You": "Вы",
|
||||||
"What can I help you with?": "Чем я могу вам помочь?",
|
"What can I help you with?": "Чем я могу вам помочь?",
|
||||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||||
@@ -1041,6 +1051,9 @@
|
|||||||
"Page menu": "Меню страницы",
|
"Page menu": "Меню страницы",
|
||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Collapse": "Свернуть",
|
"Collapse": "Свернуть",
|
||||||
|
"Expand all": "Развернуть все",
|
||||||
|
"Collapse all": "Свернуть все",
|
||||||
|
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||||
"Comment menu": "Меню комментария",
|
"Comment menu": "Меню комментария",
|
||||||
"Group menu": "Меню группы",
|
"Group menu": "Меню группы",
|
||||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||||
@@ -1077,7 +1090,7 @@
|
|||||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||||
"No results found": "Результаты не найдены",
|
"No results found": "Результаты не найдены",
|
||||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||||
"Chat menu": "Меню чата",
|
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||||
"API key menu": "Меню API-ключа",
|
"API key menu": "Меню API-ключа",
|
||||||
"Jump to comment selection": "Перейти к выбору комментария",
|
"Jump to comment selection": "Перейти к выбору комментария",
|
||||||
"Slash commands": "Команды со слешем",
|
"Slash commands": "Команды со слешем",
|
||||||
@@ -1131,6 +1144,9 @@
|
|||||||
"Undo": "Отменить",
|
"Undo": "Отменить",
|
||||||
"Redo": "Повторить",
|
"Redo": "Повторить",
|
||||||
"Backlinks": "Обратные ссылки",
|
"Backlinks": "Обратные ссылки",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
|
"Empty footnote": "Пустая сноска",
|
||||||
"Last updated by": "Последний изменивший",
|
"Last updated by": "Последний изменивший",
|
||||||
"Last updated": "Последнее обновление",
|
"Last updated": "Последнее обновление",
|
||||||
"Stats": "Статистика",
|
"Stats": "Статистика",
|
||||||
@@ -1164,6 +1180,7 @@
|
|||||||
"Page title": "Заголовок страницы",
|
"Page title": "Заголовок страницы",
|
||||||
"Page content": "Содержимое страницы",
|
"Page content": "Содержимое страницы",
|
||||||
"Member actions": "Действия с участником",
|
"Member actions": "Действия с участником",
|
||||||
|
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||||
"Toggle password visibility": "Переключить видимость пароля",
|
"Toggle password visibility": "Переключить видимость пароля",
|
||||||
"Send comment": "Отправить комментарий",
|
"Send comment": "Отправить комментарий",
|
||||||
"Token actions": "Действия с токеном",
|
"Token actions": "Действия с токеном",
|
||||||
@@ -1183,11 +1200,187 @@
|
|||||||
"Removed from favorites": "Удалено из избранного",
|
"Removed from favorites": "Удалено из избранного",
|
||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
|
"Label added": "Метка добавлена",
|
||||||
|
"Label removed": "Метка удалена",
|
||||||
|
"Image updated": "Изображение обновлено",
|
||||||
|
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||||
|
"Member deactivated": "Участник деактивирован",
|
||||||
|
"Member activated": "Участник активирован",
|
||||||
|
"Name is required": "Укажите имя",
|
||||||
|
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||||
|
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||||
|
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||||
|
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||||
|
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
|
"AI chat": "AI-чат",
|
||||||
|
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||||
|
"Ask a question…": "Задайте вопрос…",
|
||||||
|
"Thinking…": "Думаю…",
|
||||||
|
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||||
|
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||||
|
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||||
|
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||||
|
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||||
|
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||||
|
"Public share assistant": "Ассистент публичного доступа",
|
||||||
|
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||||
|
"Public assistant model": "Модель публичного ассистента",
|
||||||
|
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||||
|
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||||
|
"Assistant identity": "Личность ассистента",
|
||||||
|
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||||
|
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||||
|
"Minimize": "Свернуть",
|
||||||
|
"Context size / model limit": "Размер контекста / лимит модели",
|
||||||
|
"Context window (tokens)": "Окно контекста (токены)",
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
|
"AI agent": "AI-агент",
|
||||||
|
"Take a look at the current document": "Посмотри текущий документ",
|
||||||
|
"AI agent is typing…": "AI-агент печатает…",
|
||||||
|
"{{name}} is typing…": "{{name}} печатает…",
|
||||||
|
"Send": "Отправить",
|
||||||
|
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||||
|
"Queue message": "Поставить в очередь",
|
||||||
|
"Remove queued message": "Убрать из очереди",
|
||||||
|
"Send now": "Отправить сейчас",
|
||||||
|
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||||
|
"Stop": "Стоп",
|
||||||
|
"Response stopped.": "Ответ остановлен.",
|
||||||
|
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||||
|
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||||
|
"Chat menu": "Меню чата",
|
||||||
|
"No chats yet.": "Чатов пока нет.",
|
||||||
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
|
"Ask the AI agent…": "Спросите AI-агента…",
|
||||||
|
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||||
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
|
"Something went wrong": "Что-то пошло не так",
|
||||||
|
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||||
|
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||||
|
"Searched pages": "Поиск по страницам",
|
||||||
|
"Read page": "Прочитана страница",
|
||||||
|
"Created page": "Создана страница",
|
||||||
|
"Updated page": "Обновлена страница",
|
||||||
|
"Renamed page": "Переименована страница",
|
||||||
|
"Moved page": "Перемещена страница",
|
||||||
|
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||||
|
"Commented": "Добавлен комментарий",
|
||||||
|
"Resolved comment": "Комментарий решён",
|
||||||
|
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||||
|
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||||
|
"AI agent {{name}}": "AI-агент {{name}}",
|
||||||
|
"Endpoints": "Эндпоинты",
|
||||||
|
"where we fetch models": "откуда мы получаем модели",
|
||||||
|
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||||
|
"Chat / LLM": "Чат / LLM",
|
||||||
|
"root": "корневой",
|
||||||
|
"Semantic search": "Семантический поиск",
|
||||||
|
"Voice / STT": "Голос / STT",
|
||||||
|
"Voice dictation": "Голосовой ввод",
|
||||||
|
"Streaming dictation": "Потоковый голосовой ввод",
|
||||||
|
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||||
|
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||||
|
"Test endpoint": "Проверить эндпоинт",
|
||||||
|
"Save and test": "Сохранить и проверить",
|
||||||
|
"Save endpoints": "Сохранить эндпоинты",
|
||||||
|
"Configured and enabled": "Настроено и включено",
|
||||||
|
"Configured but disabled": "Настроено, но отключено",
|
||||||
|
"Enabled but not configured": "Включено, но не настроено",
|
||||||
|
"Not configured": "Не настроено",
|
||||||
|
"External tools": "Внешние инструменты",
|
||||||
|
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||||
|
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||||
|
"MCP server": "MCP-сервер",
|
||||||
|
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||||
|
"Enable MCP server": "Включить MCP-сервер",
|
||||||
|
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||||
|
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||||
|
"Model": "Модель",
|
||||||
|
"Done": "Готово",
|
||||||
|
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||||
|
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||||
|
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||||
|
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||||
|
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||||
|
"Embedding API key": "API-ключ для эмбеддингов",
|
||||||
|
"Embeddings": "Эмбеддинги",
|
||||||
|
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||||
|
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||||
|
"Reindex now": "Переиндексировать сейчас",
|
||||||
|
"Start dictation": "Начать диктовку",
|
||||||
|
"Stop recording": "Остановить запись",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||||
|
"No microphone found": "Микрофон не найден",
|
||||||
|
"Could not start recording": "Не удалось начать запись",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
|
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||||
|
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||||
|
"Dictation": "Диктовка",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||||
|
"This page is read-only": "Страница открыта только для чтения",
|
||||||
|
"Request format": "Формат запроса",
|
||||||
|
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||||
|
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||||
|
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
|
"Agent role": "Роль агента",
|
||||||
|
"Universal assistant": "Универсальный ассистент",
|
||||||
|
"Add role": "Добавить роль",
|
||||||
|
"Edit role": "Изменить роль",
|
||||||
|
"Role name": "Название роли",
|
||||||
|
"e.g. Proofreader": "напр. Корректор",
|
||||||
|
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||||
|
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||||
|
"Instructions": "Инструкции",
|
||||||
|
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Model provider override": "Переопределение провайдера модели",
|
||||||
|
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||||
|
"Model override": "Переопределение модели",
|
||||||
|
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||||
|
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||||
|
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||||
|
"Start automatically": "Запускать автоматически",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||||
|
"Launch message": "Стартовое сообщение",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||||
|
"Agent roles": "Роли агента",
|
||||||
|
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||||
|
"No roles configured": "Роли не настроены",
|
||||||
|
"Delete role": "Удалить роль",
|
||||||
|
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||||
|
"HTML embed": "HTML-вставка",
|
||||||
|
"Edit HTML embed": "Изменить HTML-вставку",
|
||||||
|
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||||
|
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||||
|
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||||
|
"<script>...</script>": "<script>...</script>",
|
||||||
|
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||||
|
"advanced": "дополнительно",
|
||||||
|
"Enable HTML embed": "Включить HTML-вставки",
|
||||||
|
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||||
|
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||||
|
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||||
|
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||||
|
"Analytics / tracker": "Аналитика / трекер",
|
||||||
|
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||||
|
"Go to login page": "Перейти на страницу входа",
|
||||||
|
"Move to space": "Переместить в пространство",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
"Inline (side by side)": "В ряд",
|
"Inline (side by side)": "В ряд",
|
||||||
@@ -1199,6 +1392,7 @@
|
|||||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||||
|
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||||
"Protocol": "Протокол",
|
"Protocol": "Протокол",
|
||||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||||
@@ -1268,7 +1462,6 @@
|
|||||||
"Retry": "Повторить",
|
"Retry": "Повторить",
|
||||||
"The catalog is empty": "Каталог пуст",
|
"The catalog is empty": "Каталог пуст",
|
||||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
@@ -1281,5 +1474,39 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
"Dismiss": "Не применять",
|
"Dismiss": "Не применять",
|
||||||
"Suggestion dismissed": "Предложение отклонено",
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
|
||||||
|
"Save version": "Сохранить версию",
|
||||||
|
"Ctrl+S": "Ctrl+S",
|
||||||
|
"Version saved": "Версия сохранена",
|
||||||
|
"Already saved as the latest version": "Уже сохранено как последняя версия",
|
||||||
|
"Agent version": "Версия агента",
|
||||||
|
"Boundary": "Граница",
|
||||||
|
"Autosave": "Автосейв",
|
||||||
|
"Only versions": "Только версии",
|
||||||
|
"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,8 +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 {
|
||||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
hasAutoReloaded,
|
||||||
|
markAutoReloaded,
|
||||||
|
recordReloadBreadcrumb,
|
||||||
|
} from "@/lib/reload-guard";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -21,20 +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 once, guarding against a reload loop
|
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
||||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
||||||
// flag is already set we fall through to the manual recovery UI below.
|
// proactive version-coherence path consumes, so a mismatch that arrives on
|
||||||
try {
|
// both paths reloads at most once per window across BOTH). This recovers
|
||||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
// lazy chunk (which would loop) is stopped after the first reload and falls
|
||||||
} catch {
|
// through to the manual recovery UI below. If the shared budget is already
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
// spent this window, or the stamp write fails (storage unavailable), we return
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
// without reloading rather than risk a loop.
|
||||||
return;
|
if (hasAutoReloaded()) return;
|
||||||
}
|
if (!markAutoReloaded()) return;
|
||||||
|
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||||
|
// proactive version-coherence path writes, tagged with this path).
|
||||||
|
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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,8 +58,11 @@ import ConversationList from "@/features/ai-chat/components/conversation-list.ts
|
|||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import {
|
import {
|
||||||
exportAiChat,
|
exportAiChat,
|
||||||
|
getAiChatMessagesDelta,
|
||||||
stopRun,
|
stopRun,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { mergeDeltaRowsIntoPages } from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -86,19 +89,11 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
// #184 phase 1.5 / #430: backstop for the degraded-poll fallback. The poll is
|
// #184 phase 1.5 / #430 / #488: the degraded-poll fallback. The window owns only
|
||||||
// armed when a resume attempt could not attach to the live run and disarmed by the
|
// a DUMB 2.5s timer, gated by an armed flag; the THREAD's run-lifecycle FSM owns
|
||||||
// thread on settle / local stream; this cap is the ONLY backstop against an endless
|
// arm/disarm AND the inactivity cap that turns a stuck run into a `stalled` banner
|
||||||
// tick (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no
|
// (#488 commit 4a — the cap moved into the thread so polling->stalled is a single
|
||||||
// run).
|
// FSM transition; the window no longer silently stops polling at the cap).
|
||||||
//
|
|
||||||
// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes
|
|
||||||
// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll
|
|
||||||
// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is
|
|
||||||
// still making progress (its persisted rows keep changing), and only give up after
|
|
||||||
// this long with NO new activity. A genuinely stuck run produces no row changes, so
|
|
||||||
// the idle cap still bounds it; a long-but-progressing run polls to completion.
|
|
||||||
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
@@ -259,17 +254,13 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[roles],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
// #184 phase 1.5 / #488: degraded-poll fallback. ChatThread's FSM arms this via
|
||||||
// ChatThread could not attach to a still-running run it arms this via
|
// onResumeFallback(true) when it enters a poll-bearing recovery (attach 204 /
|
||||||
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
// starved finish / stop) and disarms it on settle / local stream / stalled. The
|
||||||
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
// window owns ONLY the dumb 2.5s timer; the THREAD owns arm/disarm AND the
|
||||||
|
// inactivity cap (a stuck run -> the thread's `stalled` banner disarms this).
|
||||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||||
// #430: timestamp of the LAST run activity while the poll is armed — stamped on
|
|
||||||
// arm and re-stamped whenever the polled rows change (see the effect below). The
|
|
||||||
// idle cap is measured from this, so a long-but-progressing run keeps polling.
|
|
||||||
const lastActivityAtRef = useRef(0);
|
|
||||||
const onResumeFallback = useCallback((active: boolean): void => {
|
const onResumeFallback = useCallback((active: boolean): void => {
|
||||||
if (active) lastActivityAtRef.current = Date.now();
|
|
||||||
setDegradedPoll(active);
|
setDegradedPoll(active);
|
||||||
}, []);
|
}, []);
|
||||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||||
@@ -281,32 +272,63 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(
|
useAiChatMessagesQuery(
|
||||||
activeChatId ?? undefined,
|
activeChatId ?? undefined,
|
||||||
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
// #491: the full infinite-query no longer POLLS. It seeds the thread ONCE; the
|
||||||
// and while the run is still active (#430: under the INACTIVITY cap, not a
|
// degraded fallback now runs a DELTA poller (below) that augments THIS cache
|
||||||
// fixed-from-start cap); otherwise off. NO error checks (TanStack v5 resets
|
// idempotently, instead of refetching every page (with full parts) every 2.5s.
|
||||||
// fetchFailureCount each fetch, so consecutive errors are not expressible —
|
false,
|
||||||
// and the poll must survive a server restart) and NO tail checks (the
|
// #344: gate on windowOpen too — no message history is fetched while the window
|
||||||
// settled/local-stream semantics live in ChatThread, which disarms via
|
// is closed; it loads when the window opens with an active chat.
|
||||||
// onResumeFallback(false)). The idle cap is the only backstop.
|
|
||||||
() =>
|
|
||||||
degradedPoll === true &&
|
|
||||||
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
|
|
||||||
? 2500
|
|
||||||
: false,
|
|
||||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
|
||||||
// degraded poll runs) while the window is closed; it loads when the window
|
|
||||||
// opens with an active chat.
|
|
||||||
windowOpen,
|
windowOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
// #430: re-stamp the activity clock whenever the polled rows change while the
|
// #491 degraded DELTA poll. While armed (degradedPoll) and the window is open on a
|
||||||
// poll is armed. TanStack keeps the same `messageRows` reference across refetches
|
// chat, poll POST /ai-chat/messages/delta every 2.5s: it returns only the rows
|
||||||
// that return deep-equal data (structural sharing), so a new reference means the
|
// CHANGED since the previous cursor (+ the run fact) in ONE round-trip. We merge
|
||||||
// run genuinely progressed — which extends the inactivity cap above. A stuck run
|
// those rows into the SAME infinite-query cache the thread reads (idempotently by
|
||||||
// yields no reference change, so the cap eventually fires and stops the poll.
|
// id — the delta's overlap window re-delivers rows), so the thread's reconcile
|
||||||
|
// effect follows the detached run to its terminal row from a fraction of the wire
|
||||||
|
// cost. The run-fact settle stays the thread FSM's job (row-status reconcile), so
|
||||||
|
// we do NOT double-poll /run here. Cursor resets when the chat changes / disarms.
|
||||||
|
const deltaCursorRef = useRef<string | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (degradedPoll) lastActivityAtRef.current = Date.now();
|
deltaCursorRef.current = undefined;
|
||||||
}, [degradedPoll, messageRows]);
|
}, [activeChatId, degradedPoll]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!degradedPoll || !windowOpen || !activeChatId) return;
|
||||||
|
const chatId = activeChatId;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await getAiChatMessagesDelta(chatId, deltaCursorRef.current);
|
||||||
|
if (cancelled) return;
|
||||||
|
deltaCursorRef.current = res.cursor;
|
||||||
|
if (res.rows.length > 0) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
AI_CHAT_MESSAGES_RQ_KEY(chatId),
|
||||||
|
(
|
||||||
|
old:
|
||||||
|
| {
|
||||||
|
pages: { items: IAiChatMessageRow[]; meta: unknown }[];
|
||||||
|
pageParams: unknown[];
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) =>
|
||||||
|
old
|
||||||
|
? { ...old, pages: mergeDeltaRowsIntoPages(old.pages, res.rows) }
|
||||||
|
: old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient failure (e.g. a server restart mid-run): swallow and retry on
|
||||||
|
// the next tick — the poll must survive a bounce, like the old dumb refetch.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const id = setInterval(() => void tick(), 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [degradedPoll, windowOpen, activeChatId, queryClient]);
|
||||||
|
|
||||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
// this workspace. When the feature is off no runs are ever created, so the
|
// this workspace. When the feature is off no runs are ever created, so the
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
@@ -47,6 +48,13 @@ interface MessageItemProps {
|
|||||||
* agent's raw query/argument text.
|
* agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
|
||||||
|
* errorText. Defaults to true (internal chat). The public share passes false so
|
||||||
|
* internal detail in a tool error is never painted (belt to the server-side
|
||||||
|
* byte sanitization).
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
/**
|
/**
|
||||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||||
* their href so they become inert text). Defaults to false (internal chat,
|
* their href so they become inert text). Defaults to false (internal chat,
|
||||||
@@ -79,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 (
|
||||||
@@ -125,6 +155,7 @@ function MessageItem({
|
|||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
turnStreaming = false,
|
turnStreaming = false,
|
||||||
@@ -171,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
|
||||||
@@ -219,11 +213,81 @@ function MessageItem({
|
|||||||
part={part as unknown as ToolUiPart}
|
part={part as unknown as ToolUiPart}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
|
showErrors={showErrors}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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. */}
|
||||||
@@ -284,6 +348,7 @@ export function arePropsEqual(
|
|||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.showInput === next.showInput &&
|
prev.showInput === next.showInput &&
|
||||||
|
prev.showErrors === next.showErrors &&
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName &&
|
prev.assistantName === next.assistantName &&
|
||||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ interface MessageListProps {
|
|||||||
* doesn't see the agent's raw query/argument text.
|
* doesn't see the agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
|
||||||
|
* its raw errorText. Defaults to true (internal chat). The public share passes
|
||||||
|
* false so internal detail in a tool error is never painted.
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
/**
|
/**
|
||||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
* the rendered answers (drop their href so they render as inert text).
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
@@ -127,6 +133,7 @@ export default function MessageList({
|
|||||||
emptyState,
|
emptyState,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
@@ -217,6 +224,7 @@ export default function MessageList({
|
|||||||
signature={messageSignature(message)}
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
|
showErrors={showErrors}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,16 @@ interface ToolCallCardProps {
|
|||||||
* the extra summary line, leaving the card (the action log) intact.
|
* the extra summary line, leaving the card (the action log) intact.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to render the tool's raw errorText on a failed call. Defaults to true
|
||||||
|
* (the internal chat, where the operator may debug). The public share passes
|
||||||
|
* false: a tool error string can carry internal detail (an internal page title,
|
||||||
|
* a stack fragment, a provider message). This is the RENDER gate only — the
|
||||||
|
* authoritative fix also sanitizes the bytes server-side (see
|
||||||
|
* PublicShareChatToolsService.forShare), so a share reader never receives raw
|
||||||
|
* error text over the wire, not just never sees it painted (#394).
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +51,7 @@ export default function ToolCallCard({
|
|||||||
part,
|
part,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
@@ -74,7 +85,7 @@ export default function ToolCallCard({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "error" && part.errorText && (
|
{state === "error" && showErrors && part.errorText && (
|
||||||
<Text size="xs" c="red" mt={2}>
|
<Text size="xs" c="red" mt={2}>
|
||||||
{part.errorText}
|
{part.errorText}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -57,6 +57,50 @@ export async function stopRun(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta poll (#491): the chat's message rows changed since `cursor` (a DB-clock
|
||||||
|
* timestamp echoed from the previous poll) plus the current run fact, in ONE
|
||||||
|
* round-trip — the degraded-poll fallback's payload, replacing the old "refetch
|
||||||
|
* ALL infinite-query pages every 2.5s with full parts" poll. Omit `cursor` on the
|
||||||
|
* first poll (returns just a fresh cursor, no rows, to start the chain). The
|
||||||
|
* overlap window guarantees occasional REPEATS, so the caller MUST merge rows
|
||||||
|
* idempotently by id (mergeById). Owner-gated server-side.
|
||||||
|
*/
|
||||||
|
export async function getAiChatMessagesDelta(
|
||||||
|
chatId: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<{
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
cursor: string;
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
}> {
|
||||||
|
const req = await api.post<{
|
||||||
|
rows: IAiChatMessageRow[];
|
||||||
|
cursor: string;
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
}>("/ai-chat/messages/delta", { chatId, cursor });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
||||||
|
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
||||||
|
* and to VERIFY after a supersede mismatch (an observer following a superseded
|
||||||
|
* run asks for the latest run and follows it). Returns the latest run row (with
|
||||||
|
* its `id` and `status`) and its projected assistant message, or `run: null` when
|
||||||
|
* the chat has never had a run. Owner-gated server-side.
|
||||||
|
*/
|
||||||
|
export async function getRun(chatId: string): Promise<{
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}> {
|
||||||
|
const req = await api.post<{
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}>("/ai-chat/run", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# AI-chat run-lifecycle FSM — design spec (#488)
|
||||||
|
|
||||||
|
This is the written design that `run-fsm.ts` implements. It ships in the PR (issue
|
||||||
|
#488 commit 1: "the spec is written FIRST and enters the PR"). It has four parts:
|
||||||
|
(1) the event × state transition table, (2) the map of every `chat-thread.tsx` ref
|
||||||
|
to {FSM state | FSM context | stays data}, (3) the run-fact protocol, (4) the
|
||||||
|
invariants.
|
||||||
|
|
||||||
|
The reducer is a **pure function** `reduce(machine, event) → machine`. The returned
|
||||||
|
machine carries the **command effects** for that transition; a thin runtime in
|
||||||
|
`chat-thread.tsx` dispatches events and executes effects. Because it is pure, the
|
||||||
|
whole machine is enumerable and unit-tested directly (event × state → next state is
|
||||||
|
the observable property) — see `run-fsm.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Event × state transition table
|
||||||
|
|
||||||
|
Phases: `idle | sending | streaming | attaching | reconnecting(attempt,failed) |
|
||||||
|
polling(reason) | stalled | stopping | superseding | error(kind)`.
|
||||||
|
Context (orthogonal): `epoch`, `ownership: local|observer`, `runFact: {runId}|null`,
|
||||||
|
`liveFollow` (are we following a live run we locally streamed — the reconnect
|
||||||
|
ladder — vs a one-shot mount-attach resume? both are `observer`, but a live-follow
|
||||||
|
drop RE-ENTERS the ladder (#488 commit 3) while a mount-resume drop polls).
|
||||||
|
|
||||||
|
Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
||||||
|
|
||||||
|
| Event (source) | From phase(s) | → To phase | Effects / ctx |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SEND_LOCAL` (user send) | idle, error, polling, stalled, reconnecting | sending **†** | `[cancelReconnect, disarmPoll]`, ownership=local |
|
||||||
|
| `STREAM_START{runId}` (SDK `start` metadata) | sending, attaching, reconnecting, superseding | streaming | `[cancelReconnect, disarmPoll]`, runFact←runId |
|
||||||
|
| `FINISH_CLEAN` (onFinish clean) | streaming, … | idle | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||||
|
| `FINISH_ABORT` (onFinish isAbort) | streaming, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4 exits stopping by this DATA) |
|
||||||
|
| `FINISH_DISCONNECT` (observer, NOT liveFollow) | streaming(observer) | polling(disconnect-visible) | `[armPoll]` (a mount-resume drop polls) |
|
||||||
|
| `FINISH_DISCONNECT{hasVisibleContent}` (local drop OR liveFollow) | streaming | reconnecting(1) **†** *iff runFact\|liveFollow* | `[scheduleReconnect(1)]` (+`armPoll` if visible), ownership=observer, liveFollow=true (commit 3: repeatable) |
|
||||||
|
| `FINISH_DISCONNECT` (no runFact, not liveFollow) | streaming | idle | runFact←null (plain terminal "connection lost") |
|
||||||
|
| `STREAM_INCOMPLETE{reason}` (observer starved/torn clean finish) | streaming(observer) | polling(reason) | `[armPoll(reason)]` |
|
||||||
|
| `FINISH_ERROR{kind}` (onFinish isError) | any | error(kind) | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||||
|
| `STREAM_START{runId}` (first assistant frame of a local turn) | sending | streaming | runFact←runId, `[cancelReconnect, disarmPoll]` |
|
||||||
|
| `ATTACH_START{runId}` (mount resume) | **idle only** (F2) | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId; ignored from any non-idle phase |
|
||||||
|
| `ATTACH_LIVE` (attach GET 2xx) | attaching | streaming | — |
|
||||||
|
| `ATTACH_NONE` (attach GET 204/err/throw) | attaching | polling(attach-none) | `[armPoll(attach-none)]` |
|
||||||
|
| `RECONNECT_ATTEMPT{n}` (backoff timer) | reconnecting | reconnecting(n) **†** | `[resumeStream]` |
|
||||||
|
| `RECONNECT_ATTACHED` (reconnect GET 2xx) | reconnecting | streaming | `[cancelReconnect, disarmPoll]` — **counter reset** (commit 3) |
|
||||||
|
| `RECONNECT_NONE` (reconnect GET 204/err), attempt<MAX | reconnecting | reconnecting(n+1) **†** | `[armPoll(attach-none), scheduleReconnect(n+1)]` |
|
||||||
|
| `RECONNECT_NONE`, attempt=MAX | reconnecting | reconnecting(MAX, failed) | `[armPoll(reconnect-exhausted)]` |
|
||||||
|
| `RETRY` (manual, failed banner) | reconnecting(failed) | reconnecting(1) **†** | `[resumeStream]` |
|
||||||
|
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||||
|
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||||
|
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||||
|
| `POLL_IDLE_CAP` (inactivity cap) | stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (Review #4: a Stop-armed poll with no SDK/terminal backstop gets a bounded exit — NOT `stalled`, Stop was already pressed so nothing to retry) |
|
||||||
|
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||||
|
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||||
|
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||||
|
| `SUPERSEDE_REQUESTED{targetRunId}` (interrupt+send) | streaming, reconnecting, polling, error | superseding **†** | `[supersede(target), cancelReconnect, disarmPoll]` |
|
||||||
|
| `SUPERSEDE_READY{runId}` (CAS ok) | superseding | streaming | ownership=local, runFact←runId |
|
||||||
|
| `SUPERSEDE_MISMATCH{currentRunId}` (409 SUPERSEDE_TARGET_MISMATCH) | superseding | error(supersede-mismatch) | `[postRun(verify)]`, runFact←currentRunId |
|
||||||
|
| `SUPERSEDE_TIMEOUT` (409 SUPERSEDE_TIMEOUT) | superseding | error(supersede-timeout) | — (composer keeps text; no auto-retry) |
|
||||||
|
| `SUPERSEDE_INVALID` (409 SUPERSEDE_INVALID) | superseding | error(supersede-invalid) | — |
|
||||||
|
| `RUN_ALREADY_ACTIVE{activeRunId}` (409 A_RUN_ALREADY_ACTIVE, plain POST) | sending | error(run-already-active) | runFact←activeRunId (composer offers supersede; NO auto-retry) |
|
||||||
|
| `DISPOSE` (unmount) | any | idle **†** | `[abortAttach, cancelReconnect, disarmPoll]` (I1/I5 — epoch++ kills late callbacks) |
|
||||||
|
|
||||||
|
**`stopping` honors any finish (re-review MEDIUM):** BEFORE the epoch filter, a
|
||||||
|
stream finish (`FINISH_*`/`STREAM_INCOMPLETE`) arriving in phase `stopping` exits
|
||||||
|
`stopping -> idle` regardless of generation. A plain Stop has no successor stream,
|
||||||
|
so the aborted stream's finish IS the expected end (I4 exit by data) — and it
|
||||||
|
carries the PRE-stop generation (STOP_REQUESTED bumped the epoch), so the filter
|
||||||
|
would otherwise strand the machine in `stopping` (no idle-cap covers it). The filter
|
||||||
|
stays in force for `superseding` (that is the F1 supersede drop).
|
||||||
|
|
||||||
|
**Epoch filter (I1):** the reducer then drops any event carrying an `epoch` that
|
||||||
|
does not equal the current `ctx.epoch`. Outcome events (`STREAM_START`, `ATTACH_*`,
|
||||||
|
`RECONNECT_*`, `SUPERSEDE_*`, **`FINISH_*`/`STREAM_INCOMPLETE`**, `RUN_FACT`) are
|
||||||
|
stamped with the generation the corresponding STREAM started under (the runtime
|
||||||
|
holds a per-owned-stream `turnEpoch`); trigger events (user actions, fresh
|
||||||
|
disconnects) carry no epoch. **F1:** this is what makes a SUPERSEDED stream's late
|
||||||
|
`onFinish` (a dead stream A closing after the CAS started stream B) get dropped, so
|
||||||
|
A cannot drive the live new run into a false reconnect or reset its run-fact. The
|
||||||
|
supersede path additionally ABORTS A and starts B only from A's onFinish (a
|
||||||
|
microtask), because ai@6 `AbstractChat.makeRequest` corrupts overlapping streams
|
||||||
|
(A's `finally` reads then nulls the shared `activeResponse`).
|
||||||
|
|
||||||
|
**Removed events (scope-cut, internal review):** `RUN_SUPERSEDED` (a ghost feature —
|
||||||
|
never dispatched; the observer-superseded case is handled by the degraded poll,
|
||||||
|
which follows the latest rows regardless of runId), `RECONNECT_BEGIN` (reconnect is
|
||||||
|
entered by `FINISH_DISCONNECT`), and `POLL_ACTIVITY` (the window's activity clock was
|
||||||
|
removed when the idle-cap moved into the thread). The reducer and this table now
|
||||||
|
share exactly the dispatched event set.
|
||||||
|
|
||||||
|
### 409-code → event map (the real #487 contract consumed here)
|
||||||
|
|
||||||
|
| Server response | Event dispatched | error kind → banner |
|
||||||
|
|---|---|---|
|
||||||
|
| 409 `A_RUN_ALREADY_ACTIVE` (+ body.activeRunId) | `RUN_ALREADY_ACTIVE{activeRunId}` | run-already-active → "already answering / interrupt & send" |
|
||||||
|
| 409 `SUPERSEDE_TARGET_MISMATCH` (+ body.activeRunId) | `SUPERSEDE_MISMATCH{currentRunId}` | supersede-mismatch → verify via /run |
|
||||||
|
| 409 `SUPERSEDE_TIMEOUT` | `SUPERSEDE_TIMEOUT` | supersede-timeout → "couldn't interrupt in time, resend" |
|
||||||
|
| 409 `SUPERSEDE_INVALID` | `SUPERSEDE_INVALID` | supersede-invalid → "couldn't interrupt this run" |
|
||||||
|
| 503 `A_RUN_BEGIN_FAILED` | `FINISH_ERROR{begin-failed}` | begin-failed → "could not start, temporary" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ref-map — every `chat-thread.tsx` ref → its new home (MIGRATION RESOLVED)
|
||||||
|
|
||||||
|
The migration is COMPLETE: the 13 run-lifecycle FLAGS below are GONE from
|
||||||
|
`chat-thread.tsx` (collapsed into FSM phase/ctx/effects, or deleted). What remains
|
||||||
|
are identity/data mirrors, effect-owned controllers/timers, and ONE React-liveness
|
||||||
|
bit — none of which is a run-lifecycle flag, so the post-merge "no new flags" rule
|
||||||
|
holds. **Pending column: empty.**
|
||||||
|
|
||||||
|
| # | Old ref | Resolved to | Where now |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `reconcileTailRef` | **FSM phase** | reconcile-merge gated on `phase ∈ {polling, reconnecting, stopping}` |
|
||||||
|
| 2 | `noStreamHandledRef` | **FSM epoch (I1)** | the attach outcome's epoch guard drops the stale/second outcome |
|
||||||
|
| 3 | `onNoActiveStreamRef` | **FSM event** | transport → `handleAttachOutcome` dispatches `ATTACH_NONE`/`RECONNECT_NONE` |
|
||||||
|
| 4 | `onReconnectAttachedRef` | **FSM event** | transport dispatches `ATTACH_LIVE` / `RECONNECT_ATTACHED` |
|
||||||
|
| 5 | `resumedTurnRef` + `resumedTurn` state | **FSM ctx `ownership`** | `ownership==='observer'` ⇒ never flush; hides "Send now" |
|
||||||
|
| 6 | `reconnectStateRef` + `reconnectState` state | **FSM phase** | `reconnecting(attempt,failed)` renders the banner |
|
||||||
|
| 7 | `reconnectTimerRef` | **effect-owned timer** | owned by `scheduleReconnect`/`cancelReconnect` effects (not a flag) |
|
||||||
|
| 8 | `flushOnAbortRef` | **DELETED** | the stop→flush dance is replaced by the CAS supersede (commit 5) |
|
||||||
|
| 9 | `interruptNextSendRef` | **DELETED** | the server injects the interrupt note from the supersede itself |
|
||||||
|
| 10 | `supersedeRetryRef` | **DELETED** (commit 5) | the client 409 retry ladder is gone; CAS supersede replaces it |
|
||||||
|
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
||||||
|
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
||||||
|
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
||||||
|
| 14–15 | `anchorRef {id, stepsPersisted}` | **data** (attachStrategy) | #491 tail-only: replaced `stripRef`/`strippedRowRef`. The PERSISTED assistant row that pins the run (server invariant 6) + its step frontier N; feeds `?anchor=<id>&n=<stepsPersisted>`. No strip — the seed keeps every row; entering reconnecting re-seeds from persist |
|
||||||
|
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
||||||
|
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
||||||
|
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
||||||
|
| NEW | `idleCapTimerRef` | **effect-owned timer** | the stalled inactivity cap → `POLL_IDLE_CAP` (commit 4a); not a flag |
|
||||||
|
|
||||||
|
Net: the 13 lifecycle flags (#1–#13) are eliminated: **8** → FSM phase/ctx/epoch/event
|
||||||
|
(#1–#6, #11, #13), **3** deleted (#8/#9/#10), **`reconnectTimerRef` (#7)** becomes an
|
||||||
|
effect-owned controller, and **`mountedRef` (#12)** is retained as React liveness
|
||||||
|
(8 + 3 + 1 + 1 = 13). (`attachAbortRef` (#16) is outside the #1–#13 set — it was
|
||||||
|
already an effect-owned controller.) Two effect-owned timers + one send-plumbing data
|
||||||
|
ref are added — none is a boolean lifecycle latch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Run-fact protocol (`runFact: {runId} | null`) — I3
|
||||||
|
|
||||||
|
"A run is active" is first-class from the SERVER, not inferred from an assistant
|
||||||
|
message. Sources, in the order they update `ctx.runFact`:
|
||||||
|
|
||||||
|
1. **Init (mount):** `POST /ai-chat/run { chatId }` → `{ run, message }`. A `run`
|
||||||
|
with a non-terminal `status` seeds `runFact = { runId: run.id }`; a null/terminal
|
||||||
|
run seeds `null`. This is what arms the resume attempt (`ATTACH_START`) — the
|
||||||
|
attempt is armed ONLY on a positive fact (commit 4b: a user-tail with no active
|
||||||
|
run no longer arms a pointless poll on every open).
|
||||||
|
2. **Live update:** the `start` stream metadata carries `runId` → `STREAM_START{runId}`.
|
||||||
|
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
||||||
|
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
||||||
|
which cancels recovery (I3 fresh-negative gate).
|
||||||
|
4. **Poll (#491, implemented):** the degraded poll now hits the delta endpoint
|
||||||
|
(`POST /ai-chat/messages/delta`), which ALREADY carries the run fact
|
||||||
|
(`run: {id, status} | null`) alongside the changed rows. The client does NOT yet
|
||||||
|
consume that run field — it still drives to a terminal ROW (merged by id),
|
||||||
|
dispatched as `POLL_TERMINAL` — so the run field rides the wire for a future
|
||||||
|
client that settles straight off it.
|
||||||
|
|
||||||
|
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
||||||
|
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants
|
||||||
|
|
||||||
|
- **I1 — Epoch (generation counter).** Every command-emitting transition bumps
|
||||||
|
`ctx.epoch`; every async outcome event carries its issuing epoch; the reducer
|
||||||
|
drops stale-epoch outcomes. Replaces the one-shot-ref zoo (`noStreamHandledRef`,
|
||||||
|
the flush/interrupt/supersede one-shots, the `mountedRef` late-callback gate).
|
||||||
|
- **I2 — Ownership is context, not state.** `local | observer` is orthogonal to the
|
||||||
|
transport phase. The queue flushes ONLY under local ownership; an observer
|
||||||
|
following a detached run never flushes (was `resumedTurnRef`).
|
||||||
|
- **I3 — Run-fact is first-class from the server.** Reconnect is entered by the
|
||||||
|
run-fact, not by an assistant message (commit 2). A fresh negative fact cancels
|
||||||
|
recovery.
|
||||||
|
- **I4 — Exit `stopping` by DATA.** A terminal row / negative run-fact / terminal
|
||||||
|
finish exits `stopping`, never the stopRun HTTP response (which returns after the
|
||||||
|
abort but before finalization — keying off it would unlock the composer on a 409).
|
||||||
|
- **I5 — Dispose protocol.** Command controllers (attach GET, POST /stream, POST
|
||||||
|
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
||||||
|
render-phase refs. A client abort of an already-sent POST does not cancel the
|
||||||
|
server action, so disarming on unmount is safe.
|
||||||
|
- **attachStrategy** is behind the `resumeStream` effect; #491 swapped it to
|
||||||
|
tail-only (`?anchor=&n=`, `anchorRef` data) WITHOUT touching the FSM. Entering
|
||||||
|
reconnecting always re-seeds from persist; on a getRun failure the live partial
|
||||||
|
is dropped + replay-from-start so it is never the tail-apply base (no #137/#161
|
||||||
|
duplication).
|
||||||
|
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
reduce,
|
||||||
|
initialMachine,
|
||||||
|
reconnectDelayMs,
|
||||||
|
RECONNECT_MAX_ATTEMPTS,
|
||||||
|
type Machine,
|
||||||
|
type Effect,
|
||||||
|
type Event,
|
||||||
|
} from "./run-fsm";
|
||||||
|
|
||||||
|
// Drive a sequence of events through the reducer, returning the final machine.
|
||||||
|
function run(m: Machine, ...events: Event[]): Machine {
|
||||||
|
return events.reduce(reduce, m);
|
||||||
|
}
|
||||||
|
function withRunFact(runId = "run-1"): Machine {
|
||||||
|
return {
|
||||||
|
...initialMachine(),
|
||||||
|
ctx: { epoch: 0, ownership: "local", runFact: { runId }, liveFollow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function effectTypes(m: Machine): string[] {
|
||||||
|
return m.effects.map((e) => e.type);
|
||||||
|
}
|
||||||
|
function hasEffect(m: Machine, type: Effect["type"]): boolean {
|
||||||
|
return m.effects.some((e) => e.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run-fsm — epoch invariant (I1)", () => {
|
||||||
|
it("drops an outcome carrying a stale epoch", () => {
|
||||||
|
// A command bumps the epoch; an outcome stamped with the OLD epoch is dropped.
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" }); // epoch 0->1, attaching
|
||||||
|
expect(m0.ctx.epoch).toBe(1);
|
||||||
|
expect(m0.phase.name).toBe("attaching");
|
||||||
|
// A late ATTACH_LIVE from a SUPERSEDED attempt (epoch 0) must NOT drive us.
|
||||||
|
const stale = reduce(m0, { type: "ATTACH_LIVE", epoch: 0 });
|
||||||
|
expect(stale.phase.name).toBe("attaching");
|
||||||
|
expect(stale.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an outcome carrying the current epoch", () => {
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const live = reduce(m0, { type: "ATTACH_LIVE", epoch: m0.ctx.epoch });
|
||||||
|
expect(live.phase.name).toBe("streaming");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an outcome with no epoch is never dropped (trigger events)", () => {
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const disposed = reduce(m0, { type: "DISPOSE" });
|
||||||
|
expect(disposed.phase.name).toBe("idle");
|
||||||
|
expect(hasEffect(disposed, "abortAttach")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every command-transition increments the epoch exactly once", () => {
|
||||||
|
let m = initialMachine();
|
||||||
|
const before = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "SEND_LOCAL" });
|
||||||
|
expect(m.ctx.epoch).toBe(before + 1);
|
||||||
|
m = reduce(m, { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.ctx.epoch).toBe(before + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — local turn", () => {
|
||||||
|
it("SEND_LOCAL → sending, local ownership, cancels recovery", () => {
|
||||||
|
const m = reduce(withRunFact(), { type: "SEND_LOCAL" });
|
||||||
|
expect(m.phase.name).toBe("sending");
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
expect(effectTypes(m)).toEqual(
|
||||||
|
expect.arrayContaining(["cancelReconnect", "disarmPoll"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STREAM_START adopts the runId into the run-fact and goes streaming", () => {
|
||||||
|
const m = run(initialMachine(), { type: "SEND_LOCAL" });
|
||||||
|
const s = reduce(m, { type: "STREAM_START", runId: "run-9", epoch: m.ctx.epoch });
|
||||||
|
expect(s.phase.name).toBe("streaming");
|
||||||
|
expect(s.ctx.runFact).toEqual({ runId: "run-9" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_CLEAN → idle, run-fact cleared, poll/reconnect disarmed", () => {
|
||||||
|
const streaming = run(initialMachine(), { type: "SEND_LOCAL" }, { type: "STREAM_START", runId: "r" });
|
||||||
|
const done = reduce(streaming, { type: "FINISH_CLEAN" });
|
||||||
|
expect(done.phase.name).toBe("idle");
|
||||||
|
expect(done.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 2 — SSE break BEFORE the first assistant frame must still recover.
|
||||||
|
describe("run-fsm — commit 2: reconnect by run-fact, not by assistant message", () => {
|
||||||
|
it("FINISH_DISCONNECT with an active run-fact → reconnecting (even with no visible content)", () => {
|
||||||
|
// Setup-phase break: no assistant frame yet, but a run-fact exists.
|
||||||
|
const streaming = withRunFact("run-2");
|
||||||
|
const m = reduce(streaming, {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: false,
|
||||||
|
epoch: streaming.ctx.epoch,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||||
|
// No visible content -> no poll arm yet (the reconnect ladder rebuilds it).
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_DISCONNECT WITH visible content also arms the poll", () => {
|
||||||
|
const m = reduce(withRunFact("run-2"), {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: true,
|
||||||
|
epoch: 0,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_DISCONNECT with NO run-fact → idle (plain connection-lost)", () => {
|
||||||
|
const m = reduce(initialMachine(), {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: true,
|
||||||
|
epoch: 0,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 3 — a SECOND break after a successful re-attach starts a NEW ladder.
|
||||||
|
describe("run-fsm — commit 3: repeated reconnect cycles", () => {
|
||||||
|
it("two breaks in a row produce two reconnect cycles (counter resets on attach)", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
// First break -> reconnecting(1).
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
// Attempt fires, re-attaches live.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
// SECOND break: the counter was reset, so a fresh ladder starts at attempt 1
|
||||||
|
// (the old one-shot !wasResumed gate would have sent this to silent poll).
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a MOUNT-attach observer drop falls to POLL, not the reconnect ladder", () => {
|
||||||
|
// Distinguishes commit 3 from a one-shot resume: an observer that never
|
||||||
|
// live-followed (liveFollow false) polls on a drop.
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(m.ctx.liveFollow).toBe(false);
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STREAM_INCOMPLETE (observer starved/torn finish) → polling", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "STREAM_INCOMPLETE", reason: "starved", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "polling", reason: "starved" });
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveFollow is set on the first local drop and kept across a re-attach", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(true);
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(true); // kept — so a second drop reconnects
|
||||||
|
// A clean finish clears it.
|
||||||
|
m = reduce(m, { type: "FINISH_CLEAN", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RECONNECT_NONE backs off through the ladder, then fails at the cap", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
for (let n = 1; n < RECONNECT_MAX_ATTEMPTS; n++) {
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: n, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") {
|
||||||
|
expect(m.phase.attempt).toBe(n + 1);
|
||||||
|
expect(m.phase.failed).toBe(false);
|
||||||
|
}
|
||||||
|
// The belt-and-suspenders poll is armed each failed attempt.
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
}
|
||||||
|
// Final attempt fails -> failed banner (Retry), poll armed.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: RECONNECT_MAX_ATTEMPTS, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.failed).toBe(true);
|
||||||
|
// RETRY restarts at attempt 1.
|
||||||
|
m = reduce(m, { type: "RETRY" });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") {
|
||||||
|
expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(m.phase.failed).toBe(false);
|
||||||
|
}
|
||||||
|
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnectDelayMs is the exponential backoff 1s,2s,4s,8s,16s", () => {
|
||||||
|
expect([1, 2, 3, 4, 5].map(reconnectDelayMs)).toEqual([1000, 2000, 4000, 8000, 16000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 4 — polling stalled-state + user-tail gating.
|
||||||
|
describe("run-fsm — commit 4: stalled + run-fact gating", () => {
|
||||||
|
it("POLL_IDLE_CAP: polling → stalled with a banner (poll disarmed), not silent", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
expect(m.phase.name).toBe("stalled");
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RETRY from stalled re-arms the poll", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
m = reduce(m, { type: "RETRY" });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh NEGATIVE run-fact while attaching cancels recovery (user-tail, no active run)", () => {
|
||||||
|
// The mount POST /run returns no active run: attaching → idle, no poll armed.
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a negative run-fact while polling stops the poll", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POLL_TERMINAL settles polling → idle (I4 data-driven exit)", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 5 — error classification + supersede CAS transitions.
|
||||||
|
describe("run-fsm — commit 5: supersede CAS + error classification", () => {
|
||||||
|
it("SUPERSEDE_REQUESTED → superseding, fires the CAS effect, bumps epoch", () => {
|
||||||
|
const streaming = withRunFact("run-old");
|
||||||
|
const m = reduce(streaming, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
expect(m.phase.name).toBe("superseding");
|
||||||
|
expect(m.ctx.epoch).toBe(streaming.ctx.epoch + 1);
|
||||||
|
const sup = m.effects.find((e) => e.type === "supersede");
|
||||||
|
expect(sup).toEqual({ type: "supersede", targetRunId: "run-old" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_READY → streaming as the new local owner", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_READY", runId: "run-new", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_MISMATCH → error(supersede-mismatch) + verify via /run (no blind banner)", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_MISMATCH", currentRunId: "run-x", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-mismatch" });
|
||||||
|
expect(hasEffect(m, "postRun")).toBe(true);
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-x" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_TIMEOUT → error(supersede-timeout), no auto-retry effect", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-timeout" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_INVALID → error(supersede-invalid)", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_INVALID", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-invalid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale SUPERSEDE outcome from a superseded epoch is dropped", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
const supersedingEpoch = m.ctx.epoch;
|
||||||
|
// The user retriggers, bumping the epoch again.
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
// The first CAS's late TIMEOUT (old epoch) must NOT knock us out of superseding.
|
||||||
|
const late = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: supersedingEpoch });
|
||||||
|
expect(late.phase.name).toBe("superseding");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RUN_ALREADY_ACTIVE (plain POST gate) → error(run-already-active), no retry effect", () => {
|
||||||
|
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), { type: "RUN_ALREADY_ACTIVE" });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#497/S4: RUN_ALREADY_ACTIVE{activeRunId} ADOPTS the server's active run as the run-fact", () => {
|
||||||
|
// The server sends `activeRunId` so a later supersede can TARGET that run
|
||||||
|
// instead of a blind promote+abort. Absorb it into runFact.
|
||||||
|
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||||
|
type: "RUN_ALREADY_ACTIVE",
|
||||||
|
activeRunId: "run-foreign",
|
||||||
|
});
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-foreign" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#497/S4: RUN_ALREADY_ACTIVE without an activeRunId keeps the prior run-fact", () => {
|
||||||
|
const seeded = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||||
|
type: "RUN_FACT",
|
||||||
|
runFact: { runId: "run-prior" },
|
||||||
|
});
|
||||||
|
const m = reduce(seeded, { type: "RUN_ALREADY_ACTIVE" });
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-prior" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 F2 — a late mount `getRun → ATTACH_START` must not hijack a local turn.
|
||||||
|
describe("run-fsm — F2: ATTACH_START only from idle", () => {
|
||||||
|
it("ATTACH_START from a local `sending` turn is ignored (no observer hijack)", () => {
|
||||||
|
const sending = reduce(initialMachine(), { type: "SEND_LOCAL" }); // idle -> sending, local
|
||||||
|
const m = reduce(sending, { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.phase.name).toBe("sending");
|
||||||
|
expect(m.ctx.ownership).toBe("local"); // NOT flipped to observer
|
||||||
|
expect(m.effects).toEqual([]); // no resumeStream
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ATTACH_START from idle attaches as normal", () => {
|
||||||
|
const m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.phase.name).toBe("attaching");
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — stop (I4: exit by data)", () => {
|
||||||
|
it("STOP_REQUESTED → stopping, fires stopRun + abortAttach, no data-independent exit", () => {
|
||||||
|
const m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.phase.name).toBe("stopping");
|
||||||
|
expect(effectTypes(m)).toEqual(expect.arrayContaining(["stopRun", "abortAttach"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on the aborted stream's finish carrying the PRE-STOP epoch", () => {
|
||||||
|
// MEDIUM (#488 re-review): STOP_REQUESTED is a command that BUMPS the epoch, but
|
||||||
|
// the runtime stamps the aborted stream's onFinish with the stream's START (pre-
|
||||||
|
// stop) generation — exactly what the component sends. `stopping` must HONOR
|
||||||
|
// that finish regardless of generation (no idle-cap covers `stopping`).
|
||||||
|
// MUTATION-VERIFY: remove the honor-in-`stopping` branch and this hangs in
|
||||||
|
// `stopping` (the epoch filter drops the pre-stop finish) -> red.
|
||||||
|
const preStopEpoch = withRunFact().ctx.epoch; // E1 (the stream's start epoch)
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" }); // E1 -> E2, stopping
|
||||||
|
expect(m.ctx.epoch).toBe(preStopEpoch + 1);
|
||||||
|
m = reduce(m, { type: "FINISH_ABORT", epoch: preStopEpoch }); // NOT the current epoch
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on a clean finish carrying the pre-stop epoch too", () => {
|
||||||
|
const preStopEpoch = withRunFact().ctx.epoch;
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
m = reduce(m, { type: "FINISH_CLEAN", epoch: preStopEpoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on a negative run-fact (data)", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #4: `stopping` arms the poll but had no inactivity backstop.
|
||||||
|
it("review-4: POLL_IDLE_CAP in `stopping` exits to idle (bounded), NOT stalled", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.phase.name).toBe("stopping");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
// MUTATION-VERIFY: drop the `stopping` branch in POLL_IDLE_CAP and this hangs
|
||||||
|
// in `stopping` (poll forever) -> red.
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #1: positive attach outcomes must be guarded by the SOURCE phase — the
|
||||||
|
// epoch filter alone is insufficient because POLL_TERMINAL uses to() (no epoch
|
||||||
|
// bump) and does not abort the in-flight GET.
|
||||||
|
describe("run-fsm — review-1: attach outcomes guarded by source phase", () => {
|
||||||
|
it("a late RECONNECT_ATTACHED after POLL_TERMINAL stays idle (no phantom streaming)", () => {
|
||||||
|
let m = withRunFact("run-1");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch }); // attach GET
|
||||||
|
const epoch = m.ctx.epoch;
|
||||||
|
// The armed degraded poll reaches the terminal row FIRST (epoch unchanged).
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.epoch).toBe(epoch); // POLL_TERMINAL did NOT bump the epoch
|
||||||
|
// The slow GET returns live 2xx under the SAME epoch — must NOT resurrect.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a late ATTACH_LIVE / ATTACH_NONE after leaving `attaching` is ignored", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const epoch = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch }); // attaching -> polling
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" }); // -> idle (epoch unchanged)
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch }); // late 2xx, same epoch
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
// And a late ATTACH_NONE (not `attaching`) is a no-op too.
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #2: every terminal transition resets ownership to local.
|
||||||
|
describe("run-fsm — review-2: terminal transitions reset ownership to local", () => {
|
||||||
|
const observer = (): Machine => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
it("FINISH_CLEAN resets ownership", () => {
|
||||||
|
const m = reduce(observer(), { type: "FINISH_CLEAN", epoch: observer().ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
it("FINISH_ERROR / POLL_TERMINAL / RUN_FACT(null) reset ownership", () => {
|
||||||
|
let o = observer();
|
||||||
|
expect(reduce(o, { type: "FINISH_ERROR", kind: "stream", epoch: o.ctx.epoch }).ctx.ownership).toBe("local");
|
||||||
|
// POLL_TERMINAL from an observer polling phase
|
||||||
|
let p = reduce(observer(), { type: "STREAM_INCOMPLETE", reason: "starved", epoch: observer().ctx.epoch });
|
||||||
|
expect(reduce(p, { type: "POLL_TERMINAL" }).ctx.ownership).toBe("local");
|
||||||
|
// RUN_FACT(null) from an observer attaching phase
|
||||||
|
let a = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(reduce(a, { type: "RUN_FACT", runFact: null, epoch: a.ctx.epoch }).ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — ownership (I2) is context, orthogonal to phase", () => {
|
||||||
|
it("attach/reconnect set observer; send/supersede-ready set local", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
expect(m.ctx.ownership).toBe("observer"); // still observing a detached run
|
||||||
|
// A local send flips ownership back to local.
|
||||||
|
m = reduce(m, { type: "SEND_LOCAL" });
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — dispose (I5)", () => {
|
||||||
|
it("DISPOSE from any phase aborts controllers and bumps epoch", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const before = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "DISPOSE" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.epoch).toBe(before + 1);
|
||||||
|
expect(effectTypes(m)).toEqual(
|
||||||
|
expect.arrayContaining(["abortAttach", "cancelReconnect", "disarmPoll"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* Run-lifecycle finite state machine for a single AI-chat thread (#488).
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* The resume/reconnect/poll/stop/supersede lifecycle used to be spread across
|
||||||
|
* ~26 `useRef` one-shot flags in `chat-thread.tsx`, each disarmed "on every
|
||||||
|
* path". Ownerless flag combinations produced silent UI freezes, and every fix
|
||||||
|
* added another ref (the #381 -> #432 -> #456 spiral). This module replaces that
|
||||||
|
* ref-zoo with ONE pure reducer whose transitions are enumerable and unit-
|
||||||
|
* testable in isolation (event x state -> next state is the observable property).
|
||||||
|
*
|
||||||
|
* The reducer is PURE: it owns no timers, no fetches, no React state. It maps
|
||||||
|
* `(machine, event) -> machine`, where the returned machine carries the list of
|
||||||
|
* COMMAND EFFECTS to run for that transition. A thin runtime in `chat-thread.tsx`
|
||||||
|
* dispatches events (from SDK callbacks / HTTP outcomes) and executes the
|
||||||
|
* effects (attach GET, POST /stream, POST /run, POST /stop, backoff timers,
|
||||||
|
* poll arm/disarm). The runtime lives in a THREAD, not the window, so a late SDK
|
||||||
|
* callback dies with the owner (kills the "event from a dead view" class, #161).
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* INVARIANTS (see run-fsm.spec.md for the full spec + tables)
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* I1 EPOCH (generation counter). Commands (`resumeStream`, `postRun`, `stop`,
|
||||||
|
* `supersede`, `scheduleReconnect`) are async; their outcomes arrive on the
|
||||||
|
* SAME SDK/HTTP callbacks. Every command-emitting transition increments
|
||||||
|
* `ctx.epoch`; every OUTCOME event carries the epoch it was issued under;
|
||||||
|
* the reducer DROPS an outcome whose epoch != the current epoch. This is
|
||||||
|
* what the one-shot-ref zoo used to approximate by hand.
|
||||||
|
* I2 OWNERSHIP is a CONTEXT FIELD (`'local' | 'observer'`), not a state —
|
||||||
|
* orthogonal to the transport phase. The queue is flushed ONLY by a local
|
||||||
|
* owner (an observer following a detached run never flushes).
|
||||||
|
* I3 RUN-FACT ("a run is active") is first-class from the server: `runFact`
|
||||||
|
* holds the server-confirmed active run id (POST /run on mount, the `start`
|
||||||
|
* metadata runId, attach outcomes). Reconnect is entered by the RUN-FACT,
|
||||||
|
* not by the presence of an assistant message (#488 commit 2). A fresh
|
||||||
|
* negative fact (null) cancels reconnect immediately.
|
||||||
|
* I4 Exit `stopping` by DATA (a terminal row / negative run-fact), NEVER by the
|
||||||
|
* stopRun HTTP response (which returns after abort, before finalization).
|
||||||
|
* I5 Command controllers are effect-owned (abort in cleanup), NOT render-phase
|
||||||
|
* refs — expressed here as the `abortAttach` effect on disposing transitions.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phases (the transport lifecycle). Ownership / runFact are CONTEXT, not here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Why the degraded poll is the active recovery. */
|
||||||
|
export type PollReason =
|
||||||
|
| "attach-none" // mount attach returned 204 / error — nothing live to attach
|
||||||
|
| "starved" // a resumed finish carried no visible content
|
||||||
|
| "disconnect-visible" // a live disconnect WITH on-screen content — poll to terminal
|
||||||
|
| "reconnect-exhausted"; // the live re-attach ladder gave up
|
||||||
|
|
||||||
|
/** The classified error kind (drives the banner text + composer behavior). */
|
||||||
|
export type ErrorKind =
|
||||||
|
| "stream" // a generic provider/network stream error (useChat error)
|
||||||
|
| "run-already-active" // 409 A_RUN_ALREADY_ACTIVE (a plain POST hit the gate)
|
||||||
|
| "supersede-mismatch" // 409 SUPERSEDE_TARGET_MISMATCH (CAS target moved)
|
||||||
|
| "supersede-timeout" // 409 SUPERSEDE_TIMEOUT (old run did not settle in W)
|
||||||
|
| "supersede-invalid" // 409 SUPERSEDE_INVALID (bad supersede target)
|
||||||
|
| "begin-failed"; // 503 A_RUN_BEGIN_FAILED (could not start the run)
|
||||||
|
|
||||||
|
export type Phase =
|
||||||
|
| { name: "idle" }
|
||||||
|
| { name: "sending" } // local POST in flight, before the first frame
|
||||||
|
| { name: "streaming" } // receiving frames
|
||||||
|
| { name: "attaching" } // mount-time attach GET in flight
|
||||||
|
| { name: "reconnecting"; attempt: number; failed: boolean }
|
||||||
|
| { name: "polling"; reason: PollReason }
|
||||||
|
| { name: "stalled" } // poll hit the inactivity cap — banner + Retry
|
||||||
|
| { name: "stopping" }
|
||||||
|
| { name: "superseding" }
|
||||||
|
| { name: "error"; kind: ErrorKind };
|
||||||
|
|
||||||
|
export type Ownership = "local" | "observer";
|
||||||
|
|
||||||
|
/** The server-confirmed active run, or null when no run is active. */
|
||||||
|
export type RunFact = { runId: string } | null;
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
/** I1: generation counter — every command-transition increments it. */
|
||||||
|
epoch: number;
|
||||||
|
/** I2: does THIS client own the turn's writes (local streamer) or observe? */
|
||||||
|
ownership: Ownership;
|
||||||
|
/** I3: the server-confirmed active run. */
|
||||||
|
runFact: RunFact;
|
||||||
|
/**
|
||||||
|
* Are we FOLLOWING a live run we were locally streaming (the reconnect ladder),
|
||||||
|
* as opposed to a one-shot mount-attach resume? Both are `ownership: 'observer'`,
|
||||||
|
* but they recover DIFFERENTLY on a drop: a live-follow drop RE-ENTERS the
|
||||||
|
* reconnect ladder (#488 commit 3 — the second break after a successful re-attach
|
||||||
|
* must reconnect again, not fall to silent poll), while a mount-resume drop falls
|
||||||
|
* to the degraded poll. This is the ctx bit that separates the two WITHOUT a new
|
||||||
|
* component ref (it is why commit 3 needs the FSM, not a surgical patch).
|
||||||
|
*/
|
||||||
|
liveFollow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
phase: Phase;
|
||||||
|
ctx: Ctx;
|
||||||
|
/** Command effects to run for the transition that produced THIS machine.
|
||||||
|
* The runtime executes them and does not read them again. */
|
||||||
|
effects: Effect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command effects (the reducer's only side-channel — executed by the runtime).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Effect =
|
||||||
|
/** POST /run to (re)establish or verify the run-fact. `reason` is diagnostic. */
|
||||||
|
| { type: "postRun"; reason: "mount" | "verify" }
|
||||||
|
/** Trigger the SDK `resumeStream()` (attach GET via prepareReconnectToStream). */
|
||||||
|
| { type: "resumeStream" }
|
||||||
|
/** Schedule a reconnect attempt after a backoff, then dispatch RECONNECT_ATTEMPT. */
|
||||||
|
| { type: "scheduleReconnect"; attempt: number; delayMs: number }
|
||||||
|
/** Cancel any pending reconnect backoff timer. */
|
||||||
|
| { type: "cancelReconnect" }
|
||||||
|
/** Arm the degraded poll (the window's dumb timer follows the run in the DB). */
|
||||||
|
| { type: "armPoll"; reason: PollReason }
|
||||||
|
/** Disarm the degraded poll. */
|
||||||
|
| { type: "disarmPoll" }
|
||||||
|
/** POST /stop the chat's active run (authoritative detached-run stop). */
|
||||||
|
| { type: "stopRun" }
|
||||||
|
/** POST /stream { supersede: { runId } } — the CAS "interrupt and send now". */
|
||||||
|
| { type: "supersede"; targetRunId: string }
|
||||||
|
/** Abort the in-flight attach/reconnect GET controller (dispose / observer stop). */
|
||||||
|
| { type: "abortAttach" };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events. An OUTCOME event MAY carry `epoch`; if it does and it does not equal
|
||||||
|
// the current epoch, the reducer drops it (I1). Trigger events (user actions,
|
||||||
|
// fresh disconnects) carry no epoch and are never dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Event =
|
||||||
|
// -- local turn --
|
||||||
|
| { type: "SEND_LOCAL" }
|
||||||
|
| { type: "STREAM_START"; runId?: string; epoch?: number }
|
||||||
|
/** An OBSERVER's attached stream ended WITHOUT reaching terminal (a starved
|
||||||
|
* clean replay, or a torn resume) — fall to the degraded poll to drive the row
|
||||||
|
* to its real terminal state. (A live-follow drop uses FINISH_DISCONNECT.) */
|
||||||
|
| { type: "STREAM_INCOMPLETE"; reason: PollReason; epoch?: number }
|
||||||
|
| { type: "FINISH_CLEAN"; epoch?: number }
|
||||||
|
| { type: "FINISH_ABORT"; epoch?: number }
|
||||||
|
| { type: "FINISH_DISCONNECT"; hasVisibleContent: boolean; epoch?: number }
|
||||||
|
| { type: "FINISH_ERROR"; kind: ErrorKind; epoch?: number }
|
||||||
|
// -- mount attach (resume) --
|
||||||
|
| { type: "ATTACH_START"; runId?: string }
|
||||||
|
| { type: "ATTACH_LIVE"; epoch?: number }
|
||||||
|
| { type: "ATTACH_NONE"; epoch?: number }
|
||||||
|
// -- reconnect after a live disconnect (entered by FINISH_DISCONNECT, #488 c2) --
|
||||||
|
| { type: "RECONNECT_ATTEMPT"; attempt: number; epoch?: number }
|
||||||
|
| { type: "RECONNECT_ATTACHED"; epoch?: number }
|
||||||
|
| { type: "RECONNECT_NONE"; epoch?: number }
|
||||||
|
| { type: "RETRY" }
|
||||||
|
// -- degraded poll --
|
||||||
|
| { type: "POLL_TERMINAL" }
|
||||||
|
| { type: "POLL_IDLE_CAP" }
|
||||||
|
// -- run-fact (server-confirmed active run) --
|
||||||
|
| { type: "RUN_FACT"; runFact: RunFact; epoch?: number }
|
||||||
|
// -- stop --
|
||||||
|
| { type: "STOP_REQUESTED" }
|
||||||
|
// -- supersede (CAS) --
|
||||||
|
| { type: "SUPERSEDE_REQUESTED"; targetRunId: string }
|
||||||
|
| { type: "SUPERSEDE_READY"; runId?: string; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_MISMATCH"; currentRunId?: string; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_TIMEOUT"; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_INVALID"; epoch?: number }
|
||||||
|
| { type: "RUN_ALREADY_ACTIVE"; activeRunId?: string }
|
||||||
|
// -- lifecycle --
|
||||||
|
| { type: "DISPOSE" };
|
||||||
|
|
||||||
|
export const RECONNECT_MAX_ATTEMPTS = 5;
|
||||||
|
export const RECONNECT_BASE_DELAY_MS = 1000;
|
||||||
|
/** Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s. */
|
||||||
|
export function reconnectDelayMs(attempt: number): number {
|
||||||
|
return RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructors / helpers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function initialMachine(overrides?: Partial<Ctx>): Machine {
|
||||||
|
return {
|
||||||
|
phase: { name: "idle" },
|
||||||
|
ctx: { epoch: 0, ownership: "local", runFact: null, liveFollow: false, ...overrides },
|
||||||
|
effects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a machine result: a phase, optional ctx patch, and effects. Empty
|
||||||
|
* effects by default. Never mutates the input. */
|
||||||
|
function to(
|
||||||
|
m: Machine,
|
||||||
|
phase: Phase,
|
||||||
|
opts?: { ctx?: Partial<Ctx>; effects?: Effect[] },
|
||||||
|
): Machine {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
ctx: { ...m.ctx, ...(opts?.ctx ?? {}) },
|
||||||
|
effects: opts?.effects ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No transition: keep the phase, clear effects (so a re-run does not re-fire). */
|
||||||
|
function stay(m: Machine): Machine {
|
||||||
|
return { phase: m.phase, ctx: m.ctx, effects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A command-transition: same as `to` but bumps the epoch (I1). Any outcome
|
||||||
|
* event issued under the old epoch is dropped once this lands. */
|
||||||
|
function command(
|
||||||
|
m: Machine,
|
||||||
|
phase: Phase,
|
||||||
|
effects: Effect[],
|
||||||
|
ctx?: Partial<Ctx>,
|
||||||
|
): Machine {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
ctx: { ...m.ctx, ...(ctx ?? {}), epoch: m.ctx.epoch + 1 },
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The pure reducer.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The terminal stream-finish events (one turn's stream ended). */
|
||||||
|
function isFinishEvent(event: Event): boolean {
|
||||||
|
return (
|
||||||
|
event.type === "FINISH_ABORT" ||
|
||||||
|
event.type === "FINISH_CLEAN" ||
|
||||||
|
event.type === "FINISH_DISCONNECT" ||
|
||||||
|
event.type === "FINISH_ERROR" ||
|
||||||
|
event.type === "STREAM_INCOMPLETE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(m: Machine, event: Event): Machine {
|
||||||
|
// MEDIUM (#488 re-review): honor ANY stream finish in `stopping` regardless of
|
||||||
|
// generation. A plain user Stop has NO successor stream — the aborted stream's
|
||||||
|
// finish IS the expected end of the stop, so exit `stopping -> idle` by that DATA
|
||||||
|
// (I4). The epoch filter below must NOT drop it: STOP_REQUESTED bumped the epoch,
|
||||||
|
// but the finish carries the PRE-stop generation (the runtime stamps it with the
|
||||||
|
// stream's start epoch), so I1 would otherwise strand the machine in `stopping`
|
||||||
|
// forever (no idle-cap covers `stopping`). The epoch filter stays in force for
|
||||||
|
// `superseding` (a successor B owns) — that is the F1 supersede drop.
|
||||||
|
if (m.phase.name === "stopping" && isFinishEvent(event)) {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
// Reset ownership to local on this terminal transition (review #2): otherwise
|
||||||
|
// an observer-stop leaves ownership 'observer' and hides "Send now" forever.
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// I1: drop a stale outcome (an event issued under a superseded epoch).
|
||||||
|
if ("epoch" in event && event.epoch !== undefined && event.epoch !== m.ctx.epoch) {
|
||||||
|
return stay(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
// ---- local turn ----------------------------------------------------
|
||||||
|
case "SEND_LOCAL":
|
||||||
|
// A local send owns the view: leave any recovery, become the local
|
||||||
|
// streamer, disarm poll/reconnect. epoch++ so a late recovery outcome
|
||||||
|
// from the previous phase is dropped.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "sending" },
|
||||||
|
[{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
{ ownership: "local", liveFollow: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
case "STREAM_INCOMPLETE":
|
||||||
|
// An OBSERVER's attached stream ended incomplete (starved / torn) — follow
|
||||||
|
// the run to terminal via the degraded poll.
|
||||||
|
return to(m, { name: "polling", reason: event.reason }, {
|
||||||
|
effects: [{ type: "armPoll", reason: event.reason }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "STREAM_START": {
|
||||||
|
// First frame arrived. Adopt the run-fact runId if present. sending ->
|
||||||
|
// streaming; a reconnect/attach that just went live also lands here.
|
||||||
|
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
ctx: { runFact },
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "FINISH_CLEAN":
|
||||||
|
// A clean terminal outcome. The run is done — clear the run-fact and go
|
||||||
|
// idle. (The queue flush is a component concern gated by ownership; the
|
||||||
|
// FSM only models the phase.) Review #2: reset ownership to local so a
|
||||||
|
// just-finished observer-attach turn re-exposes "Send now" for the queue.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_ABORT":
|
||||||
|
// A user Stop / intentional abort finished. If we were stopping, the
|
||||||
|
// terminal data has now arrived (I4) — go idle. The run-fact is cleared.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_DISCONNECT":
|
||||||
|
// A LIVE SSE drop. Recovery depends on WHO we are (I2 + liveFollow):
|
||||||
|
// - a mount-attach OBSERVER (a one-shot resume, NOT live-follow) that drops
|
||||||
|
// -> the degraded poll drives the row to terminal from the DB.
|
||||||
|
if (m.ctx.ownership === "observer" && !m.ctx.liveFollow) {
|
||||||
|
return to(m, { name: "polling", reason: "disconnect-visible" }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "disconnect-visible" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// - a LOCAL live turn (first drop) OR a live-follow re-attach (a SUBSEQUENT
|
||||||
|
// drop) -> (re-)enter the reconnect ladder. #488 commit 3: allowed
|
||||||
|
// REPEATEDLY — `liveFollow` is kept across a successful re-attach, so the
|
||||||
|
// second break reconnects again instead of falling to silent poll.
|
||||||
|
// #488 commit 2: gated on the RUN-FACT (or an existing live-follow), NOT on
|
||||||
|
// the presence of an assistant message — a setup-phase break still recovers.
|
||||||
|
// - visible content already on screen -> keep it, ALSO poll to terminal
|
||||||
|
// (a full replay could clobber the fuller live tail);
|
||||||
|
// - no visible content -> the reconnect ladder rebuilds it.
|
||||||
|
if (m.ctx.runFact || m.ctx.liveFollow) {
|
||||||
|
const effects: Effect[] = [
|
||||||
|
{ type: "scheduleReconnect", attempt: 1, delayMs: reconnectDelayMs(1) },
|
||||||
|
];
|
||||||
|
if (event.hasVisibleContent) effects.push({ type: "armPoll", reason: "disconnect-visible" });
|
||||||
|
return command(m, { name: "reconnecting", attempt: 1, failed: false }, effects, {
|
||||||
|
ownership: "observer",
|
||||||
|
liveFollow: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// No run to recover: a plain disconnect. Surface the terminal notice.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_ERROR":
|
||||||
|
return to(m, { name: "error", kind: event.kind }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- mount attach (resume) ----------------------------------------
|
||||||
|
case "ATTACH_START":
|
||||||
|
// A reopened tab attaches to a still-running run: observer ownership.
|
||||||
|
// #488 F2: ONLY from idle. The mount `getRun` round-trip resolves async, and
|
||||||
|
// a local send may have started meanwhile (phase `sending`, ownership local);
|
||||||
|
// a late ATTACH_START must NOT hijack that local turn into an observer-attach
|
||||||
|
// (queue would stop flushing, "Send now" would hide). Guarding in the reducer
|
||||||
|
// covers every dispatch source.
|
||||||
|
if (m.phase.name !== "idle") return stay(m);
|
||||||
|
return command(m, { name: "attaching" }, [{ type: "resumeStream" }], {
|
||||||
|
ownership: "observer",
|
||||||
|
runFact: event.runId ? { runId: event.runId } : m.ctx.runFact,
|
||||||
|
});
|
||||||
|
|
||||||
|
case "ATTACH_LIVE":
|
||||||
|
// The attach GET returned a live 2xx stream — follow it as an observer.
|
||||||
|
// Review #1: guard by SOURCE phase. The epoch filter alone is not enough — a
|
||||||
|
// POLL_TERMINAL uses to() (no epoch bump) and does not abort the in-flight
|
||||||
|
// GET, so a slow 2xx landing after the machine already left `attaching` (e.g.
|
||||||
|
// the armed poll saw the terminal row -> idle) would resurrect a settled run
|
||||||
|
// into a phantom `streaming`. Only enter streaming FROM `attaching`.
|
||||||
|
if (m.phase.name !== "attaching") return stay(m);
|
||||||
|
return to(m, { name: "streaming" });
|
||||||
|
|
||||||
|
case "ATTACH_NONE":
|
||||||
|
// 204 / non-2xx / throw: nothing live to attach. Arm the degraded poll to
|
||||||
|
// follow the run to terminal from the DB. This is a soft-negative run-fact
|
||||||
|
// (204 on a non-stripped path is authoritative-negative; the runtime may
|
||||||
|
// pass a RUN_FACT null separately). Keep the run-fact as-is here.
|
||||||
|
// Review #1: guard by source phase for consistency (a late outcome after the
|
||||||
|
// machine already left `attaching` must not re-arm a poll).
|
||||||
|
if (m.phase.name !== "attaching") return stay(m);
|
||||||
|
return to(m, { name: "polling", reason: "attach-none" }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "attach-none" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- reconnect after a live disconnect ----------------------------
|
||||||
|
case "RECONNECT_ATTEMPT":
|
||||||
|
// A scheduled backoff fired — fire the attach GET. epoch++ so the previous
|
||||||
|
// attempt's late outcome cannot drive this one.
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: event.attempt, failed: false },
|
||||||
|
[{ type: "resumeStream" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
case "RECONNECT_ATTACHED":
|
||||||
|
// #488 commit 3: a live re-attach succeeded. Reset to streaming — the
|
||||||
|
// attempt counter is dropped, so a LATER disconnect can start a fresh
|
||||||
|
// ladder from attempt 1 (the old one-shot `!wasResumed` gate forbade a
|
||||||
|
// second cycle, sending the second break to silent poll).
|
||||||
|
// Review #1: guard by SOURCE phase. The armed degraded poll can reach the
|
||||||
|
// terminal row (POLL_TERMINAL -> idle, via to(), NO epoch bump, GET not
|
||||||
|
// aborted) BEFORE a slow reconnect GET returns 2xx; without this guard that
|
||||||
|
// late RECONNECT_ATTACHED (same epoch) would resurrect a settled run into a
|
||||||
|
// phantom `streaming`. Only re-enter streaming FROM `reconnecting`.
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "RECONNECT_NONE": {
|
||||||
|
// 204 / error during a reconnect attempt. Arm the degraded poll as the
|
||||||
|
// belt-and-suspenders fallback, then either back off to the next attempt
|
||||||
|
// or, at the cap, surface the manual Retry ("failed").
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
const attempt = m.phase.attempt;
|
||||||
|
if (attempt < RECONNECT_MAX_ATTEMPTS) {
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: attempt + 1, failed: false },
|
||||||
|
[
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
{ type: "scheduleReconnect", attempt: attempt + 1, delayMs: reconnectDelayMs(attempt + 1) },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return to(m, { name: "reconnecting", attempt, failed: true }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "reconnect-exhausted" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "RETRY":
|
||||||
|
// Manual Retry from the "failed" reconnect banner OR the stalled banner.
|
||||||
|
if (m.phase.name === "reconnecting" && m.phase.failed) {
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: 1, failed: false },
|
||||||
|
[{ type: "resumeStream" }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m.phase.name === "stalled") {
|
||||||
|
// Re-arm the poll to try to catch the run up again.
|
||||||
|
return command(m, { name: "polling", reason: "attach-none" }, [
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return stay(m);
|
||||||
|
|
||||||
|
// ---- degraded poll -------------------------------------------------
|
||||||
|
case "POLL_TERMINAL":
|
||||||
|
// The run reached a terminal row via the poll (or the reconcile merge). Go
|
||||||
|
// idle and disarm everything (I4: this is a DATA-driven exit, incl. exit
|
||||||
|
// from `stopping`). Review #2: reset ownership to local.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "POLL_IDLE_CAP":
|
||||||
|
// Review #4: `stopping` also arms the poll (STOP_REQUESTED) but has NO other
|
||||||
|
// backstop — an observer-stop with no SDK stream to fire onFinish, whose
|
||||||
|
// server stop never drives the run terminal, would poll the DB forever. Give
|
||||||
|
// it a bounded exit: cap -> idle + disarm (NOT `stalled`; Stop was already
|
||||||
|
// pressed, so there is nothing for the user to retry).
|
||||||
|
if (m.phase.name === "stopping") {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #488 commit 4a: the poll hit the inactivity cap. Instead of going SILENT
|
||||||
|
// (the old "forever half-done answer"), surface a stalled banner + Retry.
|
||||||
|
if (m.phase.name !== "polling" && m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return to(m, { name: "stalled" }, {
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- run-fact ------------------------------------------------------
|
||||||
|
case "RUN_FACT": {
|
||||||
|
const runFact = event.runFact;
|
||||||
|
// A fresh NEGATIVE fact (no active run) cancels recovery immediately (I3):
|
||||||
|
// there is nothing to reconnect to / poll for.
|
||||||
|
if (!runFact) {
|
||||||
|
if (
|
||||||
|
m.phase.name === "reconnecting" ||
|
||||||
|
m.phase.name === "attaching" ||
|
||||||
|
m.phase.name === "polling" ||
|
||||||
|
m.phase.name === "stopping"
|
||||||
|
) {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
// Review #2: reset ownership to local on this terminal transition.
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return to(m, m.phase, { ctx: { runFact: null } });
|
||||||
|
}
|
||||||
|
// A positive fact just updates the context (pessimism toward an attempt: a
|
||||||
|
// stale-but-positive fact permits entering recovery; a 204 will cut it).
|
||||||
|
return to(m, m.phase, { ctx: { runFact } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stop ----------------------------------------------------------
|
||||||
|
case "STOP_REQUESTED":
|
||||||
|
// Authoritative stop of a detached run. Enter `stopping` and fire stopRun +
|
||||||
|
// abort the local/attach reader. ALSO arm the poll so the terminal row is
|
||||||
|
// observed — the exit is by DATA (I4: a terminal row / negative run-fact),
|
||||||
|
// never by the stopRun HTTP response (which returns after abort, before
|
||||||
|
// finalization). For a local turn the aborted stream's onFinish (ANY finish)
|
||||||
|
// is HONORED in `stopping` at the top of reduce() — regardless of generation
|
||||||
|
// — and exits to idle; the armed poll is the fallback for an observer stop
|
||||||
|
// with no local onFinish.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "stopping" },
|
||||||
|
[
|
||||||
|
{ type: "stopRun" },
|
||||||
|
{ type: "abortAttach" },
|
||||||
|
{ type: "cancelReconnect" },
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- supersede (CAS) ----------------------------------------------
|
||||||
|
case "SUPERSEDE_REQUESTED":
|
||||||
|
// "Interrupt and send now": CAS POST /stream { supersede }. epoch++ so a
|
||||||
|
// late outcome of the interrupted run is dropped.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "superseding" },
|
||||||
|
[{ type: "supersede", targetRunId: event.targetRunId }, { type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
case "SUPERSEDE_READY": {
|
||||||
|
// CAS succeeded (old run stopped/settled, slot taken, new run begun). We
|
||||||
|
// are now the local streamer of the NEW run. Adopt its runId if provided.
|
||||||
|
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
ctx: { ownership: "local", runFact, liveFollow: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SUPERSEDE_MISMATCH":
|
||||||
|
// The active run moved between the click and the CAS. Per the spec: verify
|
||||||
|
// via /run rather than blindly banner — the mismatch may be our own already-
|
||||||
|
// superseded run. Surface a classified error AND fire a run-fact verify.
|
||||||
|
return to(m, { name: "error", kind: "supersede-mismatch" }, {
|
||||||
|
ctx: { runFact: event.currentRunId ? { runId: event.currentRunId } : m.ctx.runFact },
|
||||||
|
effects: [{ type: "postRun", reason: "verify" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "SUPERSEDE_TIMEOUT":
|
||||||
|
// The old run did not settle within W. Nothing persisted; the composer keeps
|
||||||
|
// its text. Classified error, NO auto-retry (the old client retry ladder is
|
||||||
|
// removed in #488 commit 5).
|
||||||
|
return to(m, { name: "error", kind: "supersede-timeout" });
|
||||||
|
|
||||||
|
case "SUPERSEDE_INVALID":
|
||||||
|
return to(m, { name: "error", kind: "supersede-invalid" });
|
||||||
|
|
||||||
|
case "RUN_ALREADY_ACTIVE":
|
||||||
|
// A plain POST hit the one-active-run gate. NO auto-retry — the composer
|
||||||
|
// offers "interrupt and send" (supersede) instead. #497/S4: adopt the
|
||||||
|
// server's activeRunId as the run-fact so that supersede can TARGET the
|
||||||
|
// (possibly foreign-tab) active run via the CAS, rather than a blind
|
||||||
|
// promote+abort that just 409s again. A stale/absent id keeps the prior fact.
|
||||||
|
return to(m, { name: "error", kind: "run-already-active" }, {
|
||||||
|
ctx: { runFact: event.activeRunId ? { runId: event.activeRunId } : m.ctx.runFact },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- lifecycle -----------------------------------------------------
|
||||||
|
case "DISPOSE":
|
||||||
|
// Unmount: abort in-flight controllers, drop timers, and bump the epoch so
|
||||||
|
// NO late callback can drive this (now dead) machine (I5).
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "idle" },
|
||||||
|
[
|
||||||
|
{ type: "abortAttach" },
|
||||||
|
{ type: "cancelReconnect" },
|
||||||
|
{ type: "disarmPoll" },
|
||||||
|
],
|
||||||
|
{ liveFollow: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness guard.
|
||||||
|
const _never: never = event;
|
||||||
|
void _never;
|
||||||
|
return stay(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,12 @@ export interface IAiChatMessageRow {
|
|||||||
toolCalls?: unknown;
|
toolCalls?: unknown;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
parts?: UIMessage["parts"];
|
parts?: UIMessage["parts"];
|
||||||
|
// #491 step-alignment anchor: the count of FINISHED steps whose parts are in
|
||||||
|
// THIS row, written atomically with `parts` server-side (flushAssistant). The
|
||||||
|
// resume client reads it as its persisted step frontier N — the tail-only
|
||||||
|
// attach asks the run-stream registry for the frames of step N onward (the
|
||||||
|
// seed already carries steps 0..N-1). Absent on pre-#491 rows -> read as 0.
|
||||||
|
stepsPersisted?: number;
|
||||||
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
// AI SDK v6 `totalUsage` persisted on assistant rows. Legacy cumulative
|
||||||
// figure (sum of every step's usage for the turn); kept for back-compat and
|
// figure (sum of every step's usage for the turn); kept for back-compat and
|
||||||
// as the fallback for older rows that have no `contextTokens`.
|
// as the fallback for older rows that have no `contextTokens`.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
resolveAdoptedChatId,
|
resolveAdoptedChatId,
|
||||||
newlyAddedChatIds,
|
newlyAddedChatIds,
|
||||||
extractServerChatId,
|
extractServerChatId,
|
||||||
|
extractRunId,
|
||||||
} from "./adopt-chat-id";
|
} from "./adopt-chat-id";
|
||||||
|
|
||||||
describe("resolveAdoptedChatId", () => {
|
describe("resolveAdoptedChatId", () => {
|
||||||
@@ -70,3 +71,17 @@ describe("extractServerChatId", () => {
|
|||||||
expect(extractServerChatId(undefined)).toBeUndefined();
|
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractRunId", () => {
|
||||||
|
it("reads a string runId from the start metadata", () => {
|
||||||
|
expect(extractRunId({ metadata: { runId: "run-1" } })).toBe("run-1");
|
||||||
|
});
|
||||||
|
it("returns undefined when runId is absent", () => {
|
||||||
|
expect(extractRunId({ metadata: { chatId: "c" } })).toBeUndefined();
|
||||||
|
expect(extractRunId({})).toBeUndefined();
|
||||||
|
expect(extractRunId(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("returns undefined for a non-string runId", () => {
|
||||||
|
expect(extractRunId({ metadata: { runId: 7 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ export function extractServerChatId(
|
|||||||
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #488: read the authoritative RUN id off a streaming assistant message. The
|
||||||
|
* server attaches it as `message.metadata.runId` on the `start` part when a run
|
||||||
|
* wraps the turn (see server `chatStreamMetadata`, #184/#487). This is the live
|
||||||
|
* run-fact update the client FSM adopts (mirrors `extractServerChatId`). Returns
|
||||||
|
* it only when it is a string; undefined otherwise.
|
||||||
|
*/
|
||||||
|
export function extractRunId(
|
||||||
|
message: { metadata?: unknown } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const m = message?.metadata as { runId?: string } | undefined;
|
||||||
|
return typeof m?.runId === "string" ? m.runId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||||
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ describe("estimateTokens", () => {
|
|||||||
expect(estimateTokens("")).toBe(0);
|
expect(estimateTokens("")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
||||||
|
// from the old client-only chars/4) so the client counter and the server replay
|
||||||
|
// budgeter can never diverge.
|
||||||
|
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
||||||
expect(estimateTokens("a")).toBe(1);
|
expect(estimateTokens("a")).toBe(1);
|
||||||
expect(estimateTokens("abcd")).toBe(1);
|
expect(estimateTokens("ab")).toBe(1);
|
||||||
expect(estimateTokens("abcde")).toBe(2);
|
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
||||||
expect(estimateTokens("12345678")).toBe(2);
|
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,10 @@
|
|||||||
* Rough client-side token estimation for AI-chat UI affordances.
|
* Rough client-side token estimation for AI-chat UI affordances.
|
||||||
*
|
*
|
||||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
||||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
||||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
||||||
* ("Thinking · N tokens").
|
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
||||||
|
* in-body reasoning counter ("Thinking · N tokens").
|
||||||
*/
|
*/
|
||||||
|
export { estimateTokens } from "@docmost/token-estimate";
|
||||||
/**
|
|
||||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
|
||||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
|
||||||
* non-empty text counts as at least one token.
|
|
||||||
*/
|
|
||||||
export function estimateTokens(text: string): number {
|
|
||||||
if (!text) return 0;
|
|
||||||
return Math.ceil(text.length / 4);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,89 @@ describe("describeChatError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
|
||||||
|
// The FULL real body the server writes for a beginRun failure: a
|
||||||
|
// ServiceUnavailableException(object) whose response is serialized verbatim
|
||||||
|
// onto the raw socket, self-describing statusCode 503 + the run-start code.
|
||||||
|
const body =
|
||||||
|
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "Could not start the run",
|
||||||
|
detail:
|
||||||
|
"The agent run could not be started. This is usually temporary — please try again.",
|
||||||
|
});
|
||||||
|
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
|
||||||
|
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
|
||||||
|
// never mislabeled "AI provider not configured".
|
||||||
|
expect(describeChatError(body, t).title).not.toBe(
|
||||||
|
"AI provider not configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 5: the #487 concurrency-gate / supersede 409s. FULL real bodies:
|
||||||
|
// a ConflictException(object) whose response is serialized verbatim, carrying a
|
||||||
|
// `code` and statusCode 409. Each must classify to a human text, not raw JSON.
|
||||||
|
it("classifies A_RUN_ALREADY_ACTIVE (409) as already-answering, not raw JSON", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"A run is already active for this chat","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"The agent is already answering",
|
||||||
|
);
|
||||||
|
// Never leaks the raw code as the detail.
|
||||||
|
expect(describeChatError(body, t).detail).not.toContain("A_RUN_ALREADY_ACTIVE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_TARGET_MISMATCH (409) as run-changed", () => {
|
||||||
|
// Real server body shape: the current run id is `activeRunId` (NOT `runId`) —
|
||||||
|
// see ai-chat.controller.ts. describeChatError classifies off `code` only.
|
||||||
|
const body =
|
||||||
|
'{"message":"active run does not match the supersede target","code":"SUPERSEDE_TARGET_MISMATCH","activeRunId":"run-x","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"Couldn't interrupt — the run changed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_TIMEOUT (409) as couldn't-interrupt-in-time", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"the run did not settle within the supersede window","code":"SUPERSEDE_TIMEOUT","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe("Couldn't interrupt in time");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_INVALID (409) as couldn't-interrupt-that-run", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"supervise requires chatId","code":"SUPERSEDE_INVALID","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"Couldn't interrupt that run",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ORDER GUARD: A_RUN_ALREADY_ACTIVE wins over any generic status branch", () => {
|
||||||
|
// Even though the body could superficially look 4xx-ish, the code branch runs
|
||||||
|
// first, so it is never mislabeled by a generic status heading.
|
||||||
|
const body =
|
||||||
|
'{"message":"conflict","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||||
|
const view = describeChatError(body, t);
|
||||||
|
expect(view.title).not.toBe("Something went wrong");
|
||||||
|
expect(view.title).not.toBe("AI provider not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||||
|
// The exact reason the server persists in metadata.error on a degeneration
|
||||||
|
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||||
|
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||||
|
// so live and refetch never disagree.
|
||||||
|
const view = describeChatError(
|
||||||
|
"Output degeneration detected (repeated token loop)",
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(view.title).toBe("Response stopped.");
|
||||||
|
expect(view.detail).toBe(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
);
|
||||||
|
// Regression guard: it must NOT fall through to the generic heading.
|
||||||
|
expect(view.title).not.toBe("Something went wrong");
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||||
expect(
|
expect(
|
||||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||||
|
|||||||
@@ -24,6 +24,75 @@ export function describeChatError(
|
|||||||
): ChatErrorView {
|
): ChatErrorView {
|
||||||
const msg = message ?? "";
|
const msg = message ?? "";
|
||||||
|
|
||||||
|
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
|
||||||
|
// whose body carries this code is a TEMPORARY server-side failure while
|
||||||
|
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
|
||||||
|
// be matched STRICTLY BEFORE the generic 503 branch below, which would
|
||||||
|
// otherwise mislabel it "The AI provider is not configured" and tell the user
|
||||||
|
// to call an admin instead of just retrying.
|
||||||
|
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Could not start the run"),
|
||||||
|
detail: t(
|
||||||
|
"The agent run could not be started. This is usually temporary — please try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #488 commit 5: the #487 concurrency-gate / supersede 409s. These arrive as a
|
||||||
|
// ConflictException(object) body carrying a `code` (and statusCode 409). They
|
||||||
|
// MUST be classified by `code` STRICTLY BEFORE any generic status branch, or the
|
||||||
|
// user sees the raw JSON `{"code":"A_RUN_ALREADY_ACTIVE",…}`. The code strings
|
||||||
|
// are the real #487 server contract (ai-chat.controller.ts) — do not invent.
|
||||||
|
if (/"code"\s*:\s*"A_RUN_ALREADY_ACTIVE"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("The agent is already answering"),
|
||||||
|
detail: t(
|
||||||
|
"This chat already has a run in progress. Wait for it to finish, or interrupt it and send now.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_TARGET_MISMATCH"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt — the run changed"),
|
||||||
|
detail: t(
|
||||||
|
"The run you tried to interrupt is no longer the active one. Check the latest answer and try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_TIMEOUT"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt in time"),
|
||||||
|
detail: t(
|
||||||
|
"The previous run didn't stop in time. Nothing was sent — try sending again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_INVALID"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt that run"),
|
||||||
|
detail: t(
|
||||||
|
"The run to interrupt doesn't belong to this chat. Reload and try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||||
|
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||||
|
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||||
|
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||||
|
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||||
|
// later refetch show two different texts for one event. The detail explains the
|
||||||
|
// loop-guard cause without contradicting the shared heading.
|
||||||
|
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Response stopped."),
|
||||||
|
detail: t(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||||
return {
|
return {
|
||||||
title: t("AI chat is disabled"),
|
title: t("AI chat is disabled"),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
import {
|
import {
|
||||||
isStreamingTail,
|
isStreamingTail,
|
||||||
isSettledAssistantTail,
|
isSettledAssistantTail,
|
||||||
seedRows,
|
stepsPersistedOf,
|
||||||
|
mergeDeltaRowsIntoPages,
|
||||||
mergeById,
|
mergeById,
|
||||||
} from "./resume-helpers.ts";
|
} from "./resume-helpers.ts";
|
||||||
|
|
||||||
@@ -12,8 +13,18 @@ function row(
|
|||||||
id: string,
|
id: string,
|
||||||
role: string,
|
role: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
|
stepsPersisted?: number,
|
||||||
): IAiChatMessageRow {
|
): IAiChatMessageRow {
|
||||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
return {
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
content: "",
|
||||||
|
status,
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
...(stepsPersisted !== undefined
|
||||||
|
? { metadata: { stepsPersisted } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMsg(id: string, text: string): UIMessage {
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
@@ -65,23 +76,92 @@ describe("isSettledAssistantTail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("seedRows", () => {
|
describe("stepsPersistedOf", () => {
|
||||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
it("reads metadata.stepsPersisted", () => {
|
||||||
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 3))).toBe(3);
|
||||||
it("returns the rows unchanged when not stripping", () => {
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming", 0))).toBe(0);
|
||||||
expect(seedRows(rows, false)).toBe(rows);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the last row when stripping", () => {
|
it("defaults to 0 for a pre-#491 row (absent), null/undefined, or a bad value", () => {
|
||||||
const seeded = seedRows(rows, true);
|
expect(stepsPersistedOf(row("a1", "assistant", "streaming"))).toBe(0);
|
||||||
expect(seeded).toHaveLength(1);
|
expect(stepsPersistedOf(null)).toBe(0);
|
||||||
expect(seeded[0].id).toBe("u1");
|
expect(stepsPersistedOf(undefined)).toBe(0);
|
||||||
|
expect(
|
||||||
|
stepsPersistedOf({
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: "x",
|
||||||
|
metadata: { stepsPersisted: -2 },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when stripping a single-row list", () => {
|
it("floors a non-integer count", () => {
|
||||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
expect(
|
||||||
0,
|
stepsPersistedOf({
|
||||||
);
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: "x",
|
||||||
|
metadata: { stepsPersisted: 2.9 },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeDeltaRowsIntoPages", () => {
|
||||||
|
const pages = () => [
|
||||||
|
{ items: [row("u1", "user"), row("a1", "assistant", "streaming", 1)], meta: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the pages unchanged for an empty delta", () => {
|
||||||
|
const p = pages();
|
||||||
|
expect(mergeDeltaRowsIntoPages(p, [])).toBe(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a genuinely new row to the last page in chronological order", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages(pages(), [row("a2", "assistant", "streaming", 0)]);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a grown row in place (per-step growth), never appends a duplicate", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages(pages(), [
|
||||||
|
row("a1", "assistant", "streaming", 2),
|
||||||
|
]);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1", "a1"]);
|
||||||
|
// the in-place replacement carries the grown step frontier.
|
||||||
|
expect(stepsPersistedOf(merged[0].items[1])).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input pages", () => {
|
||||||
|
const input = pages();
|
||||||
|
const before = input[0].items.slice();
|
||||||
|
mergeDeltaRowsIntoPages(input, [row("a2", "assistant", "streaming", 0)]);
|
||||||
|
expect(input[0].items).toEqual(before); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
// #491 CONTRACT: the delta overlap window re-delivers the same rows, so merging
|
||||||
|
// MUST be idempotent — applying a delta twice equals applying it once (no growth,
|
||||||
|
// no reorder). A regression re-introduces duplicate assistant bubbles per poll.
|
||||||
|
it("is idempotent: applying the same delta twice equals once", () => {
|
||||||
|
const delta = [
|
||||||
|
row("a1", "assistant", "streaming", 2), // grown existing row
|
||||||
|
row("a2", "assistant", "streaming", 0), // new row
|
||||||
|
];
|
||||||
|
const once = mergeDeltaRowsIntoPages(pages(), delta);
|
||||||
|
const twice = mergeDeltaRowsIntoPages(once, delta);
|
||||||
|
const thrice = mergeDeltaRowsIntoPages(twice, delta);
|
||||||
|
expect(once[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice[0].items.map((i) => i.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice).toEqual(once);
|
||||||
|
expect(thrice).toEqual(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds a first page when the cache is empty", () => {
|
||||||
|
const merged = mergeDeltaRowsIntoPages([], [row("u1", "user")]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].items.map((i) => i.id)).toEqual(["u1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,4 +189,37 @@ describe("mergeById", () => {
|
|||||||
expect(mergeById(prev, null)).toBe(prev);
|
expect(mergeById(prev, null)).toBe(prev);
|
||||||
expect(mergeById(prev, undefined)).toBe(prev);
|
expect(mergeById(prev, undefined)).toBe(prev);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #491 CONTRACT: the delta poll's overlap window GUARANTEES the same row is
|
||||||
|
// re-delivered across close polls, so merging must be IDEMPOTENT by id — merging
|
||||||
|
// the same row (or an equal-length list of rows) twice must not duplicate or
|
||||||
|
// reorder. This is the property the whole delta-poll design leans on; a
|
||||||
|
// regression here would re-introduce duplicate assistant bubbles on every poll.
|
||||||
|
it("is idempotent by id: re-merging the same row does not duplicate or reorder", () => {
|
||||||
|
const seed = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const repeat = makeMsg("a1", "step 1"); // the SAME row the overlap re-delivers
|
||||||
|
const once = mergeById(seed, repeat);
|
||||||
|
const twice = mergeById(once, repeat);
|
||||||
|
const thrice = mergeById(twice, repeat);
|
||||||
|
// Length is stable (no growth), order is stable (user then assistant).
|
||||||
|
expect(once.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
expect(twice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
expect(thrice.map((m) => m.id)).toEqual(["u1", "a1"]);
|
||||||
|
// The repeated merge converges: the row is replaced in place, never appended.
|
||||||
|
expect(twice[1]).toBe(repeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent across a batch of repeated + grown rows (delta re-delivery)", () => {
|
||||||
|
// A delta poll re-delivers a1 (unchanged) and a2 (grown one step). Applying the
|
||||||
|
// batch twice must equal applying it once — the poll can re-send either.
|
||||||
|
const start = [makeMsg("u1", "hi"), makeMsg("a1", "done")];
|
||||||
|
const batch = [makeMsg("a1", "done"), makeMsg("a2", "grown step 2")];
|
||||||
|
const apply = (list: typeof start) =>
|
||||||
|
batch.reduce((acc, row) => mergeById(acc, row), list);
|
||||||
|
const once = apply(start);
|
||||||
|
const twice = apply(once);
|
||||||
|
expect(once.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice.map((m) => m.id)).toEqual(["u1", "a1", "a2"]);
|
||||||
|
expect(twice).toEqual(once);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
* `status === 'streaming'`. #491 (tail-only): such a tail is seeded UNCHANGED —
|
||||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
* it carries the persisted steps 0..N-1 — and the run-stream registry's tail
|
||||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
* (frames for steps >= N) is APPENDED to it by the SDK's `readUIMessageStream`
|
||||||
|
* continuation. Only the presence of this tail decides WHETHER to attach.
|
||||||
*/
|
*/
|
||||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||||
const tail = rows[rows.length - 1];
|
const tail = rows[rows.length - 1];
|
||||||
@@ -32,15 +33,61 @@ export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
* #491 tail-only anchor: the count of FINISHED steps whose parts are persisted in
|
||||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
* THIS assistant row (`metadata.stepsPersisted`), written atomically with `parts`
|
||||||
* without duplicating parts).
|
* server-side. The resume client reads it as its persisted step frontier N — the
|
||||||
|
* tail-only attach asks the run-stream registry for the frames of step N onward
|
||||||
|
* (the seed already carries steps 0..N-1). Absent on pre-#491 rows => 0.
|
||||||
*/
|
*/
|
||||||
export function seedRows(
|
export function stepsPersistedOf(
|
||||||
|
row: IAiChatMessageRow | null | undefined,
|
||||||
|
): number {
|
||||||
|
const n = row?.metadata?.stepsPersisted;
|
||||||
|
return typeof n === "number" && n >= 0 ? Math.floor(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of the messages infinite-query cache (`{ items, meta }`). */
|
||||||
|
export interface IMessagePage {
|
||||||
|
items: IAiChatMessageRow[];
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #491 delta-poll merge: upsert the delta poll's `rows` into the messages
|
||||||
|
* infinite-query page structure IDEMPOTENTLY by id. The delta endpoint's overlap
|
||||||
|
* window GUARANTEES occasional REPEATS, so this MUST converge: a row already
|
||||||
|
* present is REPLACED IN PLACE (per-step growth of an in-progress row), a new row
|
||||||
|
* is APPENDED to the last page in chronological order (the server returns delta
|
||||||
|
* rows oldest-first). Applying the same delta twice equals applying it once. Never
|
||||||
|
* mutates the input pages (returns fresh page objects with cloned item arrays).
|
||||||
|
*/
|
||||||
|
export function mergeDeltaRowsIntoPages(
|
||||||
|
pages: IMessagePage[],
|
||||||
rows: IAiChatMessageRow[],
|
rows: IAiChatMessageRow[],
|
||||||
strip: boolean,
|
): IMessagePage[] {
|
||||||
): IAiChatMessageRow[] {
|
if (rows.length === 0) return pages;
|
||||||
return strip ? rows.slice(0, -1) : rows;
|
const next: IMessagePage[] = pages.map((p) => ({
|
||||||
|
...p,
|
||||||
|
items: p.items.slice(),
|
||||||
|
}));
|
||||||
|
const locate = (id: string): [number, number] | null => {
|
||||||
|
for (let pi = 0; pi < next.length; pi++) {
|
||||||
|
const ii = next[pi].items.findIndex((it) => it.id === id);
|
||||||
|
if (ii !== -1) return [pi, ii];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (const row of rows) {
|
||||||
|
const at = locate(row.id);
|
||||||
|
if (at) {
|
||||||
|
next[at[0]].items[at[1]] = row; // replace in place — idempotent by id
|
||||||
|
} else if (next.length > 0) {
|
||||||
|
next[next.length - 1].items.push(row); // append chronologically
|
||||||
|
} else {
|
||||||
|
next.push({ items: [row], meta: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readUIMessageStream, type UIMessage } from "ai";
|
||||||
|
import pkg from "../../../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PIN-SPEC TRIP-WIRE (#491). The tail-only attach continuation relies on THREE
|
||||||
|
* behaviors of `ai@6.0.207`, verified line-by-line in the issue. Without this
|
||||||
|
* test, an `ai` bump could silently break attach (the client would append the
|
||||||
|
* live tail to the wrong message, or duplicate a step):
|
||||||
|
*
|
||||||
|
* 1. `readUIMessageStream({ message })` CONTINUES the passed message — it does
|
||||||
|
* not start a fresh one — so the tail streamed after a re-seed is appended to
|
||||||
|
* the seeded assistant row (the same DB id).
|
||||||
|
* 2. A `start` frame does NOT reset the existing message's parts (so the seeded
|
||||||
|
* steps 0..N-1 survive; the synthetic `start` the registry prepends only
|
||||||
|
* carries the run-fact metadata).
|
||||||
|
* 3. Text parts do NOT cross a `finish-step` boundary — a new `text-start` after
|
||||||
|
* `finish-step` is a NEW part — so the reconstructed steps stay separated and
|
||||||
|
* the step frontier stays meaningful.
|
||||||
|
*
|
||||||
|
* If an `ai` upgrade changes any of these, this test fails LOUD instead of the
|
||||||
|
* resume path silently corrupting.
|
||||||
|
*/
|
||||||
|
describe("ai SDK continuation trip-wire (#491, tail-only attach)", () => {
|
||||||
|
it("is pinned to the exact ai version the continuation was verified against", () => {
|
||||||
|
// A caret/range bump is exactly what would silently break attach — require an
|
||||||
|
// exact pin. Bumping ai MUST re-verify the behavior asserted below, then this.
|
||||||
|
expect((pkg as { dependencies: Record<string, string> }).dependencies.ai).toBe(
|
||||||
|
"6.0.207",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues the seeded message: start does not reset parts, the tail appends as new parts", async () => {
|
||||||
|
// A seeded assistant row with ONE finished step already reconstructed.
|
||||||
|
const seeded: UIMessage = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "step-start" },
|
||||||
|
{ type: "text", text: "STEP0", state: "done" },
|
||||||
|
],
|
||||||
|
} as UIMessage;
|
||||||
|
|
||||||
|
// The tail the registry delivers on re-attach: a synthetic start (run-fact),
|
||||||
|
// then step 1's frames, then finish. As UI-message chunks (what the SSE frames
|
||||||
|
// decode to).
|
||||||
|
const chunks = [
|
||||||
|
{ type: "start", messageMetadata: { runId: "r1", chatId: "c1" } },
|
||||||
|
{ type: "start-step" },
|
||||||
|
{ type: "text-start", id: "t1" },
|
||||||
|
{ type: "text-delta", id: "t1", delta: "STEP1" },
|
||||||
|
{ type: "text-end", id: "t1" },
|
||||||
|
{ type: "finish-step" },
|
||||||
|
{ type: "finish" },
|
||||||
|
];
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(c) {
|
||||||
|
for (const ch of chunks) c.enqueue(ch);
|
||||||
|
c.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let last: UIMessage | undefined;
|
||||||
|
for await (const msg of readUIMessageStream({ message: seeded, stream })) {
|
||||||
|
last = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(last).toBeDefined();
|
||||||
|
// Same message id (continuation, not a fresh message).
|
||||||
|
expect(last!.id).toBe("assistant-1");
|
||||||
|
// The seeded step-0 parts SURVIVED the `start` frame, and step 1 was appended
|
||||||
|
// as SEPARATE parts (text did not cross the finish-step boundary).
|
||||||
|
const shape = last!.parts.map((p) => `${p.type}:${(p as { text?: string }).text ?? ""}`);
|
||||||
|
expect(shape).toEqual([
|
||||||
|
"step-start:",
|
||||||
|
"text:STEP0",
|
||||||
|
"step-start:",
|
||||||
|
"text:STEP1",
|
||||||
|
]);
|
||||||
|
// The run-fact metadata from the synthetic start frame is applied.
|
||||||
|
expect(last!.metadata).toMatchObject({ runId: "r1", chatId: "c1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
|
|||||||
import {
|
import {
|
||||||
buildChildrenByParent,
|
buildChildrenByParent,
|
||||||
CommentEditorWithActions,
|
CommentEditorWithActions,
|
||||||
|
sortResolvedByResolvedAt,
|
||||||
} from "./comment-list-with-tabs";
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
({ id, parentCommentId }) as IComment;
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
|
||||||
|
({ id, resolvedAt }) as unknown as IComment;
|
||||||
|
|
||||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
it("returns an empty map for undefined or empty input", () => {
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
@@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
|
||||||
|
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
|
||||||
|
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
|
||||||
|
// the sort must coerce with new Date(...) before .getTime().
|
||||||
|
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
|
||||||
|
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
|
||||||
|
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
|
||||||
|
|
||||||
|
const out = sortResolvedByResolvedAt([older, newest, middle]);
|
||||||
|
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also handles Date instances (optimistic onMutate window)", () => {
|
||||||
|
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
|
||||||
|
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
|
||||||
|
"newer",
|
||||||
|
"older",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input array", () => {
|
||||||
|
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
|
||||||
|
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
|
||||||
|
const input = [a, b];
|
||||||
|
sortResolvedByResolvedAt(input);
|
||||||
|
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable order for equal resolvedAt timestamps", () => {
|
||||||
|
const ts = "2026-03-03T03:03:03.000Z";
|
||||||
|
const x = resolvedAtComment("x", ts);
|
||||||
|
const y = resolvedAtComment("y", ts);
|
||||||
|
const z = resolvedAtComment("z", ts);
|
||||||
|
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function renderReplyEditor() {
|
function renderReplyEditor() {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -53,6 +60,48 @@ export function buildChildrenByParent(
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
|
||||||
|
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
|
||||||
|
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
|
||||||
|
// real Date only during the optimistic onMutate window — so it MUST be coerced
|
||||||
|
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
|
||||||
|
// throws / yields NaN. ES2019's stable sort preserves order for equal
|
||||||
|
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
|
||||||
|
// the non-null assertion is safe.
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
|
||||||
|
return [...resolved].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
@@ -74,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) {
|
||||||
@@ -91,9 +142,23 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
(comment: IComment) => comment.resolvedAt,
|
(comment: IComment) => comment.resolvedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activeComments: active, resolvedComments: resolved };
|
return {
|
||||||
|
activeComments: active,
|
||||||
|
resolvedComments: sortResolvedByResolvedAt(resolved),
|
||||||
|
};
|
||||||
}, [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.
|
||||||
@@ -149,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 <></>;
|
||||||
}
|
}
|
||||||
@@ -207,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}
|
||||||
@@ -309,7 +420,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
activeComments.map(renderComments)
|
activeUnits.map(renderUnit)
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -328,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,353 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and
|
||||||
|
* its double-click guard, the terminal 404 branch (drop from cache + clear the
|
||||||
|
* inline mark, no rollback), and the directional error copy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
|
||||||
|
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
|
||||||
|
const editorMock = vi.hoisted(() => ({
|
||||||
|
current: {
|
||||||
|
isDestroyed: false,
|
||||||
|
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||||
|
} as {
|
||||||
|
isDestroyed: boolean;
|
||||||
|
commands: {
|
||||||
|
unsetComment: (id: string) => void;
|
||||||
|
setCommentResolved: (id: string, v: boolean) => void;
|
||||||
|
};
|
||||||
|
} | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn(), hide: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("jotai", () => ({
|
||||||
|
atom: (v: unknown) => v,
|
||||||
|
useAtomValue: () => editorMock.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useResolveCommentMutation,
|
||||||
|
RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [
|
||||||
|
{ items: [comment], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
resolvedAt: null,
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
// Pull the inline Undo button's onClick out of the success toast's message tree.
|
||||||
|
function undoOnClickFromToast(): () => void {
|
||||||
|
const call = vi
|
||||||
|
.mocked(notifications.show)
|
||||||
|
.mock.calls.map((c) => c[0])
|
||||||
|
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||||
|
expect(call).toBeTruthy();
|
||||||
|
const message: any = (call as any).message;
|
||||||
|
// message = Group( Text, Button ); grab the Button element's onClick.
|
||||||
|
const children = message.props.children as any[];
|
||||||
|
const button = children[1];
|
||||||
|
return button.props.onClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useResolveCommentMutation — Undo toast (#542)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
editorMock.current = {
|
||||||
|
isDestroyed: false,
|
||||||
|
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
|
||||||
|
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||||
|
comment({
|
||||||
|
resolvedAt: data.resolved ? (new Date() as any) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const resolveToast = vi
|
||||||
|
.mocked(notifications.show)
|
||||||
|
.mock.calls.map((c) => c[0])
|
||||||
|
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||||
|
expect(resolveToast).toBeTruthy();
|
||||||
|
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
|
||||||
|
expect((resolveToast as any).autoClose).toBe(10000);
|
||||||
|
|
||||||
|
// Now a reopen → plain toast, no autoClose/Undo, no id.
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||||
|
expect(
|
||||||
|
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||||
|
).toBe(false);
|
||||||
|
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
|
||||||
|
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||||
|
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
|
||||||
|
);
|
||||||
|
const { wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const onClick = undoOnClickFromToast();
|
||||||
|
// Fire twice synchronously (notifications.hide is not synchronous).
|
||||||
|
onClick();
|
||||||
|
onClick();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const reopenCalls = vi
|
||||||
|
.mocked(resolveComment)
|
||||||
|
.mock.calls.filter(([d]) => d.resolved === false);
|
||||||
|
expect(reopenCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
// The mark was cleared once via setCommentResolved(id, false).
|
||||||
|
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
|
||||||
|
"c-1",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// The toast was hidden.
|
||||||
|
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Removed from cache (NOT rolled back to a phantom).
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
|
||||||
|
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
|
||||||
|
"c-1",
|
||||||
|
);
|
||||||
|
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment no longer exists",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||||
|
expect(
|
||||||
|
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
|
||||||
|
editorMock.current = null;
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment no longer exists",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
// Seed a RESOLVED comment (the reopen target).
|
||||||
|
const resolved = comment({ resolvedAt: new Date() as any });
|
||||||
|
const { queryClient, wrapper } = seededClient(resolved);
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Failed to re-open comment",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "Failed to resolve comment" }),
|
||||||
|
);
|
||||||
|
// Rolled back: the comment is still present and still resolved.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
|
||||||
|
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
|
||||||
|
vi.mocked(resolveComment).mockResolvedValueOnce(
|
||||||
|
comment({ resolvedAt: new Date() as any }),
|
||||||
|
);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Now the reopen fired by Undo fails with a 500.
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
const onClick = undoOnClickFromToast();
|
||||||
|
onClick();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
|
||||||
|
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
|
||||||
|
// doc would show an active highlight the panel still treats as resolved and
|
||||||
|
// the collab mark would diverge with nothing committed on the server.
|
||||||
|
expect(
|
||||||
|
editorMock.current!.commands.setCommentResolved,
|
||||||
|
).not.toHaveBeenCalledWith("c-1", false);
|
||||||
|
// Cache rolled back: the comment stays resolved and present.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
|
||||||
|
// Read-only view / panel closed: pageEditorAtom is null on the success path.
|
||||||
|
editorMock.current = null;
|
||||||
|
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
|
||||||
|
const resolved = comment({ resolvedAt: new Date() as any });
|
||||||
|
const { queryClient, wrapper } = seededClient(resolved);
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Comment re-opened successfully",
|
||||||
|
});
|
||||||
|
// Cache updated to reopened (resolvedAt cleared by the server payload).
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
|
||||||
|
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Failed to resolve comment",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
// Rolled back to open (previousCache), still present.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,12 +20,19 @@ import {
|
|||||||
ISuggestionOutcome,
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { Button, Group, Text } from "@mantine/core";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
|
// How long the resolve success toast (with its inline Undo) stays up before it
|
||||||
|
// auto-closes. Policy constant — no env override.
|
||||||
|
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
|
||||||
|
|
||||||
export function useCommentsQuery(params: ICommentParams) {
|
export function useCommentsQuery(params: ICommentParams) {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: RQ_KEY(params.pageId),
|
queryKey: RQ_KEY(params.pageId),
|
||||||
@@ -376,7 +383,25 @@ export function useResolveCommentMutation() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
|
||||||
|
// clear the inline comment mark AFTER the originating CommentListItem has
|
||||||
|
// unmounted (resolving pulls the comment out of the Open list, so its item is
|
||||||
|
// already gone by the time the 10s toast is clicked). In read-only view
|
||||||
|
// pageEditorAtom is null and the mark converges via the server's
|
||||||
|
// COMMENT_MARK_UPDATE job instead.
|
||||||
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
|
const editorRef = useRef(editor);
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
|
||||||
|
// long after the triggering component unmounted. Declared BEFORE useMutation
|
||||||
|
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
|
||||||
|
// definition time, so there is no initialization cycle.
|
||||||
|
const mutationRef = useRef<{
|
||||||
|
mutate: (vars: IResolveComment) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||||
@@ -401,7 +426,39 @@ export function useResolveCommentMutation() {
|
|||||||
|
|
||||||
return { previousCache };
|
return { previousCache };
|
||||||
},
|
},
|
||||||
onError: (_err, variables, context) => {
|
onError: (err: any, variables, context) => {
|
||||||
|
// Terminal 404: the comment was really deleted (missing comment or deleted
|
||||||
|
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
|
||||||
|
// roll back (that would resurrect a phantom row in Resolved); instead drop
|
||||||
|
// it from the cache and clear its now-orphaned inline mark. Mirrors
|
||||||
|
// handleDeleteComment and the dismiss-mutation 404 branch.
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ed = editorRef.current;
|
||||||
|
if (ed && !ed.isDestroyed) {
|
||||||
|
try {
|
||||||
|
ed.commands.unsetComment(variables.commentId);
|
||||||
|
} catch {
|
||||||
|
/* editor gone / mark already removed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Comment no longer exists"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
|
||||||
|
// error (resolve vs. reopen), not always "resolve".
|
||||||
if (context?.previousCache) {
|
if (context?.previousCache) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
RQ_KEY(variables.pageId),
|
RQ_KEY(variables.pageId),
|
||||||
@@ -409,7 +466,9 @@ export function useResolveCommentMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to resolve comment"),
|
message: variables.resolved
|
||||||
|
? t("Failed to resolve comment")
|
||||||
|
: t("Failed to re-open comment"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -430,11 +489,72 @@ export function useResolveCommentMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen keeps the plain toast without an Undo.
|
||||||
|
if (!variables.resolved) {
|
||||||
|
// Clear the inline mark ONLY after the server confirms the reopen, so a
|
||||||
|
// failed reopen never leaves an active highlight the panel still treats
|
||||||
|
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
|
||||||
|
// The button-triggered reopen already set the mark, so this is an
|
||||||
|
// idempotent no-op there.
|
||||||
|
const ed = editorRef.current;
|
||||||
|
if (ed && !ed.isDestroyed) {
|
||||||
|
try {
|
||||||
|
ed.commands.setCommentResolved(variables.commentId, false);
|
||||||
|
} catch {
|
||||||
|
/* editor gone — server COMMENT_MARK_UPDATE converges it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Comment re-opened successfully") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
|
||||||
|
// React.createElement because this is a .ts module (no JSX).
|
||||||
|
const { commentId, pageId } = variables;
|
||||||
|
const notificationId = `resolve-undo-${commentId}`;
|
||||||
|
// Double-click guard: notifications.hide is NOT synchronous, so the button
|
||||||
|
// stays clickable for a frame or two — without this a fast double-click
|
||||||
|
// would fire reopen twice.
|
||||||
|
let done = false;
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: variables.resolved
|
id: notificationId,
|
||||||
? t("Comment resolved successfully")
|
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||||
: t("Comment re-opened successfully"),
|
message: React.createElement(
|
||||||
|
Group,
|
||||||
|
{ justify: "space-between", wrap: "nowrap", gap: "md" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ size: "sm" },
|
||||||
|
t("Comment resolved successfully"),
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
variant: "subtle",
|
||||||
|
size: "compact-sm",
|
||||||
|
onClick: () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
// Reopen via the SAME mutation (read at click time — the
|
||||||
|
// originating item is already unmounted).
|
||||||
|
mutationRef.current?.mutate({
|
||||||
|
commentId,
|
||||||
|
pageId,
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
// The inline mark is cleared in the reopen mutation's onSuccess
|
||||||
|
// (bound to server confirmation), NOT here — clearing it eagerly
|
||||||
|
// would desync the doc from the panel if reopen then fails.
|
||||||
|
notifications.hide(notificationId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
t("Undo"),
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
return mutation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -3,11 +3,20 @@ import { atom } from "jotai";
|
|||||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
// shell component that reads one of these atoms.
|
// shell component that reads one of these atoms.
|
||||||
import type { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
|
// #370 — the active page's collab provider, published by the page editor so the
|
||||||
|
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
|
||||||
|
// Null when the page is read-only / collab isn't connected. A typed initial
|
||||||
|
// value (rather than an explicit generic) keeps jotai's overload resolution on
|
||||||
|
// the writable PrimitiveAtom branch.
|
||||||
|
const initialCollabProvider: HocuspocusProvider | null = null;
|
||||||
|
export const collabProviderAtom = atom(initialCollabProvider);
|
||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||||
|
|
||||||
|
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||||
|
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||||
|
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||||
|
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||||
|
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||||
|
// silently enabling/disabling the toolbar buttons in production.
|
||||||
|
describe("yHistoryAvailability", () => {
|
||||||
|
it("reports availability from the stack lengths", () => {
|
||||||
|
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: false });
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||||
|
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||||
|
// fields: the caller then falls back to the safe prosemirror-history default
|
||||||
|
// instead of throwing on `.length` of undefined or reading garbage.
|
||||||
|
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||||
|
expect(yHistoryAvailability(null)).toBeNull();
|
||||||
|
expect(yHistoryAvailability({})).toBeNull();
|
||||||
|
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||||
|
// Only one stack present (partial rename) is still not trusted.
|
||||||
|
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const text = doc.getText("prosemirror");
|
||||||
|
const undoManager = new Y.UndoManager(text);
|
||||||
|
|
||||||
|
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||||
|
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||||
|
// the expectation below fails loudly.
|
||||||
|
text.insert(0, "hello");
|
||||||
|
undoManager.stopCapturing();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||||
|
undoManager.undo();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,30 @@ export interface ToolbarState {
|
|||||||
// When neither history backend is installed (the pre-sync static editor —
|
// When neither history backend is installed (the pre-sync static editor —
|
||||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||||
// matching the previous `safeCan` behavior.
|
// matching the previous `safeCan` behavior.
|
||||||
|
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||||
|
//
|
||||||
|
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||||
|
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||||
|
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||||
|
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||||
|
// returns null and the caller falls back to a safe default. The pin-test in
|
||||||
|
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||||
|
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||||
|
export function yHistoryAvailability(
|
||||||
|
undoManager: unknown,
|
||||||
|
): { canUndo: boolean; canRedo: boolean } | null {
|
||||||
|
if (!undoManager || typeof undoManager !== "object") return null;
|
||||||
|
const { undoStack, redoStack } = undoManager as {
|
||||||
|
undoStack?: unknown;
|
||||||
|
redoStack?: unknown;
|
||||||
|
};
|
||||||
|
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||||
|
return {
|
||||||
|
canUndo: undoStack.length > 0,
|
||||||
|
canRedo: redoStack.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function historyAvailability(editor: Editor): {
|
function historyAvailability(editor: Editor): {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): {
|
|||||||
|
|
||||||
// Collaboration history (Yjs) takes precedence when present.
|
// Collaboration history (Yjs) takes precedence when present.
|
||||||
const yState = yUndoPluginKey.getState(state) as
|
const yState = yUndoPluginKey.getState(state) as
|
||||||
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
| { undoManager?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (yState?.undoManager) {
|
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||||
return {
|
if (yAvail) return yAvail;
|
||||||
canUndo: yState.undoManager.undoStack.length > 0,
|
|
||||||
canRedo: yState.undoManager.redoStack.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||||
|
// This is also the safe default when a Yjs UndoManager is present but its
|
||||||
|
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||||
return {
|
return {
|
||||||
canUndo: undoDepth(state) > 0,
|
canUndo: undoDepth(state) > 0,
|
||||||
canRedo: redoDepth(state) > 0,
|
canRedo: redoDepth(state) > 0,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
|
collabProviderAtom,
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
VERSION_SAVED_MESSAGE_TYPE,
|
||||||
|
type VersionSavedMessage,
|
||||||
|
saveVersionPending,
|
||||||
|
} from "@/features/page-history/version-messages";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -124,6 +131,7 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
|
const setCollabProvider = useSetAtom(collabProviderAtom);
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
@@ -181,6 +189,24 @@ export default function PageEditor({
|
|||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(payload);
|
const message = JSON.parse(payload);
|
||||||
|
// #370 — a version was saved somewhere; live-refresh the history panel
|
||||||
|
// on every client. Only the client that pressed Save (tracked by the
|
||||||
|
// module-level flag) shows the confirmation toast.
|
||||||
|
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
|
||||||
|
const versionMsg = message as VersionSavedMessage;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-history-list"],
|
||||||
|
});
|
||||||
|
if (saveVersionPending.current) {
|
||||||
|
saveVersionPending.current = false;
|
||||||
|
notifications.show({
|
||||||
|
message: versionMsg.alreadySaved
|
||||||
|
? t("Already saved as the latest version")
|
||||||
|
: t("Version saved"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
@@ -238,12 +264,16 @@ export default function PageEditor({
|
|||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
local.on("synced", onLocalSyncedHandler);
|
||||||
providersRef.current = { socket, local, remote };
|
providersRef.current = { socket, local, remote };
|
||||||
|
// #370 — publish the provider so the header menu can emit save-version.
|
||||||
|
setCollabProvider(remote);
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
|
setCollabProvider(providersRef.current.remote);
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
setCollabProvider(null);
|
||||||
providersRef.current?.socket.destroy();
|
providersRef.current?.socket.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
UnstyledButton,
|
||||||
|
Avatar,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
@@ -7,36 +14,45 @@ import clsx from "clsx";
|
|||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
|
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;
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
index: number;
|
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
||||||
onSelect: (id: string, index: number) => void;
|
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
|
||||||
onHover?: (id: string, index: number) => void;
|
// own id — never a list index (which would be the filtered-view index).
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onHover?: (id: string) => void;
|
||||||
onHoverEnd?: () => void;
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
historyItem,
|
historyItem,
|
||||||
index,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const kindMeta = historyKindMeta(historyItem.kind);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(historyItem.id, index);
|
onSelect(historyItem.id);
|
||||||
}, [onSelect, historyItem.id, index]);
|
}, [onSelect, historyItem.id]);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onHover?.(historyItem.id, index);
|
onHover?.(historyItem.id);
|
||||||
}, [onHover, historyItem.id, index]);
|
}, [onHover, historyItem.id]);
|
||||||
|
|
||||||
const contributors = historyItem.contributors;
|
const contributors = historyItem.contributors;
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
@@ -49,8 +65,20 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={onHoverEnd}
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
|
// #370 — dim autosnapshots so intentional versions stand out.
|
||||||
|
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
|
||||||
>
|
>
|
||||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
variant={kindMeta.version ? "filled" : "light"}
|
||||||
|
color={kindMeta.color}
|
||||||
|
>
|
||||||
|
{t(kindMeta.labelKey)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import {
|
|||||||
usePageHistoryListQuery,
|
usePageHistoryListQuery,
|
||||||
prefetchPageHistory,
|
prefetchPageHistory,
|
||||||
} from "@/features/page-history/queries/page-history-query";
|
} from "@/features/page-history/queries/page-history-query";
|
||||||
import HistoryItem from "@/features/page-history/components/history-item";
|
import HistoryItem, {
|
||||||
|
historyKindMeta,
|
||||||
|
} from "@/features/page-history/components/history-item";
|
||||||
import {
|
import {
|
||||||
activeHistoryIdAtom,
|
activeHistoryIdAtom,
|
||||||
activeHistoryPrevIdAtom,
|
activeHistoryPrevIdAtom,
|
||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -17,9 +19,12 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
|
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
||||||
|
|
||||||
const PREFETCH_DELAY_MS = 150;
|
const PREFETCH_DELAY_MS = 150;
|
||||||
|
|
||||||
@@ -47,6 +52,22 @@ function HistoryList({ pageId }: Props) {
|
|||||||
[pageHistoryData],
|
[pageHistoryData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
|
||||||
|
// null), keep only intentional points (manual/agent). Filtering is over the
|
||||||
|
// already-loaded pages; the diff/restore still targets the true previous
|
||||||
|
// snapshot, so items carry their index within the FULL list.
|
||||||
|
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||||
|
// Reuse historyKindMeta().version — the SAME predicate the badge (HistoryItem)
|
||||||
|
// uses to mark intentional points — so the "Only versions" filter and the badge
|
||||||
|
// can never drift apart when a future intentional kind is added.
|
||||||
|
const visibleItems = useMemo(
|
||||||
|
() =>
|
||||||
|
onlyVersions
|
||||||
|
? historyItems.filter((item) => historyKindMeta(item.kind).version)
|
||||||
|
: historyItems,
|
||||||
|
[historyItems, onlyVersions],
|
||||||
|
);
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -60,11 +81,13 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHover = useCallback(
|
const handleHover = useCallback(
|
||||||
(historyId: string, index: number) => {
|
(historyId: string) => {
|
||||||
clearPrefetchTimeout();
|
clearPrefetchTimeout();
|
||||||
prefetchTimeoutRef.current = setTimeout(() => {
|
prefetchTimeoutRef.current = setTimeout(() => {
|
||||||
prefetchPageHistory(historyId);
|
prefetchPageHistory(historyId);
|
||||||
const prevId = historyItems[index + 1]?.id;
|
// The true previous snapshot in the FULL list (not the previous visible
|
||||||
|
// one under the "only versions" filter).
|
||||||
|
const prevId = resolvePrevSnapshotId(historyItems, historyId);
|
||||||
if (prevId) {
|
if (prevId) {
|
||||||
prefetchPageHistory(prevId);
|
prefetchPageHistory(prevId);
|
||||||
}
|
}
|
||||||
@@ -78,9 +101,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, [clearPrefetchTimeout]);
|
}, [clearPrefetchTimeout]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string, index: number) => {
|
(id: string) => {
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(id);
|
||||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
// Baseline = true previous snapshot in the FULL list, so the "only
|
||||||
|
// versions" filter never diffs/restores against the wrong item.
|
||||||
|
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
||||||
},
|
},
|
||||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
);
|
||||||
@@ -128,12 +153,27 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<Group px="xs" py={6} justify="flex-end">
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={onlyVersions}
|
||||||
|
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
||||||
|
label={t("Only versions")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{historyItems.map((historyItem, index) => (
|
{onlyVersions && visibleItems.length === 0 && (
|
||||||
|
<Center py="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("No saved versions yet.")}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
{visibleItems.map((historyItem) => (
|
||||||
<HistoryItem
|
<HistoryItem
|
||||||
key={historyItem.id}
|
key={historyItem.id}
|
||||||
historyItem={historyItem}
|
historyItem={historyItem}
|
||||||
index={index}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ export interface IPageHistory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
contributors?: IPageHistoryUser[];
|
contributors?: IPageHistoryUser[];
|
||||||
|
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
|
||||||
|
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
|
||||||
|
// autosave. Derived server-side, drives the history badge + "versions" filter.
|
||||||
|
kind?: "manual" | "agent" | "idle" | "boundary" | null;
|
||||||
// Provenance markers copied off the page row when the snapshot was saved.
|
// Provenance markers copied off the page row when the snapshot was saved.
|
||||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||||
// (when present) deep-links to the chat that produced the edit.
|
// (when present) deep-links to the chat that produced the edit.
|
||||||
|
|||||||
@@ -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,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
|
||||||
|
|
||||||
|
// #370 F4 — the risky client path: with the "only versions" filter active, diff
|
||||||
|
// and restore must still baseline against the TRUE previous snapshot in the FULL
|
||||||
|
// list, never the previous VISIBLE version (which would skip the autosnapshots
|
||||||
|
// between two versions). These pin that the resolution is by FULL-list order.
|
||||||
|
describe("resolvePrevSnapshotId", () => {
|
||||||
|
// Newest-first, as the history list stores it: a version, then two autosaves,
|
||||||
|
// then an older version.
|
||||||
|
const full = [
|
||||||
|
{ id: "v2", kind: "manual" },
|
||||||
|
{ id: "a2", kind: "idle" },
|
||||||
|
{ id: "a1", kind: "boundary" },
|
||||||
|
{ id: "v1", kind: "manual" },
|
||||||
|
{ id: "a0", kind: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the immediate FULL-list successor, not the previous visible version", () => {
|
||||||
|
// Selecting v2 while filtered to versions-only must baseline against a2 (the
|
||||||
|
// real chronological predecessor), NOT v1 (the previous visible version).
|
||||||
|
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an autosnapshot's predecessor by full-list order", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '' for the oldest item (no predecessor)", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '' for an id not in the list", () => {
|
||||||
|
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not depend on a filtered subset — same result whatever is visible", () => {
|
||||||
|
// The helper only ever sees the full list; a filtered view cannot change the
|
||||||
|
// baseline it computes.
|
||||||
|
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* #370 — resolve the TRUE previous snapshot for a history item.
|
||||||
|
*
|
||||||
|
* The history panel can be filtered to "only versions" (manual/agent), but diff
|
||||||
|
* and restore must always compare against the immediately-preceding snapshot in
|
||||||
|
* the FULL, unfiltered list — NOT the previous VISIBLE item. Comparing against
|
||||||
|
* the previous visible version would silently skip the autosnapshots between two
|
||||||
|
* versions and diff/restore the wrong baseline.
|
||||||
|
*
|
||||||
|
* Given the full (newest-first) list and an item id, this returns the id of the
|
||||||
|
* item right after it in the full list (its chronological predecessor), or "" if
|
||||||
|
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
|
||||||
|
* tested without mounting the component.
|
||||||
|
*/
|
||||||
|
export function resolvePrevSnapshotId(
|
||||||
|
fullItems: ReadonlyArray<{ id: string }>,
|
||||||
|
id: string,
|
||||||
|
): string {
|
||||||
|
const index = fullItems.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return "";
|
||||||
|
return fullItems[index + 1]?.id ?? "";
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
/**
|
||||||
|
* #370 — page-version stateless wire formats. Kept in one place so the client
|
||||||
|
* emitter (Save hotkey / button) and the client listener (page-editor) agree
|
||||||
|
* with the server (PersistenceExtension) on the message shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Client → server: "save a version now". The server derives the tier
|
||||||
|
* (manual/agent) from the signed connection actor, never from this payload. */
|
||||||
|
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
||||||
|
|
||||||
|
/** Server → all clients: a version was saved (or promoted / already existed). */
|
||||||
|
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
||||||
|
|
||||||
|
export interface VersionSavedMessage {
|
||||||
|
type: typeof VERSION_SAVED_MESSAGE_TYPE;
|
||||||
|
historyId: string;
|
||||||
|
kind: "manual" | "agent";
|
||||||
|
/** True when the latest snapshot was already a manual version (a no-op save). */
|
||||||
|
alreadySaved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-component coordination flag so only the client that pressed Save shows
|
||||||
|
* the confirmation toast, while every other client silently refreshes its
|
||||||
|
* history panel on the broadcast. A module-level ref avoids stale-closure
|
||||||
|
* pitfalls in the editor's long-lived stateless handler.
|
||||||
|
*/
|
||||||
|
export const saveVersionPending = { current: false };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user