Compare commits

..

101 Commits

Author SHA1 Message Date
a
c4ed4a4855 fix(footnotes): strip bare definitions on rebuild; MCP full-doc + zip-import canonicalize tests (#228)
Review #6 (approve-with-comments) follow-ups:
1. canonicalize step 7 now strips bare footnoteDefinitions at ANY depth
   (stripFootnoteDefinitionsDeep), not just footnotesList, in BOTH copies. A
   definition hand-authored outside a list (e.g. nested in a callout via a
   raw-JSON write path) was left in place while a copy was also added to the
   rebuilt list -> duplicate, idempotent, self-perpetuating. Runs only in the
   rebuild path (after the lists are stripped); the fast-path / placement-keep
   branch is untouched. Added a shared-corpus case (bare def nested in a callout)
   to pin it in both mirrors.
2. markdown-clipboard: removed the dead top-level footnoteReference check in
   canonicalizePastedFootnotes (an inline atom is never a top-level slice child;
   only the descendants scan can find it).

Test coverage:
4. New MCP binding tests (full-doc-write-canonicalize.test.mjs): update_page_json
   and copy_page_content canonicalize the persisted full doc, asserted via a new
   `replacePage` seam (symmetric to the existing `mutatePage` seam) so no live
   collab socket is needed. Routed both writers through the seam.
5. New server spec (file-import-task.service.footnote-canonicalize.spec.ts): the
   zip-import path (processGenericImport) canonicalizes footnotes — real
   markdown->HTML->JSON via a real ImportService over a temp-dir .md file, DB trx
   stubbed to capture the persisted page content. FileImportTaskService had no
   spec before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:39:25 +03:00
a
9c1f952b2f fix(footnotes): guard insert against nested/bare definitions, skip definitions-only paste, doc + reorder fixes (#228)
Must-fix:
- insertInlineFootnote could glue a footnoteReference inside an EXISTING
  definition (nested footnotesList, or a bare footnoteDefinition with no list
  wrapper), which canonicalize then dropped as an orphan — silently losing the
  definition's prose. Now: (a) the body/notes boundary is computed from the first
  top-level block that IS or CONTAINS (recursively) a footnotesList/
  footnoteDefinition, not just a top-level list; and (b) the insertNodesAfterAnchor
  core skips footnotesList/footnoteDefinition subtrees entirely (skipSubtreeTypes),
  so an anchor whose only match is inside a definition -> inserted:false (clean
  abort, no write). Added tests: nested-definition, bare-definition, and
  body-before-nested-list-still-inserts.
- editor-ext footnote-canonicalize header listed `markdownToProseMirror` among the
  canonicalizing MCP paths; it is the NON-canonicalizing primitive. Replaced with
  `markdownToProseMirrorCanonical` (+ note that the plain primitive is for comment
  bodies) and added copy_page_content.
- Client paste: canonicalizePastedFootnotes now skips a definitions-ONLY paste
  (no footnoteReference anywhere) — canonicalizing it would strip the
  reference-less list and yield an EMPTY paste. Added a test.

Suggestions:
- docmost_transform now runs validateDocStructure/validateDocUrls on the RAW
  transform output BEFORE canonicalizeFootnotes (mirrors updatePageJson), so a
  too-deep doc gives the intended max-depth error instead of a stack overflow.
- docmost_transform tool description now states the RESULT is footnote-canonical
  (dryRun diff may show tidy-ups; idempotent after first run).
- insertFootnote: dropped the dead `result ? … : undefined` ternaries and the
  `as any` casts (result is always set by the time we return; the not-found path
  throws and aborts mutatePage). `const r = result!;`.

Tests / architecture:
- Added a LIVE-plugin golden case: the real footnoteSyncPlugin leaves a list with
  non-empty content after it in place, and canonicalize agrees (placement parity
  is now a driven property, not a hand-set expected).
- Added generateFootnoteId uuidv7 shape + uniqueness test.
- Item 9: added the ENFORCEMENT-RULE comments at the server parseProsemirrorContent
  and the MCP canonicalizer header (any NEW full-doc persist path MUST canonicalize;
  fragments/append/prepend and comment bodies MUST NOT). Kept per-call-site over a
  brittle grep CI test (the replace-vs-fragment + comment-vs-page nuance makes a
  single wrapper unsafe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:40:28 +03:00
a
3fd66b4245 fix(footnotes): don't canonicalize comment bodies (data loss); canonicalize only page write paths (#228)
Must-fix (REAL DATA LOSS):
- markdownToProseMirror is reused for COMMENT bodies (createComment/updateComment).
  It unconditionally canonicalized, so a comment carrying a standalone footnote
  definition ([^1]: text with no matching reference) had its whole footnotesList
  stripped (referenceIds.length===0 -> stripFootnotesListsDeep) — the text
  vanished. Fix: markdownToProseMirror no longer canonicalizes (content-preserving
  primitive); a new markdownToProseMirrorCanonical wraps it for the PAGE write
  paths (markdown import via importPageMarkdown, update_page markdown via
  updatePageContentRealtime). Comment callers keep the non-canonicalizing
  primitive. Updated the now-false header comment and added create/update-comment
  inline notes. Added collaboration tests: comment path PRESERVES a reference-less
  definition; page path still drops it AND still reorders real footnotes. Updated
  the page-import canonicalization test to use the canonical variant.

Suggestions / architecture:
- #2: collapsed transforms.footnoteDefinition onto the shared
  makeFootnoteDefinition factory (adds only the inner paragraph block id); kept
  the dependency direction transforms -> footnote-authoring (no circular import,
  mirror stays pure).
- #3: confirmed docmost_transform auto-canonicalization is documented (inline
  comment, tool description, CHANGELOG) — no code change.
- #4: copyPageContent is a FULL-document write (replacePageContent of a
  type:"doc"); added a defensive canonicalizeFootnotes pass (no-op on
  already-canonical source).
- CHANGELOG entry refined to list the FULL-document write paths (incl.
  copy_page_content) and to state canonicalization is NOT applied to comment
  bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:17:15 +03:00
a
a77a0bc92b fix(footnotes): re-review #232 — refuse footnoteRef into codeBlock/definition, deep-strip nested lists, docs + cross-copy guard (#228)
Must-fix:
- REAL BUG: insertInlineFootnote could splice a footnoteReference (inline atom)
  into a codeBlock or an existing footnoteDefinition, persisting a schema-invalid
  doc (insert_footnote skips validateDocStructure). Now the search is bounded to
  the BODY (before the first footnotesList) and the insertNodesAfterAnchor core
  refuses textblocks that can't hold the atom (codeBlock); when the only match is
  in such a place the insert returns inserted:false and the write aborts cleanly.
  Reachable via docmost_transform too. Added codeBlock / definition / fall-through
  tests.
- Fixed the deepEqualJson doc comment in both copies: arrays are order-SENSITIVE
  (correctness depends on it), only object keys are order-insensitive.
- README.ru.md MCP tool count 38 -> 39 (lines 36/47/63), matching README.md/AGENTS.
- CHANGELOG [Unreleased] Added entry for insert_footnote + server-side footnote
  canonicalization on non-editor write paths (#228).

Suggestions:
- canonicalize step 5/7 now strips footnotesList at ANY depth (both copies), so a
  schema-valid list nested in a callout/blockquote can't leave duplicate defs.
- Exclude the test-only footnote-corpus.ts fixture from the editor-ext build
  (tsconfig), so it no longer ships in dist/.
- Removed the duplicate manual canonicalize cases from the MCP unit test (the
  shared corpus covers them via full deepEqual); kept idempotence + immutability.
- insertInlineFootnote dedup key now keys off the inline array directly
  (footnoteContentKey({ content: inline })) instead of a throwaway node.

Tests / architecture:
- New client-wrapper test (#9): overrides a small mutatePage seam to assert the
  not-found path throws and persists NOTHING, and the success path shapes
  footnoteId/reused/message/verify and writes the right content. Fixed the
  misleading comment in footnote-write.test.mjs.
- B: cross-copy corpus parity guard test (loads both corpora, asserts deep-equal)
  so a typo in one copy can't pass both suites green.
- A: declined — the full-vs-fragment decision lives at the call site, so a
  prepareDocForPersist wrapper would be a bare alias for canonicalizeFootnotes;
  kept the existing per-call-site comments instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:41:10 +03:00
a
07ebd8c63e fix(footnotes): address PR #232 review — fragment-safe canonicalization, plugin placement parity, dead-code removal (#228)
Must-fix:
- Move canonicalizeFootnotes OUT of parseProsemirrorContent. It now runs only
  on FULL writes (createPage, updatePageContent operation==='replace'), never on
  an append/prepend fragment (a fragment would lose definition-only footnotes or
  synthesize a bogus empty list). Add a server binding spec.
- Match the live plugin's list PLACEMENT: a single already-canonical
  footnotesList is left exactly where it sits (the plugin never repositions a
  sole correct list), so the first write no longer reorders content that follows
  the list. Applied to BOTH the editor-ext copy and the MCP mirror; pinned by a
  shared golden corpus case with content after the list.
- Fix MCP tool count 38 -> 39 (README x3, AGENTS.md) and the transformJs param
  help (add canonicalizeFootnotes/insertInlineFootnote).

Simplifications:
- Remove the dead duplicate re-id mechanism (deriveFootnoteId/suffix/occurrence)
  from the PURE canonicalizer in both copies — references are never renamed, so
  the derived ids were never requested; first-wins-drop is the real behaviour.
  This also makes the editor-ext footnote-util note about "no cross-package copy"
  true again.
- Remove the sentinel round-trip in insertInlineFootnote: a generalized
  insertNodesAfterAnchor core inserts the footnoteReference node directly.
- Drop the redundant per-definition deep clone in step 4 (shallow id-normalizing
  copy; out is already deep-cloned).

Docs / architecture:
- Correct the editor-ext copy's "It exists because…" header to its real
  consumers (server import, page.service create/update, client paste).
- Note markdownToProseMirror reuse for create/update comment in collaboration.ts.
- A: shared golden JSON corpus exercised by BOTH the editor-ext copy and the MCP
  mirror (footnote-corpus.ts / .mjs) so "the two copies behave identically" is
  checkable.
- C: split the MCP canonicalizer into a pure mirror + footnote-authoring.ts.
- B: import services persist via a different path, so left one-line consolidation
  comments at the call sites rather than folding (does not fall out cleanly).

Tests: insertFootnote wrapper guards + docmost_transform dryRun auto-canonicalize
(MCP mock), page.service create/update + append/prepend binding (server jest),
shared corpus incl. nested-container reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:23:16 +03:00
a
fa929c9e86 fix(footnotes): canonicalize footnotes on server import + markdown paste (#228)
The footnote canonicalizer was wired into the MCP and editor-ext write paths
but NOT into the server's user-facing markdown/HTML import paths, so importing
or pasting markdown with out-of-order, reused, or orphan footnotes did not
canonicalize -- the exact trigger bug #228 fixes was still reproduced on
import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never
runs the editor's footnoteSyncPlugin, and that plugin does not reorder an
existing list, so the stored footnotes kept the source's physical definition
order, retained orphans, and did not collapse reused references.

Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into
every server markdown/HTML -> page-JSON seam, before persisting:
  - ImportService.importPage (REST single-file .md/.html import)
  - FileImportTaskService (zip import worker)
  - PageService.parseProsemirrorContent (API createPage / updatePageContent)

Also hook the client markdown paste: handlePaste applies a manual transaction
(returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted
out-of-order markdown footnote block would persist out of order.
canonicalizePastedFootnotes reorders a self-contained pasted block (one that
carries its own footnotesList) to reference order, deduped and orphan-free; it
is deliberately scoped to whole-block pastes so a reference-only paste that
reuses a footnote already defined in the target doc is left untouched.

canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no
footnotes is unchanged), so it is safe on every write path.

Residual: when a pasted block merges into a doc that already has footnotes,
ordering relative to the pre-existing footnotes is still governed by the live
sync plugin (which does not reorder across the boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:10:41 +03:00
claude code agent 227
30cb9d293c feat(footnotes): inline authoring + deterministic server-side canonicalization
Make footnotes author-inline: the agent/tool inserts a footnote at its point
of use (anchor + text) and the numbering plus the bottom list are DERIVED
deterministically server-side. The agent has no access to footnotesList and
cannot desync — out-of-order lists, orphan definitions, and raw trailing
[^id] blocks become structurally impossible.

editor-ext:
- canonicalizeFootnotes(docJSON) -> docJSON: a pure, EditorView-free port of
  footnoteSyncPlugin's end-state. Distinct reference ids in document order are
  the source of truth; exactly one trailing footnotesList holds one definition
  per referenced id in reference order (reusing the existing node or
  synthesizing an empty one); orphans dropped; duplicate definitions resolved
  deterministically (first wins, never lost); idempotent.
- Unit tests + a golden parity suite: on every editor-reachable steady state
  the live footnoteSyncPlugin's JSON is a canonicalize no-op (byte-for-byte
  parity), and the canonicalizer additionally repairs the out-of-order list a
  non-editor write produces.

mcp:
- footnote-canonicalize.ts: behavioural mirror of the editor-ext canonicalizer
  (the MCP package is intentionally decoupled from the editor barrel, like
  footnote-lex/docmost-schema), plus footnoteContentKey for content dedup.
- Auto-canonicalize on EVERY write path: markdownToProseMirror (fixes import
  ordering), update_page_json, and after every docmost_transform. Idempotent,
  so it is a no-op when footnotes are already canonical.
- insert_footnote tool + insertInlineFootnote: anchor + markdown text -> a
  mark-safe footnoteReference and a content-dedup'd definition; the list and
  numbering are derived. Same-content footnotes reuse one number/definition.
- canonicalizeFootnotes + insertInlineFootnote exposed as docmost_transform
  sandbox helpers.

Tests: editor-ext 157 green; MCP 325 green; server + client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:35:25 +03:00
claude_code
904f7b4303 fix(agent-roles): bump proofreader v3 + guard against content edits without a version bump
The proofreader role content was changed (STYLE SHEET block removed) without
bumping its catalog version, so clients never saw an update. Bump proofreader
2 -> 3, and add a content-hash guard so this can't happen silently again.

- index.json: proofreader version 2 -> 3
- scripts/check.mjs: new content-hash guard. A scripts/content-hashes.json lock
  maps slug -> { version, hash } (sha256 over emoji/autoStart/name/description/
  instructions/launchMessage across all languages). check.mjs now fails when a
  role's content changed without bumping its version; the new --update-hashes
  (alias --fix) refreshes the lock but refuses to write when a bump is missing.
- check.mjs: also require every index.json role to carry a finite numeric
  version (matches the server's catalog validation), with defense-in-depth so a
  missing version can't bypass the bump guard.
- scripts/content-hashes.json: new lock artifact (not part of the served catalog).
- README.md: document the guard, the lockfile, --update-hashes, and the
  prune-then-readd limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 05:18:39 +03:00
claude_code
cac84dec9b refactor(ai-roles): make catalog URL a per-branch image default, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer
supports a local filesystem directory. The provider fetches only from an
http(s):// base URL read at runtime from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The image ships a per-branch
default for that URL (set in CI), still overridable at runtime via the env var.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  reflects the per-branch build-time default that is runtime-overridable.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage as
  the image default.
- CI: develop.yml builds with the develop raw URL; release.yml defines the main
  raw URL once in workflow env and references it from both build steps.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:54:43 +03:00
claude_code
90dd8f1481 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:54:24 +03:00
39113c9dbf Merge pull request 'fix(share): custom address edit renames in place instead of duplicating (#226)' (#227) from fix/share-alias-rename into develop
Reviewed-on: #227
2026-06-27 03:53:31 +03:00
claude_code
1367070468 refactor(agent-roles): drop style-sheet duties from copyeditor role
Remove the STYLE SHEET / СТАЙЛ-ШИТ section from the copyeditor
(proofreader) role and clean up all dangling references to it in both
the ru and en editorial bundles:
- description: drop "maintains a style sheet" / "ведёт стайл-шит"
- instructions: remove the STYLE SHEET block
- instructions: drop "record it in the style sheet" mentions in the
  WHAT YOU DO and WHEN UNSURE sections

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:46:03 +03:00
claude code agent 227
767ac9e7e2 fix(share): guard alias swap/rename against concurrent-delete race; share unique-violation helpers
Address PR #227 re-review (comment 2193).

- Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row
  reaped by a concurrent `removeAlias` between the read and the UPDATE (READ
  COMMITTED) raises `NoResultError` instead of returning `undefined`. The service
  maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a
  200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests
  cover both branches.
- Simplification: drop the redundant secondary "unexpected unique index" warn and
  the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged
  unconditionally; both index branches still distinguish "Alias already taken" vs
  ALIAS_PAGE_RACE).
- Architecture: extract `isUniqueViolation`/`violatedConstraint` into
  database/utils.ts; adopt them in the share-alias service and favorite.repo
  (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch
  and should adopt them after #227 merges (noted at the helpers). Helper unit test
  added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:33:33 +03:00
claude_code
2a4ef9267e refactor(ai-roles): bake catalog URL at image build, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no
longer supports a local filesystem directory. The provider now fetches only
from an http(s):// base URL read from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The default URL is baked
into the Docker image at build time and set per branch in CI.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  updated to reflect build-time injection, remote-only.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage.
- CI: develop.yml builds with the develop raw URL; release.yml (both build
  steps) with the main raw URL.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:32:48 +03:00
claude code agent 227
309719abc6 fix(share): show reassign hint instead of dead-end error for a taken custom address
The share modal flagged a custom address already owned by another page with a
red "This address is already in use" error driven by the availability probe.
That reads as terminal even though Save actually triggers the server's
409 `ALIAS_REASSIGN_REQUIRED` and opens the "Move custom address?" confirm
modal that retargets the address to the current page — so the reassign path was
hidden behind what looked like a hard stop.

Replace the red error with an informational description hint ("This address is
in use. Saving will move it to this page.") and keep Save enabled, so the
existing confirm-reassign flow is discoverable. Renaming to a FREE name was
already correct (the probe returns available -> no error -> server renames the
single row in place); this only changes the taken-name presentation.

Verified end-to-end in a real browser against a live stand on this branch:
- A (free rename `test`->`test2`): 200, same alias row renamed in place, link
  becomes `/l/test2`, no error, exactly one DB row for the page.
- B (`test2` owned by another page): hint shown (no dead-end error), Save ->
  409 ALIAS_REASSIGN_REQUIRED -> "Move custom address?" modal -> confirm -> 200,
  the single row retargets, one row each.
- C (same-name re-save): Save disabled (no-op); first-time set inserts.

Add a client component test covering both branches (taken name -> hint not
error + Save enabled; 409 -> reassign modal -> confirm sends confirmReassign).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:24:00 +03:00
claude_code
3511301331 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:12:27 +03:00
claude_code
b65ca6d7dd chore(agent-roles-catalog): merge copy-editor into proofreader, refresh editorial roles
Merge the copy-editor (📐) and proofreader (🧹 "Корректор") editorial roles
into a single role. Keep slug `proofreader`, drop slug `copy-editor`, and set
the merged role's emoji to 📐.

- index.json: remove copy-editor; bump structural-editor, line-editor,
  fact-checker, proofreader to version 2 (narrator unchanged); update editorial
  bundle description (ru/en).
- bundles/editorial/{ru,en}.json: delete copy-editor; refresh emoji/name/
  description/instructions of structural-editor, line-editor, fact-checker and
  the merged proofreader verbatim from gitmost-agenty-ru.md / gitmost-agents-en.md;
  preserve autoStart and launchMessage; leave narrator untouched.
- README.md: drop copy-editor from the editorial role list.

Validated with scripts/check.mjs (OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:12:14 +03:00
4a3819373d Merge pull request 'feat(ai-chat): auto-open last chat bound to the document (#191)' (#209) from feat/191-chat-doc-binding into develop
Reviewed-on: #209
2026-06-27 02:56:31 +03:00
claude code agent 227
e682bbccd1 fix(share): order swap delete-before-update and distinguish unique violations
Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:52:33 +03:00
claude code agent 227
9d2bec8eb8 fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update
the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by
name and, if free, INSERTed a brand-new row, leaving the page with multiple
alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no
`ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale)
row. Every edit also spawned an orphan row that kept a live `/l/<old>` link
forever. Regression of #205.

Enforce the invariant "a page has EXACTLY ONE custom address":
- `setAlias()` now resolves the page's current alias row and RENAMES it in
  place when the requested name is free (insert only when the page has none),
  keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED`
  + confirmed-retarget flow, and after any successful write DELETEs all other
  alias rows for the page (self-heal). Runs in one transaction so the page is
  never transiently empty or duplicated.
- repo: add `updateAlias` (rename) and `deleteOthersForPage`; make
  `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`.
- migration: dedup existing rows (keep newest per page) + a PARTIAL unique
  index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling
  aliases still coexist while live ones are one-per-page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:51:51 +03:00
b6630deb32 Merge pull request 'feat(ai-roles): импортируемый мультиязычный каталог ролей агента' (#222) from feature/agent-roles-catalog into develop
Reviewed-on: #222
2026-06-27 02:39:27 +03:00
claude code agent 227
7ef98a663b Address PR #222 review: import-mutation notification tests + redirect-SSRF hardening
ITEM 1: cover useImportAiRolesFromCatalogMutation onSuccess notifications.
Add import-from-catalog-message.test.tsx (twin of update-from-catalog-message)
asserting the always-shown summary (errors:[]) and the additional red
"Failed to import N role(s)" notification when result.errors is non-empty.

ITEM 2: pass redirect:'error' to the remote catalog fetch in fetchRemote so a
compromised-but-trusted upstream cannot 3xx the fetch into the internal network
(redirect-SSRF). Add provider specs asserting the option is passed and that a
redirect rejection maps to BadGatewayException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:36:28 +03:00
109ab10fc5 Merge pull request 'fix(temporary-notes): tree clock marker updates without reload + mobile-friendly full-width create buttons' (#225) from fix/temporary-notes-ui into develop
Reviewed-on: #225
2026-06-27 01:39:10 +03:00
claude code agent 227
2b7c861f78 Address PR #222 re-review: fix source-uniqueness detection + coverage/cleanups
MUST-FIX
- isSourceUniqueViolation read the wrong error field: kysely-postgres-js
  (postgres@3.4.8) puts the violated constraint on `constraint_name`, not
  node-postgres' `.constraint`, so a concurrent same-slug+language import's
  23505 was never recognized as a source-collision and surfaced a false
  "name already exists" error. Now read `constraint_name` (with `.constraint`
  as a fallback for other drivers). Fix the faked test fixture (it built the
  error with the same wrong `.constraint` field, masking the bug): it now
  uses `constraint_name`, so the test genuinely exercises the skip path and
  FAILS against the unfixed code.
- Extract the catalog modal's role-state computation into a pure
  `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors
  role-launch.ts) and cover it with vitest: import / installed / update /
  same-slug-different-language.

SUGGESTIONS
- Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring
  the server; narrow the consumer via `"reason" in result` (the boolean
  discriminant does not narrow under strictNullChecks:false).
- README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL
  (remote http(s) base / local path / empty => in-repo folder).
- check.mjs: drop the redundant `const key = slug` alias.
- Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation
  (4 branches) via renderHook with a mocked service.
- Cover importFromCatalog "bundle not in index" => BadGateway.
- Cover updateFromCatalog "slug in index but missing in bundle file" =>
  not-in-catalog.

ARCHITECTURE
- Extract the shared catalog read prefix: a private `loadBundleById`
  (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle
  and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the
  import insert and update patch. The three orchestrations and their distinct
  write paths stay separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:01:29 +03:00
claude code agent 227
d181b5c4ff test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work.

Tests:
- tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a)
  server node inserted WITHOUT a deadline + create response carries one => node
  gains the deadline; (b) node already has a deadline => not overwritten, prev
  returned by reference.
- ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried
  when present and pinned to null (`?? null`) when absent.
- page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree
  node's temporaryExpiresAt, and leaves the atom value at the same reference when
  the id is absent from the loaded tree (no write).

Refactor (closes the drift bug-class at the root):
- Client: extract one canonical pageToTreeNode(page, overrides) mapper in
  tree/utils and route buildTree, handleCreate's optimistic insert, the restore
  mutation and the duplicate handler through it. Restore stays permanent (server
  nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer)
  — both now reflect the server without a reload, where before they dropped the
  field entirely.
- Server: extract one toTreeNodeSnapshot(page) helper called by both the
  PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast
  (ws-tree.service), so the optional temporaryExpiresAt can't drift between the
  two literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:58:40 +03:00
claude code agent 227
12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/
disappear until a page reload when a note's temporary state changed.

- Make/unmake permanent from the page header menu and the in-page banner went
  through syncTemporaryExpiresInCache(), which patched the page query cache but
  never touched treeDataAtom, so the sidebar node kept its stale
  temporaryExpiresAt. Patch the tree node there too (via jotai's default store),
  so the marker updates without a reload.
- Creating a note as temporary showed no marker until reload: the create flow's
  cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree
  rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's
  marker with undefined. Carry temporaryExpiresAt in that cached entry.
- Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED
  snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching
  the space also render the marker immediately, and harden handleCreate's
  idempotency guard to patch the deadline if the broadcast won the insert race.

Issue 2 — the home and space-overview "New note" / "New temporary note" buttons
sat side-by-side and the temporary label clipped on narrow mobile widths. Lay
them out full-width, stacked vertically, and tint the temporary button orange
(matching the clock marker + banner) while the regular one stays neutral gray.

Tests: extend tree-socket-reducers.test.ts (addTreeNode carries
temporaryExpiresAt). Verified live with Playwright: marker appears on create and
toggles both ways with no reload; mobile buttons are stacked, full-width,
unclipped, and differently colored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:19 +03:00
claude code agent 227
26ca19f89e agent-roles: concurrency-safe catalog import + unified source validator
Item 1 (concurrency-safe import): add a partial UNIQUE index on
(workspace_id, source->>'slug', source->>'language') WHERE source IS NOT NULL
AND deleted_at IS NULL, so two concurrent imports of the same bundle can no
longer create duplicate roles for one catalog slug+language. The in-memory
installedKeys snapshot cannot see a sibling request's writes; the index is the
backstop. importFromCatalog now catches the 23505 from THIS index (keyed off
the constraint name) and treats it as "already installed" -> skip, batch
continues. A 23505 from the name-uniqueness index keeps its existing friendly
per-role error behavior (distinguished by constraint name; an indeterminate
23505 falls back to that path, so no regression).

Item 2 (single source validator): strengthen parseSource into THE single form
validator for the source jsonb column -> returns a fully-valid RoleSource | null
(slug/language non-empty strings, version a number). The service's weaker
roleSource is removed and both layers share the RoleSource type (defined in the
db entity.types module both already import AiAgentRole from, so no import
cycle). normalizeRow / the read path now only ever yield a valid RoleSource or
null; a malformed stored source normalizes to null (tolerated by the service).

Tests: parseSource null for {} / {slug:123} / {slug:'a'} / empty-string keys /
string version, typed value for a full valid shape; service test that a
source-uniqueness 23505 is skipped (not errored) and the batch continues.
Verified the partial index rejects a duplicate source-not-null row but allows
two source-NULL rows, and the migration up/down run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:40:25 +03:00
claude code agent 227
50e79275e1 Address review on agent-roles catalog: changelog, docs, BadGateway on body-read abort
- CHANGELOG: document the importable multilingual agent-roles catalog under
  [Unreleased] (browse/import/update, 4 new endpoints, source column, the new
  AI_AGENT_ROLES_CATALOG_URL env var) (#222).
- Fix importFromCatalog docstring: a role is skipped only on source.slug AND
  source.language; another language of the same slug still imports.
- Provider: map a timeout/abort (or any failure) during the response-BODY read
  to a logged BadGatewayException, so a slow/dripping source yields a 502, not a
  generic 500. Existing too-large BadGateway cases are rethrown as-is.
- Service: inject a Nest Logger and log the root cause (with workspaceId/
  bundleId/slug) on a non-23505 insert error during import.
- Modal: hoist the duplicated i18n base-subtag into a single baseLang const.
- Tests: AbortError body-read -> BadGateway; null-body text() fallback (under
  and over cap); invalid-JSON and malformed-index BadGateway; non-23505 import
  error -> generic message + logged root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:15:45 +03:00
claude code agent 227
8be8279809 Address PR #222 review: migration order, provider logging, catalog tests
- Rename catalog-source migration 20260626T120000 -> T150000 so it sorts
  after develop's latest migration (T140000-page-temporary-notes); the old
  timestamp predated ai-chat-message-status/share-aliases and tripped
  Kysely's #ensureMigrationsInOrder, aborting server boot.
- Provider: inject a Nest Logger and log the real cause (incl. response
  status) in the parseJson / readLocal / fetchRemote catch blocks, and
  propagate a useful cause into the BadGatewayException message; add a
  shortError helper (robust to jest's realm-shifted Error-likes).
- Provider: replace the manual Uint8Array assembly with
  Buffer.concat(chunks).toString('utf8'); keep the streaming size cap.
- Controller spec: add admin-gate coverage for the 4 catalog routes
  (catalog/catalogBundle/import/updateFromCatalog) - non-admin Forbidden +
  service untouched, admin delegates with the right args.
- Service spec: add getCatalog/getCatalogBundle tests covering the
  localized() three-tier fallback, the sorted language union, the
  missing-bundle BadGateway, and the role-version default.
- Provider spec: add remote fetch-rejects and non-ok (503) error branches.
- Service: drop the dead Date.now() tail in freeName (now an explicit
  unreachable throw) and extract a shared isUniqueViolation() predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
19f84ca0e7 feat(ai-roles): add importable, multilingual agent roles catalog
Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.

Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.

Server:
- migration: nullable jsonb `source` column on ai_agent_roles
  ({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
  read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`

Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
e9409e245b style(share): drop divider line from custom-address prefix
The right border on the address prefix read as a stray vertical line
between the domain and the slug. Remove it and rely on the subtle
prefix background alone to separate the two parts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:08 +03:00
claude_code
fa6a87e22d test(ai-chat): cover MessageList parent-side signature snapshot (#224)
PR #224 fixed an AI-chat streaming-render regression by moving the React.memo
content signature into the parent: MessageList now snapshots
messageSignature(message) per render and passes it to MessageItem as the
immutable `signature` prop. The existing memo tests only SIMULATED that
parent half by hardcoding `signature={messageSignature(message)}` in their
harness; the real MessageList was never exercised (chat-thread.test.tsx mocks
it out, and there was no message-list.test).

Add message-list.test.tsx that mounts the REAL MessageList (without mocking
MessageItem or messageSignature) and asserts that an in-place mutation of a
reused message object surfaces on re-render. This guards the parent-side
contract: re-caching the signature on message identity (stable across deltas
while parts mutate) would refreeze the row, and this test would fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:01 +03:00
claude_code
0fc9c4a998 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 22:09:22 +03:00
claude_code
40b8f7922a feat(client): quick-create regular and temporary notes from Home and Space screens
Add fast note-creation entry points alongside the existing space-sidebar
actions.

- Home: refactor new-note-button.tsx into a reusable inner CreateNoteButton
  (parametrized by `temporary`/label/icon, keeps the 0/1/many writable-space
  resolution and space-picker dropdown) and render two equal-width buttons via
  `Group grow` — a regular note and a temporary note (IconHourglass).
- Space overview: new SpaceCreateNoteButtons component with two buttons that
  create a regular/temporary note directly in the current space and open it,
  reusing useTreeMutation.handleCreate (optimistic sidebar-tree insert +
  navigation). Permission-gated to members who can manage pages; a local
  pending state shows a per-button spinner and disables both to prevent a
  double-create. Wired into space-home.tsx above the tabs.
- Reuse existing i18n keys (no new strings): "New note", "New temporary note",
  "Create in space".
- Docs: add a CHANGELOG [Unreleased] entry and a "Temporary notes" roadmap
  bullet to README.md and README.ru.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:09 +03:00
08c70cf550 Merge pull request 'fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression)' (#224) from fix/ai-chat-empty-render into develop
Reviewed-on: #224
2026-06-26 22:09:05 +03:00
claude code agent 227
ae6ed76d9a fix(ai-chat): assistant turn renders empty — memo froze on in-place part mutation
The floating AI chat rendered NOTHING for the assistant turn (user bubble +
"thinking" dots showed, but the streamed text and tool-call cards never
appeared) even though the agent ran server-side. The parts DID arrive in
`useChat.messages` — this was purely a render freeze.

Root cause: the MessageItem `React.memo` comparator (#182) decided whether to
re-render by recomputing `messageSignature(prev.message)` vs
`messageSignature(next.message)` inside `arePropsEqual` (plus a
`prev.message === next.message` fast path). But the AI SDK (ai@6 /
@ai-sdk/react@3) streams a turn by MUTATING the same `parts` in place and
handing back a message wrapper that SHARES those mutated parts. So inside the
comparator both `prev.message` and `next.message` already reflect the latest
content — the two signatures are ALWAYS equal — and the memo skipped every
post-mount render. The assistant row therefore froze at its initial empty
(null) render; reasoning-first providers (e.g. z.ai/GLM) start with a
non-visible reasoning part, so the whole answer + tool cards never showed.

Fix: snapshot the signature in the PARENT (MessageList) at render time and pass
it to MessageItem as an immutable `signature` string prop; `arePropsEqual` now
compares that prop. A captured string is immutable, so `prev.signature` holds
the previous render's content and `next.signature` the new content — they differ
as the turn streams in and the row re-renders. Drop the now-incorrect
`prev.message === next.message` fast path (same-ref-but-mutated must still
re-render). MarkdownPart's per-part memo is unaffected (it already keys on the
primitive `text`).

Verified end-to-end against a real OpenAI-compatible provider: the assistant
turn (reasoning + streamed text + tool-call card) now renders live and on
finish. Regression tests added (render + comparator) that fail before / pass
after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:02:53 +03:00
claude_code
276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.

Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
  remove generativeAi from update DTO and workspace service (update block,
  delete line, cloud default now { ai: { chat: true } }); fix repo comment;
  migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
  dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
  usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
  remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
claude_code
406921ac6a fix(share): tighten and restyle custom-address prefix input
The "Custom address" slug field sized its leftSection with a
character-count heuristic (label.length * 7 + 12), which over-estimated
the real width of the small dimmed domain prefix and left an ugly empty
gap between "docs.../l/" and the input text.

- Measure the real prefix width via a ref + useLayoutEffect (scrollWidth)
  and feed it to leftSectionWidth so the slug sits flush against the
  prefix, regardless of host length or font metrics.
- Restyle the prefix as an attached addon: subtle background, a right
  divider border and input-matching left corner radii.
- Minor spacing tidy: description mb 4->6, action buttons mt xs->sm.

No behavior change: validation, availability probe, save/remove and the
reassign modal are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:05:23 +03:00
claude code agent 227
c64d7f315e fix(ai-chat): open chat window before resolving the bound chat (#191)
Address PR #209 review.

- use-open-ai-chat.ts: call setWindowOpen(true) before awaiting
  getBoundChat so the header button feels instant on slow connections;
  the chat switch (setActiveChatId/setDraft/setSelectedRoleId) is applied
  after the round-trip resolves. Also drop the redundant no-op
  setWindowOpen(true) in the already-open branch (bare early return).
- CHANGELOG.md: document the header AI-chat button auto-opening the
  latest chat bound to the current document under [Unreleased]/Added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:02:15 +03:00
claude code agent 227
7a7aa79eab feat(ai-chat): auto-open last chat bound to the document (#191)
On opening the floating AI-chat window from the header on a document page,
auto-open the LAST chat bound to that document. Binding reuses the existing
ai_chats.page_id (no migration): the bound chat is the requesting user's
most-recent non-deleted chat created on that page, so a new chat on the page
becomes the bound one for free. Resolution happens only on a genuine
closed -> open transition; the provenance badge deep-link is untouched.

Server: AiChatRepo.findLatestByPage + POST /ai-chat/bound-chat (BoundChatDto),
both read-only and owner/workspace-scoped.
Client: getBoundChat service + useOpenAiChatForCurrentPage hook wired into the
app-header entry point (fail-soft to a fresh chat; draft/role cleared only on a
real switch).
Tests: repo scoping/ordering, controller wiring, and hook behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:01:38 +03:00
719bccd80d Merge pull request 'feat(ai-chat): load full transcript for model history (drop 50-msg window)' (#202) from feat/ai-chat-full-history into develop
Reviewed-on: #202
2026-06-26 20:55:50 +03:00
83e64bad1a Merge pull request 'feat(ai): generate page title from content (#199)' (#210) from feat/199-ai-generate-title into develop
Reviewed-on: #210
2026-06-26 20:55:35 +03:00
ee78a96803 Merge pull request 'feat(ai-chat): interrupt agent + send queued message, keeping partial output (#198)' (#211) from feat/198-interrupt-agent into develop
Reviewed-on: #211
2026-06-26 20:55:20 +03:00
d971d02346 Merge pull request 'feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)' (#215) from feat/201-temporary-notes into develop
Reviewed-on: #215
2026-06-26 20:54:56 +03:00
claude code agent 227
53cbec9354 fix(db): bump temporary-notes migration timestamp past share-aliases (#201)
develop merged 20260626T130000-share-aliases; rename this PR's migration to
20260626T140000 so the two no longer share a timestamp prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:08 +03:00
claude code agent 227
686c3f9d14 fix(ai-chat): branch sendNow on live status to defuse stale-status race (#198)
Port the only substantive fix #211 was missing relative to #203 (which is
being closed): the "Send now" handler branched on the closure-captured
isStreaming, but a turn can finish between render and click. In that window
stop() is a no-op, so arming flushOnAbortRef/interruptNextSendRef would strand
those one-shot flags and leak into a later, unrelated Stop (auto-sending a
queued message the user never asked to send).

- Mirror the live useChat status in statusRef (updated each render) and branch
  sendNow on it instead of isStreaming, so the not-streaming path runs when the
  turn has already ended and the interrupt flags are never armed against a
  no-op stop().
- Belt-and-suspenders: clear flushOnAbortRef/interruptNextSendRef when a new
  turn starts streaming, defusing the sub-render-tick window where a flag could
  still be armed but the expected abort never fired. No-op for the legit
  interrupt path (both refs are consumed synchronously beforehand).

Keeps #211's existing structure and its flushNext-returns-boolean fix. The
rest of #203's divergence is comment rewording, a server-side rename of the
same pure interrupt-gate, and fewer tests — nothing else to port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:06 +03:00
claude code agent 227
6faf2475e6 fix(ai-chat): address PR #211 review (i18n keys, dead export, flag leak)
- Register the new AI-chat keys "Send now" and "Interrupt and send now" in
  both en-US and ru-RU catalogs so the UI never renders mixed-language
  tooltip/aria-label (i18n policy).
- Make INTERRUPT_NOTE module-private (drop the unused re-export), matching the
  module's private DEFAULT_PROMPT/SAFETY_FRAMEWORK siblings.
- Reset interruptNextSendRef in the flush-on-abort branch when nothing is
  actually sent, so a stuck one-shot interrupt flag cannot tag the next
  unrelated send; flushNext now reports whether it sent.
- Add a CHANGELOG [Unreleased]/Added entry for #198.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:06 +03:00
claude code agent 227
7d64b11045 test: cover future-deadline (re-armed) branch in temporary-note cleanup guard
The deletion guard skips a note when its re-read deadline is still in the
future (user disarmed-then-re-armed in the race window between the batch
SELECT and the per-row re-read). The default stub returns an epoch deadline
(always < now), so the existing race tests never exercised the
`new Date(temporaryExpiresAt) >= now` branch; a regression dropping it or
inverting the comparison would pass unnoticed. Add a test that re-reads a
fresh future deadline and asserts removePage is not called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:51 +03:00
claude code agent 227
983f2fa654 Address PR #215 review: temporary notes hardening
Must-fix:
- CHANGELOG: add [Unreleased]/Added entry for temporary notes (#201).
- temporary-note-cleanup: re-check temporary_expires_at at deletion time so a
  concurrent "Make permanent" (sets it NULL) between the batch SELECT and the
  per-row removePage wins the race and the note is not trashed. Add unit tests
  for the make-permanent and already-trashed race windows.

Non-blocking review items:
- temporary-note-cleanup: cap the sweep batch (LIMIT 500) so a large backlog is
  not loaded into memory; remainder drains on the next hourly run.
- client: extract duplicated post-toggle cache sync into
  syncTemporaryExpiresInCache() shared by the header menu and the banner.
- Remove the tautological migration spec that mocked the whole Kysely builder.
- Tests: cover create() frozen temporaryExpiresAt (workspace override + NULL
  default fallback + non-temporary skips lookup) and restorePage disarming the
  timer (temporaryExpiresAt: null).

Deferred (forward-looking, non-blocking): extract
PageService.computeTemporaryExpiresAt() to dedupe the deadline formula and drop
the @InjectKysely from PageTemplateController; replace migration unit test with
a real Postgres up/down integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:51 +03:00
claude code agent 227
e99c00a9ee test(review): pin full-transcript history past 50 rows + changelog (PR #202)
Address the PR #202 review (approve-with-comments). The only actionable
non-blocking item was the test-coverage suggestion: the source switch in
AiChatService.handle from findRecent(chatId, ws, 50) to findAllByChat(chatId,
ws) was not pinned by a test. handle() is a streaming method the project marks
as not unit-testable, so cover the behavioral guarantee it now relies on at the
repo/integration level — seed a chat of 60 messages and assert the default
findAllByChat (exactly how handle calls it) returns the FULL transcript in
chronological order, including the first turn the old 50-window would have
dropped.

Also document the behavior change under CHANGELOG [Unreleased] -> Changed.

The two stability items (token-budget trim before streamText; O(N) history
rebuild per turn) are deferred: the reviewer flagged both as non-blocking
conscious trade-offs aligned with the PR's stated goal, and the trim is a
larger architecture change out of scope for this follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
claude_code
1f459d8d26 feat(ai-chat): load full transcript for model history (drop 50-msg window)
The per-turn model conversation was rebuilt via findRecent(chatId, ws, 50),
a sliding window that dropped the beginning of any chat longer than ~50 stored
rows. Switch streamChat to the existing findAllByChat, which loads the full
non-deleted transcript chronologically with a 5000-row memory-safety backstop
(keeps the newest rows + logs a warning on overflow) — a safety net, not a
conversational limit. Remove the now-unused findRecent method and update the
comments/log text that referenced it (findAllByChat now feeds both the Markdown
export and the model history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
claude code agent 227
9632146d23 test(editor): cover focused-title guard and destroyed-editor early-out
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
0314416bfa Address PR #210 review: changelog, navigation guard, hook tests (#199)
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
  "generate title from content" byline button (reads live editor
  content, generates via the workspace AI provider, applies through
  /pages/update, gated by settings.ai.generative, throttled per user).

- use-generate-page-title: guard the visible title write against page
  navigation during generation. The mutation awaits the model for 1-3s;
  its closure captures the editors from the starting render, but the
  global page/title atoms re-point on navigation. We now keep a live ref
  to the current editors and skip setContent unless the live page editor
  still belongs to the page the title was generated for
  (editor.storage.pageId === pageId, mirroring TitleEditor's
  activePageId guard). The DB write stays correct (keyed by the captured
  pageId) and the websocket broadcast is unchanged, so only the wrong-page
  field write is suppressed.

- Add a vitest suite for the hook: empty content, empty model response,
  happy path, the navigation guard, and 403/503/429/other onError mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
001ebe2e53 feat(ai): generate page title from content (#199)
Add an AI button in the page byline that generates a note's title from the
live editor content (including unsaved edits) and applies it immediately.

Server: one-shot, non-streaming POST /ai-chat/generate-page-title mirroring
the chat generateTitle path — gated by settings.ai.generative, throttled via
AI_CHAT_THROTTLER, resolves the workspace chat model and returns { title }.
The endpoint never touches the page; the client applies the title through the
existing /pages/update route (which enforces edit permission).

Client: ai-chat-service.generatePageTitle, a useGeneratePageTitle hook that
converts the editor HTML to markdown, calls the endpoint, applies the title
via updateTitle + updatePageData, reflects it in the unfocused title editor,
and broadcasts the UpdateEvent (mirroring TitleEditor.saveTitle). A sparkles
button (GenerateTitleGroup) renders next to dictation, edit-mode + flag gated.

Tests: pure cleanGeneratedTitle helper + controller gate/delegation/error-map.
i18n: en-US + ru-RU strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
eb5b696431 feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)
"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").

Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
  frozen deadline) + partial index for the sweep; workspace column
  temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
  setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
  CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
  notes through the exact PageRepo.removePage path (recursive over children,
  emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
  deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
  workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
  ("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
  and en/ru i18n.

Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:38:42 +03:00
claude code agent 227
422389d84e feat(ai-chat): interrupt agent + send queued message, keeping partial output (#198)
Add a "send now" button to queued AI-chat messages: it interrupts the
running agent and immediately sends that message, while the agent's
partial output at interruption is kept in history and the next turn is
marked as a user interrupt.

Client:
- queue-helpers: pure `promoteToHead` to move a queued message to the head.
- chat-thread: `sendNow` (promote head + abort + flush-on-abort), one-shot
  `flushOnAbortRef`/`interruptNextSendRef`, `interrupted` flag in the
  request body, and the "send now" ActionIcon in the queued list.

Server:
- `interrupted` on AiChatStreamBody; pure `isInterruptResume` confirms the
  client hint against persisted history (prev assistant turn aborted/
  streaming) before honouring it.
- prompt: INTERRUPT_NOTE injected in the context section only on a
  confirmed interrupt-resume turn so the model treats the partial answer
  above as incomplete.

Tests: promoteToHead, chat-thread send-now (abort + resend + one-shot
interrupt flag + non-streaming immediate send), isInterruptResume, and
the prompt interrupt-note injection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:38:23 +03:00
claude_code
fad1aa0501 fix(db): move share-aliases migration spec out of migrations/
The #205 share-aliases feature placed share-aliases.migration.spec.ts
inside src/database/migrations/. Kysely's FileMigrationProvider loads
EVERY file in that folder as a migration, so `migration:latest` imported
the test file and crashed with "ReferenceError: describe is not defined"
(no Jest globals under tsx). That broke the migration step shared by the
e2e-server, e2e-mcp and integration-test (test/test) jobs.

Move the spec one level up to src/database/ (matching the existing
src/database/jsonb-bind.spec.ts convention) so the migration runner no
longer sees it, and fix its relative imports
(./migrations/... and ./types/...). Jest still picks it up via the
src/**/*.spec.ts test glob. Verified locally: 3 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:14:31 +03:00
claude_code
8bb4224a20 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 20:02:51 +03:00
13589b3973 Merge pull request 'feat(share): custom /l/:alias pretty links (share_aliases table) (#205)' (#214) from feat/205-share-aliases into develop
Reviewed-on: #214
2026-06-26 20:00:50 +03:00
claude_code
69fcccd6e8 docs(release): clarify tagging and merge flow
Update the release documentation to emphasize tagging on develop before merging to main, detail steps for pushing tags to both gitea and github, and explain the back‑merge and remote tag considerations.
2026-06-26 19:57:49 +03:00
claude_code
0db48f1706 chore(gitignore): add .claude/tmp/ to ignore list 2026-06-26 19:57:43 +03:00
claude_code
2e72a24d13 test(e2e): silence ts-jest allowJs warnings for editor-ext .js
The e2e transform matches .js (required so ESM-only node_modules like
nanoid/@sindresorhus get transpiled), which also sweeps in editor-ext's
prebuilt CommonJS dist/*.js. ts-jest then warns "Got a .js file to
compile while allowJs is not set to true" for each footnote file. The
.js match cannot be dropped without reintroducing the ESM load errors, so
enable allowJs for ts-jest via an inline tsconfig override (merged with
apps/server/tsconfig.json — decorators/paths/module stay intact).

Verified locally: 0 allowJs warnings, app still compiles and boots to the
Redis connection (no DI/metadata regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:45:37 +03:00
claude_code
aad0a37cfd 0.94.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:33:57 +03:00
claude_code
50d3e7b476 style(editor): align footnote marker and center task checkbox
Remove top margin from the first paragraph in footnote definitions so the
marker aligns with the first line of text. Adjust the task‑list label to use
the editor line‑height variable for its height and center the checkbox
vertically, keeping it in line with the item’s first text line.
2026-06-26 19:24:13 +03:00
claude_code
bd62d906bb test(e2e): anchor top-level mcp comment on existing page text
With the image fix in place, the mcp e2e ran through every section and
failed only at the last one (comments): create_comment was hardened to
require an inline "selection" (exact text to anchor on) for a top-level
comment, but the test created one without a selection ("an inline
'selection' ... is required for a top-level comment").

Pass an inline selection ("Добавленный абзац.", a plain paragraph
re-imported in section 5 and still present at the comments stage). The
reply is unchanged: it carries a parentCommentId, so it is a reply and
needs no selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:16:55 +03:00
claude_code
e4b46ddbfc test(e2e): make server e2e actually boot (ESM chain + Fastify adapter)
The previous jest-config fix let the module graph load further and exposed
two more reasons the server e2e never passed since it was added:

1. ESM transform chain: AppModule pulls in editor-ext -> @tiptap ->
   @sindresorhus/slugify -> @sindresorhus/transliterate / escape-string-regexp,
   plus p-limit -> yocto-queue — all ESM-only. Extend the e2e
   transformIgnorePatterns whitelist to transform them (scoped packages need
   both the pnpm `@scope+name` and nested `@scope/name` path forms, hence
   `@sindresorhus[+/][a-z0-9-]+`). Verified locally: the graph now fully
   transforms and resolves.

2. Wrong HTTP adapter: Docmost runs on Fastify (main.ts uses FastifyAdapter)
   and does not depend on @nestjs/platform-express, but the scaffold test used
   the default createNestApplication() (Express) and died with
   "@nestjs/platform-express package is missing". Switch the test to
   FastifyAdapter + getInstance().ready(), close in afterEach. Verified locally:
   createNestApplication + app.init() now proceed to the live Redis/Postgres
   connection (the infra CI provides via services + migrations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:13:22 +03:00
claude_code
deeec50b5f test(e2e): fix remaining server config and mcp image failures
Follow-up to the first e2e fix: with nanoid/editRes.edits resolved, the
suites failed one layer deeper. Both layers were never green since the
e2e jobs were added (non-blocking in CI), so the failures had stacked up.

server e2e (jest-e2e.json) — align module resolution/transform with the
working unit/integration jest configs so AppModule's full import graph
loads:
- moduleFileExtensions: add "tsx" (React-Email .tsx templates are pulled
  in via the auth controller chain).
- transform: ^.+\.(t|j)s$ -> ^.+\.(t|j)sx?$ so .tsx is transformed.
- moduleNameMapper: add ^src/(.*)$ -> <rootDir>/../src/$1 (code imports
  via the absolute 'src/...' alias). Verified locally: the module graph
  now fully resolves (only env vars, supplied by CI, remain).

mcp e2e (test-e2e.mjs) — insert_image/replace_image accept only http(s)
URLs the server fetches; the test passed local file paths and died with
"Invalid image URL". Serve the PNG bytes over a throwaway 127.0.0.1 HTTP
server (the Docmost server runs on the same CI host) and pass URLs. The
featPng negative test is untouched: replaceImage checks the attachmentId
and throws before fetching, so its local path is never validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:54:42 +03:00
claude_code
7eefdad512 test(e2e): fix failing server and mcp e2e suites
Two unrelated CI failures on the 0.94.0 release PR:

- server e2e: jest-e2e.json lacked transformIgnorePatterns, so the
  ESM-only nanoid@5 package was loaded as CommonJS and crashed with
  "Cannot use import statement outside a module". Add the same
  node_modules whitelist already present in the unit and integration
  jest configs (nanoid|uuid|image-dimensions|marked|happy-dom|lib0).

- mcp e2e: test-e2e.mjs read editRes.edits, but editPageText() returns
  the per-edit results under `applied` (not `edits`), so editRes.edits
  was undefined and .every() threw. Read editRes.applied instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:34:56 +03:00
claude_code
a7f8ee04b3 docs(changelog): 0.94.0 release notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
claude_code
378d8b676b 0.94.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
580f7bd5bb Merge pull request 'Батч: бейдж контекста (#189) + e2e в CI (#187) + inline-тест MCP (#170)' (#197) from batch/issues-189-187-170 into develop
Reviewed-on: #197
2026-06-26 18:09:47 +03:00
claude_code
b538c729c3 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 18:09:00 +03:00
claude code agent 227
0643cd1d82 test(share): exercise 70-char title-slug clamp in alias redirect
The controller's buildPageSlug truncates the page title via
`title?.substring(0, 70)` before slugifying, but no test drove that
branch (the only titled case was 16 chars). Add a resolvable-alias
case with a 119-char title whose 70-char boundary falls mid-word and
assert the 302 target's slug reflects only the first 70 characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:05:21 +03:00
e3b23e0d26 Merge pull request 'fix: bug batch — #161 #190 #207 #159 + #206 findings' (#212) from fix/mainline-bugs-2026-06-26 into develop
Reviewed-on: #212
2026-06-26 17:43:55 +03:00
claude code agent 227
b392219659 fix(ai-mcp): show Failed when the inline Test request itself rejects (#170)
The per-row MCP Test button derived its presentation solely from the test
mutation's data ({ ok, tools } | { ok, error }). When the request itself
rejected (401/403/500/network) there is no payload, so the row silently spun
back to the idle "Test" instead of reporting the failure.

Feed the mutation error into mcpTestButtonView so a reject also renders a red
"Failed", with the tooltip taken from the server message
(error.response.data.message) or a generic i18n fallback. Enable the tooltip
for any non-idle state. Cover the reject branch (with and without a server
message) in the helper unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:37:08 +03:00
claude code agent 227
ba5cd02439 Address PR #197 review: test coverage + dedup + CI log capture
Code-review follow-ups (Approve-with-comments) for batch #197
(context badge #189 / e2e in CI #187 / inline MCP test #170):

- server: extract the duplicated chatContextWindow ::text->positive-int
  coercion (resolve() + getMasked()) into an exported parsePositiveInt
  helper and unit-test its branches (200000/1.9/0/-5/""/abc/undefined),
  closing the untested read-path gap.
- client: merge the two backward scans over messageRows into one pure,
  exported selectContextBadge helper (numerator and denominator still
  taken from the most recent row carrying EACH value) and unit-test the
  different-rows and fresh-zero-doesn't-shadow cases.
- client: extract the MCP "Test" button tristate presentation into a pure
  mcpTestButtonView helper (collapses the two parallel if/else chains) and
  unit-test idle/ok-with-tools/ok-no-tools/failed label+tooltip branches.
- ci: redirect the backgrounded prod server's stdout/stderr to a log file
  in e2e-mcp and cat it on failure, so a start-up crash is diagnosable
  instead of surfacing only as the generic health timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:24:29 +03:00
claude code agent 227
1043fe3b51 test(share): cover alias controllers; address PR #214 review
Add the two blocking test-coverage specs requested in the PR #214 review and
clear the cheap non-blocking items.

Must-fix:
- share-alias-redirect.controller.spec.ts: routing/leak guard for the public
  GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins
  302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/
  unreadable aliases and a null workspace (no name-existence leak); defensive
  percent-decoding treated as unknown; self-hosted findFirst vs subdomain
  findByHostname workspace resolution; 404 when no built client index exists.
- share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/
  ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page
  -> NotFoundException, validateCanEdit, resolveReadableSharePage null ->
  BadRequestException, isSharingAllowed false -> ForbiddenException, set happy
  path delegation, remove() of a dangling alias (pageId null) skipping
  validateCanEdit but still deleting, and for-page validateCanView.

Cheap review items:
- Remove dead Logger import/field from ShareAliasRedirectController.
- Remove dead PagePermissionRepo import/dependency from ShareAliasController.
- Register the new share-alias UI strings in en-US and ru-RU catalogs.
- Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205).
- Drop the tautological boilerplate assertions from the migration spec
  (exports up/down; runtime checks of typed entity literals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:29 +03:00
claude code agent 227
df50f23d58 merge: fold #182 (AI-chat stream throttle + render memoization) into #212 2026-06-26 17:07:54 +03:00
claude code agent 227
eb5c8e6611 refactor(ai-chat): simplify share onFinish token extraction and cover the fallback (#159)
onFinish always receives a totalUsage object, so the `?? {}` guard and
optional chaining were dead. Extract the field-level extraction into a
recordTurnUsage method (totalTokens, else input+output) and unit-test that
recordShareTokens receives the summed value when totalTokens is absent and the
authoritative total when present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
d32ad73158 test(editor-ext): cover the transclusionSource NO_REASSIGN branch in id dedupe (#206)
A colliding transclusionSource id is deliberately NOT reassigned (its id is a
cross-reference key), while a missing id is still filled. Add coverage for both:
two sources sharing an id keep it (red if the NO_REASSIGN guard is removed), and
a source with no id gets a fresh one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
acf2241e23 docs: document SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY and changelog the bug-fix batch (#161 #190 #207 #206 #159)
Document the new per-workspace rolling-day token-budget env var in
.env.example alongside the existing share-assistant cost knobs, and add
[Unreleased] Fixed entries for #161/#190/#207/#206 plus a Security entry
for the #159 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
cb61274187 test(ai-chat): simplify msg factory and lock signature↔render coupling
Address non-blocking review items on the AI-chat stream-perf PR:

- Drop the unused `metadata` param from the `msg` test factory in
  message-item.test.ts; no caller passed it.
- Add a per-part-kind coupling guard to message-signature.test.ts that, for
  each part kind rendered today (text, reasoning, tool-*) plus the metadata
  banners, asserts that mutating a field the MessageItem render body DRAWS
  flips messageSignature — an executable lock for the load-bearing memo
  invariant documented in message-signature.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:57:31 +03:00
claude code agent 227
fdeede003b feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:28:26 +03:00
claude code agent 227
1d610b3a62 fix(ai-chat): add per-workspace rolling-day token budget for anonymous share assistant (#159)
The anonymous public-share assistant only capped the COUNT of requests
(100/hour/workspace), not their cost. One accepted turn runs the agent loop
up to stepCountIs(5), re-sending the whole client-held transcript as input on
every step, while maxOutputTokens caps only the output; the request window is
hourly with no daily ceiling, so a steady stream at the cap sustains ~24x its
count per day. Counting requests therefore does not bound the owner's LLM bill
(red-team finding #5).

Add a second cost contour: a cluster-wide, sliding-window per-workspace TOKEN
budget over a rolling day. It is checked read-only BEFORE a turn streams (429,
no request slot consumed, nothing spent) and the turn's real usage
(totalUsage: input re-sent per step + output, summed across all steps) is
recorded once it finishes via streamText onFinish. Fails closed on the check
(deny when Redis can't prove we're under budget); best-effort on the record.
Env-overridable via SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY (default 1M/day).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:23:48 +03:00
claude code agent 227
6bb9dfdc86 fix(editor-ext): dedupe colliding unique ids on import/normalize (#206)
editor-pm-7: addUniqueIdsToDoc only FILLED missing ids and never deduplicated
existing ones, so a copy/paste or bulk-JSON duplicate that kept its attrs.id
produced two nodes sharing an id. MCP addressed edits (patch_node /
delete_node "before/after id") then hit the wrong node or both.

Walk the configured-type nodes in document order: the first occurrence of an
id keeps it (stable anchor), later duplicates are reassigned a fresh id.
transclusionSource ids are cross-reference keys (references resolve a source by
this id), so they are only filled-when-missing, never reassigned, to avoid
orphaning their references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:09:02 +03:00
claude code agent 227
770ba70541 fix(collab): retry transient store failures so autosave edits aren't lost (#206)
persist-1: onStoreDocument wrapped the page write in a try/catch that only
logged and swallowed the error, then resolved "successfully". hocuspocus
destroys/unloads the in-memory Y.Doc right after the hook resolves (the only
copy of the latest edit), so a transient DB error (deadlock, serialization
failure, dropped connection) silently lost the edit. Worse, the post-store
branch ran on the partially-assigned `page`, broadcasting a phantom
"page.updated" and enqueueing a history snapshot for content never written.

Wrap the write in a small bounded retry (3 attempts) so the save is
re-attempted while we still hold the doc, and clear `page` on failure so the
success-only side effects never report a save that didn't happen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:05:28 +03:00
claude code agent 227
3d47c306fa fix(tree): cycle-guard placeByPosition so out-of-order moves don't drop subtrees (#206)
ui-state-races-1: the server-authoritative move path (placeByPosition, via
applyMoveTreeNode) lacked the isDescendant cycle guard that drag-drop `move`
has. When move events arrive out of order so the destination parent is still
nested inside the moved node's own subtree, remove(source) dropped the whole
subtree (incl. the future parent) and insertByPosition could not re-place it —
the node and all descendants silently vanished with no error/refetch.

Add the isDescendant guard to placeByPosition (returns same ref, like its other
no-op cases) and short-circuit applyMoveTreeNode on the same condition BEFORE
the placed===prev remove-fallback (which would otherwise still drop the
subtree). Leave the tree untouched so a later corrective event / reconnect
reconcile fixes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:00:26 +03:00
claude code agent 227
c919d4f636 fix(page): copy shared attachments for every referencing page on duplicate (#206)
attach-1: when the same attachmentId was referenced by more than one page
in a duplicated subtree, the per-attachmentId map held only a single copy
entry, so the last page processed clobbered the others. The downstream
ownership guard (`attachment.pageId !== oldPageId`) then matched at most one
page and skipped the lone DB row entirely: no blob copied, no new row, every
copy's image 404'd. Key the map to a list of entries and copy one blob/row
per referencing page; drop the now-incorrect ownership guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:57:46 +03:00
claude code agent 227
c4807022f2 fix(page): add cycle/depth guard to recursive tree-traversal CTEs (#207)
getPageBreadCrumbs (ancestor CTE) and forceDelete (descendant CTE) used
withRecursive + unionAll with no CYCLE clause or depth cap. If a parent/child
cycle already exists in the data (e.g. one slipped in via the #7 TOCTOU race),
both queries loop forever — hang / statement timeout. Worse, the move guard
itself runs the ancestor CTE, so a cycle would disable the very guard meant to
prevent it (#207 #8).

Add a depth counter bounded by MAX_PAGE_TREE_DEPTH to both recursive CTEs; the
walk stops at the cap, so a cycle yields a bounded result instead of hanging.
Real page trees are only a few levels deep, so the cap never truncates a
legitimate result. getPageBreadCrumbs selects an explicit column list (not
selectAll) so the internal depth counter never leaks into the breadcrumb shape.

Adds an integration test that seeds an A<->B cycle directly and asserts both
getPageBreadCrumbs and forceDelete return bounded / complete under a short
connection-level statement_timeout instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:48:01 +03:00
claude code agent 227
00ca4ff3d6 fix(page): make movePage cycle-check + update atomic to prevent concurrent A<->B cycles (#207)
The server-side move cycle guard (getPageBreadCrumbs) and the move UPDATE ran
as two separate, unlocked statements. Two concurrent moves ("A under B" and
"B under A") could each read the same pre-write acyclic snapshot, both pass the
guard, then persist A.parentPageId=B AND B.parentPageId=A — a parent/child
cycle (TOCTOU, #207 #7).

Run the cycle check and the UPDATE inside one transaction (executeTx) guarded
by a per-space advisory lock (pg_advisory_xact_lock, held until COMMIT) so all
moves within a space serialize: the second mover blocks until the first commits
and then sees the freshly written parent, so its guard rejects the cycle.
getPageBreadCrumbs gains an optional trx so the check runs on the locked snapshot.

Adds an integration test driving two opposing concurrent movePage calls and
asserting no cycle ever persists and exactly one move is rejected. Updates the
movePage unit-test stubs for the new transactional path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:43:57 +03:00
claude code agent 227
ef7d04d1e7 fix(ai-chat): clearer tool-call validation error for dropped pageId in parallel batches (#190)
In-app AI-chat tools used bare zod schemas, so when the model dropped a
required arg (typically pageId) in a parallel/batch tool call, the AI SDK
forwarded zod's raw "expected string, received undefined" text to the model
— not actionable. Add a centralized modelFriendlyInput(shape) wrapper that
keeps the exact JSON Schema contract (required/description/constraints via
z.toJSONSchema draft-7) but replaces the raw zod text with a message naming
each missing/invalid parameter and reminding the model not to drop ids like
pageId in parallel batches. No value is guessed/backfilled (cf. #159).

Applied to every in-app tool: the sharedTool() builder and all inline
inputSchema in ai-chat-tools.service.ts, plus public-share-chat-tools.service.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:30:07 +03:00
claude code agent 227
5b59a70e3f fix(ai-chat): New chat during first-turn stream resets the chat, not just the role badge (#161)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:22:20 +03:00
claude_code
eafd15f0ef docs(ai-chat): document load-bearing invariant of messageSignature memo
PR #182 review (post-fix pass) surfaced two latent correctness risks in the
new MessageItem memo: the per-message signature tracks only [type, text length,
state, error/output presence] + metadata, so a part kind whose VISIBLE content
can change WITHOUT changing those fields would silently freeze a stale row.
Neither is reachable with the current toolset (tool output is set once;
streaming is append-only with a fixed id), so the correct fix is to harden the
documented invariant rather than hash output content on every delta (getPage
returns full page content — hashing it per-delta would tax the hot path this
PR optimizes).

Add a WARNING in messageSignature naming the two future triggers (a tool that
streams `preliminary` output; a client-side regenerate/edit that mutates a
finalized row in place) and the required action (extend the signature).

No behavior change (comment only). vitest src/features/ai-chat 189/189 pass,
tsc clean for the touched files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:44:49 +03:00
claude_code
fbdb8aa16c docs(backlog): delete obsolete backlog documentation files
Removed six outdated markdown files from the `docs/backlog` and other docs directories that were no longer relevant to the project. This cleans up the repository and reduces clutter.
2026-06-25 22:43:26 +03:00
claude_code
9b61024b95 feat(ai-chat): header badge shows current/max context, max from AI settings (#189)
The floating chat window's header badge flipped meaning — a live per-turn token
counter while streaming, the persisted context size at rest — so it "reset to 1"
on each prompt and conflated two different numbers. Replace it with a stable
"current / max" context badge (e.g. `572 / 200k`). The live "Thinking · N tokens"
inside the chat body stays; only the duplicate live counter is removed from the
header.

Max comes from a new admin setting "Context window (tokens)". The server resolves
it and attaches `maxContextTokens` to the completed assistant turn's metadata
(next to contextTokens), so the badge needs no client-side model resolution and
this survives public shares / per-role models.

Server:
- ai.types: chatContextWindow on AiProviderSettings + PROVIDER_SETTINGS_KEYS +
  ResolvedAiConfig + MaskedAiSettings.
- workspace.repo: chatContextWindow in AI_PROVIDER_SETTINGS_ALLOWED (parity).
- update-ai-settings.dto: @IsInt @Min(0) chatContextWindow.
- ai-settings.service: coerce the ::text-stored value to a positive int in
  resolve()/getMasked().
- ai-chat.service: flushAssistant writes metadata.maxContextTokens (>0); the
  completed turn passes resolved.chatContextWindow.

Client:
- ai-chat.types: maxContextTokens on the message-row metadata.
- ai-chat-window: read maxContextTokens; render "current [/ max]"; drop the
  liveTurnTokens state/branch and the onLiveTurnTokens prop; new tooltip.
- chat-thread: remove the live-turn-token throttle effect and plumbing.
- count-stream-tokens: drop the now-dead liveTurnTokens()/types; keep
  estimateTokens.
- settings: chatContextWindow on IAiSettings(+Update) + a NumberInput in the AI
  provider settings form.

i18n: add the badge/settings keys (en, ru); remove the two now-unused keys.
Tests: flushAssistant maxContextTokens, DTO validation, trim token tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:39:09 +03:00
claude_code
63c26042ba test(review): address PR #182 review — tests + extract messageSignature, CHANGELOG
Resolve the PR #182 code-review (Request changes) on top of the already-merged
develop (the merge commit preserves both the markdown useMemo and the
collapseBlankLines fix in reasoning-block.tsx).

- Extract messageSignature from message-item.tsx into utils/message-signature.ts
  (matches the feature's "pure UIMessage helper + colocated test" convention) and
  export arePropsEqual so the memo seam is unit-testable. No logic change.
- Add utils/message-signature.test.ts covering every change signal (text grows,
  part appended, state flip, output appears, errorText appears, usage.reasoningTokens
  arriving on finish-step, metadata error/finishReason) plus the negative
  content-identical-clone case.
- Add components/message-item.test.ts for arePropsEqual (each prop diff -> false,
  identity fast-path -> true, same-content-different-object -> true, changed -> false).
- Add components/message-item-memo.test.tsx: render-level proof that finalized text
  parts are not re-parsed when only a tail part grows (MarkdownPart memo).
- CHANGELOG: add the user-facing 100% CPU freeze fix under [Unreleased] / Fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:33:14 +03:00
claude_code
2644fe6a83 feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row Test button to the external MCP servers list that shows the
connection result inline (no toasts). Extract the row into AiMcpServerRow so
each row owns its own useTestAiMcpServerMutation instance — independent loading
and result, no cross-row flicker.

States: idle (Test), pending (loading), success (green, "OK · N" with the tool
count), failure (red, "Failed"); a tooltip shows the tool list or the error.
The result resets when url/transport/headers change (the row is keyed by id, so
it does not remount). Backend, service and mutation are unchanged.

- ai-mcp-servers.tsx: AiMcpServerRow + Test button + reset effect + tooltip.
- i18n: add Failed / "OK · {{n}}" (en, ru) and ru Test / tool-list keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
993f884e64 ci(develop): run server + mcp e2e on every develop push without blocking deploy (#187)
Add two independent jobs to develop.yml — e2e-server and e2e-mcp — that run on
each push to develop alongside test/build. `build` stays `needs: test` only, so
a failing e2e never blocks the :develop image build/publish; the red run plus
GitHub's email to the pusher is the notification.

- e2e-server: pgvector + redis services, migrations, apps/server test:e2e.
- e2e-mcp: build editor-ext/server/mcp, migrate, start the prod server
  (REST + /collab in one process), wait for /api/health, seed the admin via
  /api/auth/setup, then run @docmost/mcp test:e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
2f058a6e40 Merge remote-tracking branch 'gitea/develop' into fix/ai-chat-stream-perf
# Conflicts:
#	apps/client/src/features/ai-chat/components/reasoning-block.tsx
2026-06-25 22:21:41 +03:00
claude_code
3ddc329bba Merge pull request 'Батч: ai-chat/footnotes/mcp/db/tree + red-team (#163 #181 #164 #173 #168 #180 + #159 8/10)' (#185) from batch/issues-2026-06-25 into develop 2026-06-25 12:49:15 +03:00
claude_code
99d0cb8773 perf(ai-chat): throttle stream + memoize markdown to stop CPU spikes on long runs
On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with
no user interaction: useChat updated state on every streamed token, and
MessageItem/ReasoningBlock re-parsed the whole transcript's markdown (the marked
pipeline + DOMPurify) on every delta. Per-turn work grew quadratically and
saturated the main thread; the SSE stream drove it, so it hung "on its own".

- chat-thread: pass experimental_throttle (50ms) to useChat so the streamed
  messages state re-renders at most ~20 Hz instead of once per token.
- message-item: memoize MessageItem on a cheap per-message content signature
  (the streaming tail still re-renders as it grows; finalized rows are skipped),
  and render each text part via a memoized MarkdownPart so finalized parts are
  not re-parsed. The signature includes usage.reasoningTokens so the
  authoritative "Thinking - N tokens" count still snaps in at finish-step.
- reasoning-block: memoize the markdown render (useMemo on the text) and wrap the
  component in React.memo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:26:44 +03:00
244 changed files with 20752 additions and 3179 deletions

View File

@@ -132,6 +132,14 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
# baked into the Docker image at build time per branch (see the Dockerfile ARG
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
# admin feature is unavailable. Local-filesystem sources are no longer supported.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000
@@ -187,3 +195,11 @@ MCP_DOCMOST_PASSWORD=
# Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value.
# SHARE_AI_MAX_OUTPUT_TOKENS=512
#
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
# (input re-sent per step + output, summed across every accepted turn). The
# hourly request cap above bounds how MANY calls run, not how expensive each is,
# so this caps the owner's actual provider bill directly. Like the request cap it
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000

View File

@@ -52,7 +52,165 @@ jobs:
platforms: linux/amd64
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: true
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
# `build` stays `needs: test` only, so the :develop image still ships even if
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
# to the pusher — that red run + email is the intended notification, not a
# deploy block.
e2e-server:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
services:
postgres:
image: 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:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Same rationale as e2e-server: this job is intentionally NOT in
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
# a red run plus GitHub's email to the pusher is the notification mechanism.
e2e-mcp:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
NODE_ENV: production
services:
postgres:
image: 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:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Build server
run: pnpm server:build
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Start server (prod)
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
# migration mismatch) is diagnosable; without this the only signal is
# the generic health-loop timeout below, ~120s later.
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
- name: Wait for server health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/api/health > /dev/null; then
echo "Server is healthy"
exit 0
fi
sleep 2
done
echo "Server did not become healthy in time"
exit 1
- name: Dump server log on failure
if: failure()
run: cat /tmp/server.log || true
- name: Seed admin
run: |
curl -fsS -X POST http://localhost:3000/api/auth/setup \
-H "Content-Type: application/json" \
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
- name: Run mcp e2e
env:
DOCMOST_API_URL: http://localhost:3000/api
DOCMOST_EMAIL: e2e@example.com
DOCMOST_PASSWORD: E2ePassword123
run: pnpm --filter @docmost/mcp test:e2e

View File

@@ -17,6 +17,7 @@ permissions:
env:
VERSION: ${{ inputs.version || github.ref_name }}
IMAGE: ghcr.io/vvzvlad/gitmost
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
jobs:
# Run the reusable test suite first so a failing test blocks the image build.
@@ -57,6 +58,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
@@ -85,6 +87,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
push: false
tags: |
${{ env.IMAGE }}:latest

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ lerna-debug.log*
.nx/installation
.nx/cache
.claude/worktrees/
.claude/tmp/
# TypeScript incremental build artifacts
*.tsbuildinfo

View File

@@ -254,7 +254,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
### The two AI subsystems (the main fork additions)
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (39 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
@@ -283,37 +283,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
### Cutting a release
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
1. Make sure `main` is clean and pushed (`git status`, `git push`).
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
Steps:
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
```bash
git push gitea develop && git push gitea vX.Y.Z
git push github develop && git push github vX.Y.Z
```
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
```bash
git checkout main
git merge --ff-only develop # or a merge commit if fast-forward is not possible
git push gitea main && git push github main
```
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
#### Why develop keeps showing the *previous* version (and why step 7 matters)
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
Fix / checklist when develop still shows the old version after a back-merge:
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
## Planning docs

View File

@@ -12,6 +12,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
resolving the target space the same way the regular button does — created
directly when you can write to a single space, or via a space picker when
several. Each space overview screen gains two buttons — "New note" and "New
temporary note" — that create the page directly in that space and open it,
mirroring the existing space-sidebar actions and shown only to members who can
manage pages.
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
follow-up turn is tagged as an interrupt so the model is told its previous
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
catalog of agent roles, grouped into bundles and offered in several languages,
and import the ones they want into the workspace (with skip-or-rename handling
for name collisions); the same role in a different language imports as a
separate install. An imported role remembers its catalog origin and offers a
one-click update when the catalog ships a newer revision. Backed by four new
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
`/catalog/bundle` (read one bundle's roles), `/import`, and
`/update-from-catalog` — and a new `source` column linking a role to its
catalog slug/language/version. The catalog source is configured via the
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
catalog's raw files; the image ships a per-branch default baked in CI, and it
can be overridden at runtime via the env var (see `.env.example`). (#222)
- **Author footnotes inline from an agent, and deterministic server-side footnote
canonicalization on every non-editor write path.** A new MCP `insert_footnote`
tool places a footnote at a body anchor by content only — the agent supplies
WHERE (anchor text) and WHAT (markdown); the number and the bottom
`footnotesList` are derived server-side, so an agent can never assign a number,
edit the list, or desync, and a same-content note reuses one definition. Under
the hood, the editor's footnote-integrity invariant (one trailing list,
numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now
enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths
that bypass the editor's plugins: server markdown/HTML import, `PageService`
create and full-document (`replace`) updates, the client markdown paste, and the
MCP markdown page-import / `update_page` (markdown) / `update_page_json` /
`docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is
idempotent (a no-op once canonical) and is deliberately NOT applied to
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
contain a standalone footnote definition, which canonicalization would drop.
(#228)
### Fixed
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
page's vanity slug previously inserted a second `share_aliases` row instead of
renaming the existing one, leaving the old `/l/<old>` link live forever and
making the share modal's lookup nondeterministic. Slug edits and confirmed
reassigns now rename/retarget the single row, and a new partial unique index on
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
orphaned duplicate alias rows the old bug created (keeping the newest per
page), so any previously-live duplicate `/l/<old>` link begins returning the
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
#227)
- **Typing a custom address already used by another page no longer looks like a
dead end.** The share modal previously flagged such a name with a red "This
address is already in use" error, hiding the fact that saving offers to MOVE
the address to the current page. The field now shows an informational hint —
"This address is in use. Saving will move it to this page." — and keeps Save
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED`
"Move custom address?") is discoverable instead of reading as terminal. (#227)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
the database step by step and exported server-side, the desktop app no longer
freezes at 100% CPU on long agent runs, and MCP writes are badged with
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
per-reference back-links), hardens page moves and duplication against cycles
and lost edits, and caps the anonymous public-share assistant with a
per-workspace rolling-day token budget.
### Added
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
any publicly shared page a short, memorable, workspace-scoped vanity address
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
(never `301`, since the target is retargetable) to the canonical
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
serves the plain SPA index so that the existence of a name never leaks. An
alias can be moved to another page (with a confirm-reassign guard) and the
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
be marked temporary so it auto-moves to Trash once a configurable workspace
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
permanent first. The deadline is frozen at creation time, so later changes to
the workspace setting never reschedule existing notes; an hourly background
sweep trashes notes past their deadline (children ride along). An open
temporary note shows a banner with a "Make permanent" rescue action; restoring
a note from Trash disarms the timer so it is not immediately re-trashed.
Operators configure the lifetime per workspace. (#201)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -52,9 +152,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
- **Generate a page title from its content.** A "sparkles" button in the page
byline reads the live editor content (including unsaved edits), generates a
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
applies it through the existing `/pages/update` route — reflecting it in the
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
flag and throttled per user. (#199)
- **AI chat: header button auto-opens the chat bound to the current document.**
Clicking the AI-chat button in the header while viewing a page now reopens the
latest chat tied to that document instead of whatever chat was last active,
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
chat you created on the page wins; with no bound chat — or off a page, or if
the lookup fails — it falls soft to a fresh chat and keeps the current
selection otherwise. (#191)
### Changed
- **AI chat now feeds the model the full stored transcript.** The per-turn model
conversation was rebuilt from a sliding window of the 50 most recent stored
rows, which silently dropped the beginning of any longer chat. It is now
rebuilt from the complete non-deleted transcript in chronological order, so
the model sees every turn (a 5000-row backstop guards process memory — a
safety net far above any realistic chat, not a conversational limit). On a
very long chat this can eventually reach the model's context window; the
client already surfaces that as "start a new chat". (#202)
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
For the `openai` driver the chat provider defaults to the openai-compatible
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
@@ -78,6 +200,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
per-turn work grew quadratically and saturated the main thread. The stream is now
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
markdown part / reasoning block is memoized, so a long turn no longer re-parses
already-finished content. (#182)
- **Editor: caret/selection landed on the wrong line when clicking inside code
blocks and footnotes.** The affected NodeViews rendered their non-editable
chrome (language menu, footnotes heading, footnote number marker) before the
@@ -92,6 +221,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
no longer froze on the previous step's authoritative usage; the current step's
estimate is combined per-component with `max`, so the count rises smoothly and
never jumps backwards. (#163)
- **AI chat: "New chat" during a streaming first turn now resets the whole
chat, not just the role badge.** Starting a new chat mid-stream cleared the
header but left the in-flight turn's messages behind, so the fresh chat opened
pre-populated with the previous conversation; it now fully resets. (#161)
- **AI chat: a dropped tool argument now yields an actionable error.** When the
model omitted a required parameter (typically `pageId`) in a parallel/batch
tool call, the assistant forwarded zod's raw "expected string, received
undefined" text; tool inputs now return a message naming each missing/invalid
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
(#190)
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
under one of its own descendants is rejected in the same transaction as the
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
so a pre-existing cycle can no longer spin a query. (#207)
- **Page/editor robustness batch.** Duplicating a page now copies shared
attachments for every referencing page (not just the first); colliding block
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
wrong node; transient collab store failures are retried so autosave edits
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
(#206)
### Security
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
share assistant now caps a workspace's actual token spend (input + output,
summed across every accepted turn) over a trailing day, on top of the hourly
request cap — so a caller who evades the per-IP throttle still cannot run up
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
## [0.93.0] - 2026-06-21

View File

@@ -23,6 +23,11 @@ RUN apt-get update \
WORKDIR /app
# Agent-roles catalog base URL: per-branch default set at build time (CI);
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
ARG AI_AGENT_ROLES_CATALOG_URL=""
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
# Copy apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist

View File

@@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
| --- | --- |
| **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. |
| **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. |
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
| **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. |
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
@@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
### Embedded MCP server
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
agent-native tools**: surgical per-block edits (patch / insert / delete by id),
structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff,
structured table editing, version history with diff / restore, comments, images and share
@@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license.
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
| --- | :---: | :---: |
| **Enterprise license** | Not required | Required |
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
| **Structured table editing, version diff / restore** | ✅ | — |
| **Comments, images, share links** | ✅ | — |
@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress
@@ -114,7 +115,7 @@ community feature, with no enterprise license. Open it from the page header; the
- 🔭 **Viewer comments** — let read-only viewers leave comments.
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Offline mode** — offline sync & PWA support.
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.

View File

@@ -33,7 +33,7 @@
| --- | --- |
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
@@ -44,7 +44,7 @@
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
| --- | :---: | :---: |
| **Enterprise-лицензия** | Не нужна | Нужна |
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе
@@ -115,7 +116,7 @@ real-time-коллаборации Docmost, поэтому запись нико
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.

View File

@@ -0,0 +1,193 @@
# Agent roles catalog
This directory is **data, not application code**. It holds the content of an
"agent roles catalog": reusable agent role definitions (system prompts plus a
little metadata), grouped into bundles and translated into one or more
languages. A separate server reads these files and serves them; nothing here is
executable application logic except the validation script.
## File layout
```
agent-roles-catalog/
index.json # the catalog manifest: bundles, languages, role versions
bundles/
<bundle-id>/
<lang>.json # one file per declared language (e.g. ru.json, en.json)
scripts/
check.mjs # validates the catalog (no dependencies)
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-editor,
fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
## How it's served
The server does not bundle this data; it reads it at request time from a single
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
to the catalog's raw files. The server fetches `<base>/index.json` for the
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
file (REMOTE only).
That base URL is provided as a per-branch default in the Docker image (set in
CI: a `develop` build points at the `develop` raw URL, a release build at the
`main` raw URL) and can be overridden at runtime via the
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
supported; if the value is unset the catalog is unavailable.
The fetched JSON is re-validated server-side (the catalog is treated as
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
rollout.
## `index.json` schema
```jsonc
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial", // unique bundle id; matches bundles/<id>/
"name": { "ru": "...", "en": "..." }, // localized display name
"description": { "ru": "...", "en": "..." },
"languages": ["ru", "en"], // which <lang>.json files must exist
"roles": [
{ "slug": "structural-editor", "version": 1 }
// ...
]
}
]
}
```
`version` lives **here, in index.json**, per role. Bump it whenever a role's
content (instructions, name, description, etc.) changes, so consumers can detect
updates.
## Bundle (`<lang>.json`) schema
```jsonc
{
"schemaVersion": 1,
"language": "ru",
"roles": [
{
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
"emoji": "🧱",
"name": "...", // REQUIRED, localized
"description": "...", // localized
"instructions": "...", // REQUIRED, the system prompt, localized
"autoStart": true, // whether the role starts working immediately
"launchMessage": "..." // first message sent on launch (or null)
}
]
}
```
Notes:
- `modelConfig` is intentionally absent; the server treats an absent
`modelConfig` as `null`.
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
files of the same bundle. Only `name`, `description`, `instructions`, and
`launchMessage` are translated.
## Slug uniqueness
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
bundle. A slug appears once per language file of its bundle (same slug in
`ru.json` and `en.json`), but no two different bundles may share a slug.
`scripts/check.mjs` enforces this.
## How to add things
### Add a role to an existing bundle
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
`slug` and `version: 1`.
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
bundle, translating `name`, `description`, `instructions`, and
`launchMessage`.
3. Run the check (see below).
### Add a bundle
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
`languages`, `roles`).
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
object per `roles[]` entry.
3. Run the check.
### Add a language to a bundle
1. Add the language code to that bundle's `languages[]` in `index.json`.
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
translated.
3. Run the check.
### Change a role's content
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
now **fails if a role's content changed but its `version` was not bumped**, so
this step is mandatory — the lock can only be refreshed after the bump.
## Validating
From this directory:
```sh
node scripts/check.mjs # or: npm run check
```
It fails (exit code 1) if any slug is duplicated across the catalog, if a
bundle's index `roles[]` don't match the slugs present in each language file, if
a declared language file is missing, or if any role is missing a required field
(`slug`, `name`, `instructions`). It prints `OK` on success.
### Content-hash guard
`check.mjs` also guards against changing a role's content without bumping its
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
`launchMessage`) across all of its language files, in a deterministic canonical
form. This lockfile is a **check artifact only** — the server fetches only
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
effect on the served catalog or its schema.
On a normal run, for every role the check recomputes the hash and compares it
against the lock:
- content unchanged and versions agree → OK;
- content changed but `version` not bumped above the lock → **error** asking you
to bump and refresh;
- content changed and `version` bumped → **error** asking you to record it by
refreshing the lock;
- role missing from the lock, or a lock entry for a role that no longer exists →
**error** asking you to refresh.
Refresh the lock with:
```sh
node scripts/check.mjs --update-hashes # alias: --fix
```
This recomputes the lock from the current catalog, prunes entries for removed
roles, and prints what changed — but it **refuses to write** (exit 1) if any
role's content changed while its `index.json` version was not bumped, so the
version bump is always enforced first. The check also requires every
`index.json` role to carry a finite numeric `version` (the server requires the
same).
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
role and run `--update-hashes`, then re-add it with changed content at the same
version) is **not** caught, because a brand-new slug has no lock baseline to
enforce a bump against.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 2 },
{ "slug": "line-editor", "version": 2 },
{ "slug": "fact-checker", "version": 2 },
{ "slug": "proofreader", "version": 3 },
{ "slug": "narrator", "version": 1 }
]
},
{
"id": "research",
"name": { "ru": "Исследование", "en": "Research" },
"description": {
"ru": "Глубокое исследование темы с подготовкой отчёта.",
"en": "Deep research on a topic with a prepared report."
},
"languages": ["ru", "en"],
"roles": [ { "slug": "researcher", "version": 1 } ]
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "agent-roles-catalog",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check.mjs"
}
}

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env node
// Validates the agent roles catalog.
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
// between a bundle's index roles[] and the slugs present in each language
// file, a missing declared language file, or a role missing required fields.
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
// the current catalog instead of just validating against it.
const updateHashes =
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
// the server never fetches it, so it has zero impact on the served schema.
const lockPath = join(__dirname, "content-hashes.json");
const errors = [];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
errors.push(`Cannot read/parse ${path}: ${err.message}`);
return null;
}
}
const indexPath = join(catalogDir, "index.json");
if (!existsSync(indexPath)) {
console.error(`Missing index.json at ${indexPath}`);
process.exit(1);
}
const index = readJson(indexPath);
if (!index) {
for (const e of errors) console.error(e);
process.exit(1);
}
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
if (bundles.length === 0) {
errors.push("index.json has no bundles[]");
}
// Track every slug seen across the whole catalog to detect duplicates.
const slugSeen = new Map(); // slug -> "bundleId/lang"
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) {
errors.push("A bundle in index.json is missing an id");
continue;
}
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
// Duplicate slugs inside the bundle index roles[].
const indexSlugSet = new Set(indexSlugs);
if (indexSlugSet.size !== indexSlugs.length) {
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
}
// Each index role must carry a finite numeric "version". The server requires
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
// below relies on it for the bump comparison, so enforce it here too.
for (const r of bundle.roles || []) {
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
errors.push(
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
);
}
}
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
if (languages.length === 0) {
errors.push(`Bundle "${bundleId}" declares no languages`);
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) {
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
continue;
}
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
const fileSlugs = roles.map((r) => r && r.slug);
// (d) Required fields per role.
for (const role of roles) {
for (const field of ["slug", "name", "instructions"]) {
if (role == null || role[field] == null || role[field] === "") {
errors.push(
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
);
}
}
}
// (b) index roles[] must match the slugs present in each language file.
const fileSlugSet = new Set(fileSlugs);
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
if (missingInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
);
}
if (extraInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
);
}
// (a) Duplicate slugs across the whole catalog.
for (const slug of fileSlugs) {
if (!slug) continue;
const where = `${bundleId}/${lang}`;
// Only flag duplicates across DIFFERENT bundles or files; the same slug
// is expected to appear once per language file of the same bundle.
if (slugSeen.has(slug)) {
const prev = slugSeen.get(slug);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
);
}
} else {
slugSeen.set(slug, where);
}
}
}
}
// ---------------------------------------------------------------------------
// Content-hash guard: detect "content changed without a version bump".
//
// check.mjs cannot use git history, so we maintain a lockfile
// (scripts/content-hashes.json) mapping each role slug to its recorded
// { version, hash }. On every run we recompute each role's content hash and
// compare it against the lock; a content change is only allowed once the role's
// version in index.json has been bumped and the lock refreshed.
//
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
// the role and run --update-hashes, then re-add it with changed content at the
// same version) is NOT caught, because a brand-new slug has no lock baseline to
// enforce a bump against. We document this rather than building tombstones.
// ---------------------------------------------------------------------------
// Content fields hashed for each role, in a fixed canonical order. `slug` is
// identity (not content) and `version` lives in index.json, so neither is here.
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
// EXCLUDED: no shipped role uses it today, and being an object it would need a
// deterministic deep canonicalization (recursive key sort) before hashing —
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
// change to it is still caught by the bump guard.
const CONTENT_FIELDS = [
"emoji",
"autoStart",
"name",
"description",
"instructions",
"launchMessage",
];
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
// current catalog so we can compute hashes and read index versions.
function collectCatalogRoles() {
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) continue;
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
for (const r of bundle.roles || []) {
if (!r || !r.slug) continue;
if (!out.has(r.slug)) {
out.set(r.slug, { version: r.version, langRoles: new Map() });
} else {
// Same slug declared twice in index.json roles[]; already flagged above.
out.get(r.slug).version = r.version;
}
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) continue;
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
for (const role of roles) {
if (!role || !role.slug) continue;
const entry = out.get(role.slug);
if (!entry) continue; // role not declared in index.json; flagged above.
entry.langRoles.set(lang, role);
}
}
}
return out;
}
// Deterministic content hash for a role: languages sorted ascending, each
// language's content fields taken in CONTENT_FIELDS order (null when absent).
function contentHash(langRoles) {
const langs = [...langRoles.keys()].sort();
const canonical = langs.map((lang) => {
const role = langRoles.get(lang);
const fields = {};
for (const field of CONTENT_FIELDS) {
fields[field] = role && role[field] != null ? role[field] : null;
}
return [lang, fields];
});
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
// Compute current { version, hash } for every catalog role.
const catalogRoles = collectCatalogRoles();
const current = new Map(); // slug -> { version, hash }
for (const [slug, entry] of catalogRoles) {
current.set(slug, {
version: entry.version,
hash: contentHash(entry.langRoles),
});
}
// Load the existing lock (may be absent on first run).
let lock = {};
if (existsSync(lockPath)) {
const parsed = readJson(lockPath);
if (parsed && typeof parsed === "object") lock = parsed;
}
if (updateHashes) {
// Refresh the lock from the current catalog, but refuse to write if any role's
// content changed without its version being bumped above the existing lock.
const blockers = [];
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) continue; // new role; nothing to enforce a bump against.
if (cur.hash === prev.hash) continue; // content unchanged.
// Defense-in-depth: a non-numeric version must never pass the bump check via
// `undefined <= N` (which is false). The standard checks already flag a
// missing numeric version, but guard here too before comparing.
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
blockers.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
);
} else if (cur.version <= prev.version) {
blockers.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
);
}
}
// Still honor the standard checks before allowing a write.
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
if (blockers.length > 0) {
console.error("Refusing to update content-hash lock:");
for (const b of blockers) console.error(` - ${b}`);
process.exit(1);
}
// Compute the change summary relative to the old lock, pruning removed slugs.
const newLock = {};
const added = [];
const changed = [];
const removed = [];
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
newLock[slug] = { version: cur.version, hash: cur.hash };
const prev = lock[slug];
if (!prev) added.push(slug);
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
}
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) removed.push(slug);
}
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
console.log(`Wrote ${lockPath}`);
if (added.length) console.log(` added: ${added.join(", ")}`);
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
if (!added.length && !changed.length && !removed.length) {
console.log(" (no changes; lock already up to date)");
}
console.log("OK");
process.exit(0);
}
// Normal run: validate current content against the lock.
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) {
errors.push(
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
);
continue;
}
if (cur.hash === prev.hash) {
// Content unchanged; the lock version must still agree with index.json.
if (cur.version !== prev.version) {
errors.push(
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
);
}
continue;
}
// Content changed.
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
// comparison, so a missing version can never silently pass the bump check
// (and we avoid a misleading "version bumped to undefined" message).
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
errors.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
);
} else if (cur.version <= prev.version) {
errors.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
);
} else {
errors.push(
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
);
}
}
// Lock entries for slugs that no longer exist in the catalog.
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) {
errors.push(
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
);
}
}
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("OK");

View File

@@ -0,0 +1,26 @@
{
"fact-checker": {
"version": 2,
"hash": "d7ad1dae07d6f4321e7d40c5b36259dbf930264d748834809c4fb77294bf72e3"
},
"line-editor": {
"version": 2,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
},
"narrator": {
"version": 1,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
},
"proofreader": {
"version": 3,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 2,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.93.0",
"version": "0.94.1",
"scripts": {
"dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -598,6 +598,17 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Make temporary": "Make temporary",
"Make permanent": "Make permanent",
"New temporary note": "New temporary note",
"Temporary note": "Temporary note",
"Temporary notes": "Temporary notes",
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
"Note is now permanent": "Note is now permanent",
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
@@ -676,9 +687,6 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
@@ -715,6 +723,8 @@
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Failed": "Failed",
"OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Clear": "Clear",
@@ -1167,8 +1177,9 @@
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
"Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"Context size / model limit": "Context size / model limit",
"Context window (tokens)": "Context window (tokens)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
@@ -1177,6 +1188,8 @@
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Send now": "Send now",
"Interrupt and send now": "Interrupt and send now",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
@@ -1315,5 +1328,41 @@
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)"
"OpenAI (official)": "OpenAI (official)",
"Custom address": "Custom address",
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address",
"Generate title with AI": "Generate title with AI",
"Title generated": "Title generated",
"Failed to generate title": "Failed to generate title",
"The note is empty": "The note is empty",
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later",
"Import from catalog": "Import from catalog",
"Browse the catalog": "Browse the catalog",
"Role catalog": "Role catalog",
"On name conflict": "On name conflict",
"Skip": "Skip",
"Import": "Import",
"Installed": "Installed",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog"
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -607,6 +607,17 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
"Move to trash": "Переместить в корзину",
"Make temporary": "Сделать временной",
"Make permanent": "Сделать постоянной",
"New temporary note": "Новая временная заметка",
"Temporary note": "Временная заметка",
"Temporary notes": "Временные заметки",
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
"Note is now permanent": "Заметка теперь постоянная",
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Permanently delete": "Удалить навсегда",
@@ -704,19 +715,27 @@
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"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": "Успешно удалено",
"Edited by AI agent on behalf of {{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-агент не смог ответить. Попробуйте ещё раз.",
@@ -730,9 +749,6 @@
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
@@ -1169,5 +1185,42 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
"OpenAI (official)": "OpenAI (официальный)",
"Custom address": "Пользовательский адрес",
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
"Generate title with AI": "Сгенерировать название через AI",
"Title generated": "Название сгенерировано",
"Failed to generate title": "Не удалось сгенерировать название",
"The note is empty": "Заметка пустая",
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
"Import from catalog": "Импорт из каталога",
"Browse the catalog": "Открыть каталог",
"Role catalog": "Каталог ролей",
"On name conflict": "При конфликте имён",
"Skip": "Пропустить",
"Import": "Импортировать",
"Installed": "Установлено",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге"
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import { useAtom, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
@@ -38,7 +38,9 @@ export function AppHeader() {
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [workspace] = useAtom(workspaceAtom);
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
// Opening from the header auto-opens the document's bound chat (last chat
// created on the current page); off a page it keeps the current selection.
const openAiChat = useOpenAiChatForCurrentPage();
// AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
@@ -105,7 +107,7 @@ export function AppHeader() {
color="dark"
size="sm"
aria-label={t("AI chat")}
onClick={() => setAiChatWindowOpen((v) => !v)}
onClick={openAiChat}
>
<IconMessage size={20} />
</ActionIcon>

View File

@@ -45,6 +45,7 @@ import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -161,12 +162,6 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -193,6 +188,7 @@ export default function AiChatWindow() {
const {
threadKey,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
@@ -215,12 +211,25 @@ export default function AiChatWindow() {
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
cancelPendingAdoption();
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
// while a brand-new chat's first turn is still streaming leaves activeChatId
// null (the real id is adopted only at turn end), so setActiveChatId(null)
// alone is a no-op and the reconciler never remounts — the chat/stream/history
// would persist and only the role badge would drop. This always remounts the
// thread into a clean new chat.
startFreshThread();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
}, [
cancelPendingAdoption,
startFreshThread,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -287,24 +296,19 @@ export default function AiChatWindow() {
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
// PERSISTED rows (updates on chat open/switch); it does not tick live
// mid-stream — acceptable for v1.
const contextTokens = useMemo(() => {
if (!activeChatId || !messageRows) return 0;
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
return meta.contextTokens;
}
const usage = meta.usage;
if (usage) {
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) return fallback;
}
}
return 0;
}, [activeChatId, messageRows]);
//
// The denominator `maxContextTokens` (the model's configured max window) is
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
// on a completed turn, but the numerator and denominator are taken from the
// most recent row carrying EACH value independently — they may land on
// different rows (e.g. a fresh error row can carry contextTokens but not
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
// no row has it (older rows, or no admin-configured limit) — the badge then
// shows just the current size with no denominator.
const { contextTokens, maxContextTokens } = useMemo(
() => selectContextBadge(activeChatId ? messageRows : undefined),
[activeChatId, messageRows],
);
// On (re)open, settle the geometry before paint (useLayoutEffect → no
// first-frame jump): compute an initial top-right placement the first time,
@@ -495,20 +499,17 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>
{formatTokens(liveTurnTokens)}
</span>
</Tooltip>
) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow>
{/* Always show the persisted "current / max" context. The denominator
(the admin-configured model limit) is appended only when known;
not clamped when current > max (shown as-is, e.g. "210k / 200k").
Hidden entirely until a turn has recorded a context figure. */}
{contextTokens > 0 ? (
<Tooltip label={t("Context size / model limit")} withArrow>
<span className={classes.badge}>
{formatTokens(contextTokens)}
{maxContextTokens > 0
? ` / ${formatTokens(maxContextTokens)}`
: ""}
</span>
</Tooltip>
) : null}
@@ -622,6 +623,7 @@ export default function AiChatWindow() {
) : (
<ChatThread
key={threadKey}
threadKey={threadKey}
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
@@ -634,7 +636,6 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
// above the imports) can expose the captured useChat callbacks / transport and
// the spies back to the test body.
const h = vi.hoisted(() => ({
state: {
status: "streaming" as string,
onFinish: null as null | ((arg: Record<string, unknown>) => void),
sendMessage: vi.fn(),
stop: vi.fn(),
transport: null as null | {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
},
},
}));
// Mock useChat: capture onFinish, return the spies and the controllable status.
vi.mock("@ai-sdk/react", () => ({
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
h.state.onFinish = opts.onFinish ?? null;
return {
messages: [],
sendMessage: h.state.sendMessage,
status: h.state.status,
stop: h.state.stop,
error: null,
};
},
}));
// Mock "ai": deterministic ids + a transport that records its options so the test
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
vi.mock("ai", () => {
let counter = 0;
return {
generateId: () => `gid-${counter++}`,
DefaultChatTransport: class {
constructor(opts: {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
}) {
h.state.transport = opts;
}
},
};
});
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
// composer). The ChatInput stub exposes a button that queues a message, the only
// interaction this test needs to populate the queue while "streaming".
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
default: () => <div data-testid="message-list" />,
}));
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
queue
</button>
),
}));
import ChatThread from "./chat-thread";
function renderThread() {
const onTurnFinished = vi.fn();
render(
<MantineProvider>
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
</MantineProvider>,
);
return { onTurnFinished };
}
describe("ChatThread — send now (#198)", () => {
beforeEach(() => {
h.state.status = "streaming";
h.state.onFinish = null;
h.state.sendMessage.mockClear();
h.state.stop.mockClear();
h.state.transport = null;
});
it("aborts the current turn and resends the queued message on the abort", () => {
renderThread();
// Queue a message while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
const sendNowBtn = screen.getByLabelText("Send now");
expect(sendNowBtn).toBeTruthy();
// "Send now" interrupts the current turn (stop), but does NOT send yet —
// the resend happens once the abort lands in onFinish.
fireEvent.click(sendNowBtn);
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(h.state.sendMessage).not.toHaveBeenCalled();
// The abort we triggered reaches onFinish: the promoted head is flushed.
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: true,
isDisconnect: false,
isError: false,
});
});
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
});
it("tags exactly the next send as interrupted (one-shot flag)", () => {
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const prep = h.state.transport!.prepareSendMessagesRequest;
// The send right after "send now" carries interrupted: true...
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
// ...and only that one (the flag is read-and-cleared).
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
it("sends immediately without an interrupt when not streaming", () => {
h.state.status = "ready";
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// No turn to interrupt: sent straight away, no abort, not flagged.
expect(h.state.stop).not.toHaveBeenCalled();
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
const prep = h.state.transport!.prepareSendMessagesRequest;
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
});

View File

@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import {
IconClockHour4,
IconPlayerPlayFilled,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -20,15 +24,23 @@ import {
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import {
dequeue,
enqueueMessage,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
// Throttle how often the streamed `messages` state triggers a re-render. Without
// it, useChat updates state on EVERY token, so the whole transcript's markdown
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
// into a quadratic CPU storm that pins the main thread and freezes the UI.
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
// from the token rate.
const STREAM_THROTTLE_MS = 50;
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -38,6 +50,11 @@ export interface OpenPageContext {
interface ChatThreadProps {
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
chatId: string | null;
/** This thread's mount key (the same value the parent uses as React `key`).
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
threadKey?: string;
/** Persisted rows to seed initial messages (existing chats only). */
initialRows?: IAiChatMessageRow[];
/** The page currently open in the workspace, or null on a non-page route.
@@ -59,20 +76,16 @@ interface ChatThreadProps {
/** Called when a turn finishes; the parent refreshes the chat list and, for a
* new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
* finishing on a thread already abandoned by New chat mid-stream (#161). */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
}
/**
@@ -109,6 +122,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
*/
export default function ChatThread({
chatId,
threadKey,
initialRows,
openPage,
roleId,
@@ -117,7 +131,6 @@ export default function ChatThread({
assistantName,
onTurnFinished,
onServerChatId,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -193,12 +206,25 @@ export default function ChatThread({
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// "Send now" single-flight flags. Kept in refs (not state) so they are read
// inside the stable `onFinish` callback and the transport closure WITHOUT a
// re-render or a stale closure. Both are one-shot (read-and-clear).
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
// though an aborted turn normally keeps the queue intact.
// - interruptNextSendRef: tag the next send as a user interrupt so the server
// injects the "your previous answer was interrupted" note for that turn only.
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
// Returns whether a message was actually sent, so callers can tell an empty
// dequeue (nothing to flush) from a real send.
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return;
if (!head) return false;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
return true;
}, [setQueue]);
const enqueue = useCallback(
@@ -224,17 +250,26 @@ export default function ChatThread({
// when null) and tell the agent which page "this page" refers to. Both
// are read live from refs so changing chats/pages does NOT recreate the
// transport. `openPage` is null on a non-page route.
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
messages,
},
}),
prepareSendMessagesRequest: ({ messages, body }) => {
// Read-and-clear the interrupt flag so the "you were interrupted" note
// is carried by ONLY this request (the one resending the promoted
// message right after we aborted the previous turn). The server still
// confirms it against history before acting on it.
const interrupted = interruptNextSendRef.current;
interruptNextSendRef.current = false; // one-shot
return {
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
interrupted,
messages,
},
};
},
}),
[],
);
@@ -246,6 +281,8 @@ export default function ChatThread({
id: chatStoreId,
messages: initialMessages,
transport,
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
experimental_throttle: STREAM_THROTTLE_MS,
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
@@ -257,14 +294,31 @@ export default function ChatThread({
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design.
onTurnFinished(extractServerChatId(message));
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
// session ignore this finish if it belongs to a thread abandoned by New chat
// mid-stream (#161).
onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
// "Send now": WE triggered this abort to interrupt the current turn and
// immediately send the promoted head. Flush it even though the turn was
// aborted (the normal abort path below keeps the queue intact). The
// interrupt note travels with this send via interruptNextSendRef.
if (flushOnAbortRef.current) {
flushOnAbortRef.current = false;
// Suppress the "Response stopped." flash for an intentional interrupt.
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();
},
@@ -279,13 +333,20 @@ export default function ChatThread({
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
console.error("AI chat stream error:", streamError);
onTurnFinished();
onTurnFinished(undefined, threadKey);
},
});
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
// CURRENT status rather than a value captured in a stale render closure — a turn
// can finish between render and click, and arming the interrupt refs against a
// no-op stop() would leave them set to leak into a later, unrelated Stop.
const statusRef = useRef(status);
statusRef.current = status;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
@@ -317,9 +378,49 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// Clear the stopped marker as soon as a new turn begins streaming.
// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages
// stay queued and flush normally after the new turn. Reuses the existing
// queue/flush machinery: promote the target to the head, then abort — the
// onFinish flush-on-abort branch sends exactly that head, tagged as an
// interrupt so the server notes the previous answer was cut off.
const sendNow = useCallback(
(id: string) => {
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
// the turn may have finished between this render and the click, in which case
// stop() is a no-op and arming the interrupt refs would strand them for a
// later, unrelated Stop. Reading the ref always sees the current status.
const liveStreaming =
statusRef.current === "submitted" || statusRef.current === "streaming";
if (liveStreaming) {
// Promote to head so the onFinish -> flushNext path sends exactly it.
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
const msg = queuedRef.current.find((m) => m.id === id);
if (!msg) return;
setQueue(removeQueuedById(queuedRef.current, id));
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop],
);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
// the race where a flag was armed but the expected abort never fired (the turn
// finished in the same tick as the click), so it cannot leak into a later turn.
useEffect(() => {
if (isStreaming) setStopNotice(null);
if (isStreaming) {
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
}
}, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
@@ -328,53 +429,6 @@ export default function ChatThread({
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while
// streaming, authoritative once a step reports usage) is the live figure. When
// the turn ends we emit a final exact value, then `null` so the parent reverts
// the badge to the persisted context size.
const lastEmitRef = useRef(0);
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!onLiveTurnTokens) return;
if (!isStreaming) {
// Turn ended (or never started): clear any pending throttle and revert.
if (emitTimerRef.current) {
clearTimeout(emitTimerRef.current);
emitTimerRef.current = null;
}
lastEmitRef.current = 0;
onLiveTurnTokens(null);
return;
}
const tail = messages[messages.length - 1];
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0;
const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz)
const elapsed = now - lastEmitRef.current;
if (elapsed >= MIN_INTERVAL) {
lastEmitRef.current = now;
onLiveTurnTokens(total);
} else if (!emitTimerRef.current) {
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
emitTimerRef.current = setTimeout(() => {
emitTimerRef.current = null;
lastEmitRef.current = Date.now();
onLiveTurnTokens(total);
}, MIN_INTERVAL - elapsed);
}
}, [messages, isStreaming, onLiveTurnTokens]);
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
// trailing emit can't fire into a torn-down thread's parent.
useEffect(() => {
return () => {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
};
}, []);
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)
@@ -458,6 +512,17 @@ export default function ChatThread({
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"

View File

@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
// OTHER named export of markdown.ts intact via `importActual`, and override only
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
// still renders. This is the seam that proves the MarkdownPart memo works: a
// finalized text part must NOT be re-parsed on a later streamed delta.
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
// as the memo key. The signature must NOT be recomputed inside the memo from the
// live (mutable) message — see message-item.tsx.
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
const callsFor = (text: string) =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
describe("MessageItem markdown memoization", () => {
it("does not re-parse finalized text parts when only a tail part grows", () => {
renderChatMarkdownSpy.mockClear();
// Two finalized text parts.
const first = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
const { rerender } = renderRow(first);
// Both finalized parts parsed exactly once on the initial render.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
// A streamed delta: a NEW message object where only a third tail part grows;
// the first two parts' text is byte-identical.
const next = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
{ type: "text", text: "gamm" },
]);
rerender(
<MantineProvider>
<MessageItem message={next} signature={messageSignature(next)} />
</MantineProvider>,
);
// The finalized parts hit the MarkdownPart memo: still parsed at most once
// each across BOTH renders (the resilient invariant). The only new parse is
// for the changed/added tail part.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
// `parts` IN PLACE and reusing the message object. A row that mounted empty
// (reasoning-first providers render nothing at first) must still stream its text
// in once the parent hands down a fresh signature snapshot. Before the fix the
// memo recomputed the signature from the (mutated) message — identical on both
// sides — and froze the row at its empty render, so the answer never appeared.
it("streams text in after the row mounted empty and parts mutated in place", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does).
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// Empty text part: nothing visible rendered yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
(message.parts[0] as { text: string }).text = "streamed answer";
rerender(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// The grown text now renders (the memo did NOT freeze the empty mount).
expect(callsFor("streamed answer")).toBe(1);
expect(queryByText("streamed answer")).not.toBeNull();
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next: importing the component module pulls in `useTranslation`,
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
// minimal `t` that echoes the key is enough. Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { arePropsEqual } from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). The memo key
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
* per render via `messageSignature(message)`. A FIXED message id is used so a
* content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Build the props the parent would pass, INCLUDING the snapshot signature it
// computes during its own render (the load-bearing part — see message-item.tsx:
// the signature must never be recomputed inside arePropsEqual).
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
signature: messageSignature(message),
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
...over,
});
describe("arePropsEqual", () => {
it("returns false when showCitations differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { showCitations: false })),
).toBe(false);
});
it("returns false when neutralizeInternalLinks differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
).toBe(false);
});
it("returns false when assistantName differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
).toBe(false);
});
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]);
expect(a).not.toBe(b);
expect(arePropsEqual(props(a), props(b))).toBe(true);
});
it("returns false when content changed in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
// `parts` in place and handing back a message wrapper that SHARES them. So the
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
// recomputing `messageSignature(message)` inside the comparator would read
// identical (latest) content on BOTH sides → always "equal" → the memo skips
// every streamed update and the assistant row freezes at its initial empty
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
// the parent captured at each render. This fails against the old implementation
// (a `prev.message === next.message` fast path + a signature recomputed from the
// live objects).
it("re-renders when parts were mutated in place but the snapshot changed", () => {
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
const prevSig = messageSignature(message); // snapshot BEFORE the delta
// SDK streams a delta by mutating the shared part IN PLACE:
(message.parts[0] as { text: string }).text = "hello world";
const nextSig = messageSignature(message); // snapshot AFTER the delta
expect(prevSig).not.toBe(nextSig);
// Same object reference on both sides (the SDK reuses it), differing snapshots.
const base = {
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
};
expect(
arePropsEqual(
{ ...base, signature: prevSig },
{ ...base, signature: nextSig },
),
).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -15,6 +16,25 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Immutable content signature for `message`, computed by the PARENT
* (MessageList) during its render via `messageSignature(message)`. This is the
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
* NOT recomputed from `message` inside `arePropsEqual`.
*
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
* array/objects in place and handing back a message wrapper that SHARES those
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
* post-mount render, and the assistant row freezes at its initial empty (null)
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
* providers start empty, so NOTHING shows). Snapshotting the signature into this
* immutable string prop in the parent fixes that: `prev.signature` holds the
* value from the previous render (old content) and `next.signature` the new
* content, so they differ as the turn streams in and the row re-renders.
*/
signature: string;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
@@ -34,6 +54,39 @@ interface MessageItemProps {
assistantName?: string;
}
/**
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
* primitives, so React.memo's default shallow compare is exactly right (the
* `text` string is compared by value).
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
<div
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{text}
</Text>
);
});
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.
@@ -41,17 +94,20 @@ interface MessageItemProps {
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
* User messages render their text as a right-aligned plain bubble.
*
* This component is intentionally NOT memoized: `useChat` replaces the streaming
* assistant message with a freshly cloned object on every streamed delta, so the
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
* per-message content signature: the streaming TAIL message's signature changes
* on each delta so it still re-renders and streams in, while finalized rows are
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
* long turn no longer re-parses the whole transcript on every token.
*/
export default function MessageItem({
function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly.
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -109,24 +165,12 @@ export default function MessageItem({
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
key={index}
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{part.text}
</Text>
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
);
}
@@ -177,3 +221,32 @@ export default function MessageItem({
</Box>
);
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
* parent), so it still re-renders and streams in; every FINALIZED message keeps
* the same signature and is skipped, turning a per-token whole-transcript
* re-render into a tail-only one.
*
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
* at its own render), NEVER `messageSignature(prev.message)` vs
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
* place, so both `prev.message` and `next.message` reflect the latest content
* here — recomputing the signature from them yields equal strings every time and
* freezes the row at its initial empty render (the bug this guards against). See
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
* fast path: same-reference-but-mutated must still re-render when the snapshot
* signature changed. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
return (
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName
);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
// Mirrors the t-mock pattern used by the other component tests in this folder
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
// every OTHER named export of markdown.ts intact via `importActual`, and override
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
// factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
// whole point of this file (it closes the parent-side coverage gap left by the
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
// harness). Use the relative import for the component under test, mirroring how
// message-list.tsx itself imports `MessageItem from "./message-item"`.
import MessageList from "./message-list";
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
//
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
renderChatMarkdownSpy.mockClear();
const { queryByText } = render(
<MantineProvider>
<MessageList
messages={[msg([{ type: "text", text: "hello world" }])]}
isStreaming={false}
/>
</MantineProvider>,
);
// The assistant text renders, which proves MessageList mounted the real
// MessageItem and handed it a valid `signature` prop (computed from the real
// `messageSignature`) — the full parent -> child -> markdown path is live.
expect(queryByText("hello world")).not.toBeNull();
});
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
// delta that REUSES the same message object. The fix moved the content signature
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
// every render and forward it as the immutable `signature` prop, so MessageItem's
// memo (which compares that prop snapshot) sees it change and re-renders the row.
//
// This test exercises the PARENT half that the memo tests only simulate: if
// MessageList ever cached/memoized the signature keyed on the message object's
// identity (which stays stable across deltas while its `parts` mutate in place),
// the snapshot would never change, MessageItem's memo would skip every delta, and
// the row would freeze at its empty mount — exactly the regression class. That
// would make this test fail. See message-item.tsx (`signature` prop +
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
// snapshot at render time).
it("reflects in-place part mutation of a reused message object across renders", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does). The empty text
// part means MessageItem renders nothing visible initially.
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Nothing streamed yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place on the SAME message object...
(message.parts[0] as { text: string }).text = "streamed answer";
// ...then re-render with a NEW array literal that still holds the SAME mutated
// message object (this mirrors useChat handing back a fresh array of reused
// message objects on each delta).
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// The grown text now renders: MessageList re-snapshotted the signature, so the
// row re-rendered instead of freezing at its empty mount.
expect(queryByText("streamed answer")).not.toBeNull();
expect(
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
});

View File

@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -196,9 +197,16 @@ export default function MessageList({
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
// `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
// shared `parts` in place, so prev/next message objects both read the
// latest content there and the memo would skip every streamed update
// (freezing the row at its empty render). See message-item.tsx.
<MessageItem
key={message.id}
message={message}
signature={messageSignature(message)}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { memo, useMemo, useState } from "react";
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -27,19 +27,23 @@ interface ReasoningBlockProps {
* Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible.
*/
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
// Collapse the blank-line gaps the model emits between every list item /
// paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — see collapseBlankLines. ONLY here, not in the normal answer.
const html = trimmed
? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "";
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
<Box className={classes.reasoningBlock} mb={6}>
@@ -87,3 +91,8 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
</Box>
);
}
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
// shallow compare), so a parent re-render during streaming of OTHER content does
// not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import { useChatSession } from "./use-chat-session";
import type { UseChatSessionOptions } from "./use-chat-session";
@@ -227,6 +227,50 @@ describe("useChatSession", () => {
expect(result.current.threadKey).toBe("C");
});
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
// Brand-new chat whose first turn is still streaming: the id is adopted only
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
// chat" must still remount to a clean thread even though the atom is unchanged
// — the render-phase reconciler (null === null) would otherwise do nothing,
// leaving the old chat/stream/history in place (the bug: only the role badge
// dropped).
const { result } = setup({ activeChatId: null, chats: { items: [] } });
const keyBefore = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(keyBefore);
});
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
// abort the abandoned stream on unmount: its onFinish still fires later with
// the real server id, tagged with the OLD (abandoned) mount key. That must not
// adopt — it would yank the user back into the chat they just left.
const { result, setActiveChatId, onInvalidateChatList } = setup({
activeChatId: null,
chats: { items: [] },
});
const abandonedKey = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(abandonedKey);
// The abandoned turn finishes in the background, streaming its real id "A".
result.current.onTurnFinished("A", abandonedKey);
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
// It still refreshes the chat list so the left-behind chat shows in history.
expect(onInvalidateChatList).toHaveBeenCalled();
});
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
// The happy path must keep working: onTurnFinished tagged with the mounted
// thread's own key adopts in place as before.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
const currentKey = result.current.threadKey;
result.current.onTurnFinished("A", currentKey);
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
// Open an existing chat whose history is still loading => loader on.
const { result, rerender } = setup({

View File

@@ -31,9 +31,19 @@ export interface UseChatSessionResult {
threadKey: string;
/** Show the history loader instead of the live thread. */
waitingForHistory: boolean;
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
* even when `activeChatId` is unchanged. The window calls this from
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
* still streaming (activeChatId still null, nothing to diverge) actually
* resets the chat instead of only dropping the role badge (#161). */
startFreshThread: () => void;
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
* thread that produced the turn (omit => "current thread", back-compatible):
* a turn ABANDONED by New chat mid-stream still fires this after its thread
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
* new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
@@ -98,6 +108,15 @@ export function useChatSession(
: switchThread(activeChatId),
);
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
// does not abort a stream on unmount and proxies callbacks through a ref, so an
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
// matching its key against this ref keeps that late finish from adopting the
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
const threadKeyRef = useRef(thread.key);
threadKeyRef.current = thread.key;
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
// reaches the client, so the primary metadata adoption cannot run. We then ARM
@@ -115,7 +134,23 @@ export function useChatSession(
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
// list, which races a second tab — #137; see adopt-chat-id.ts).
const onTurnFinished = useCallback(
(serverChatId?: string) => {
(serverChatId?: string, finishingThreadKey?: string) => {
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
// not abort on unmount and proxies callbacks through a ref). If that late
// finish ran the adoption path it would set activeChatId to the abandoned
// chat's real id and yank the user out of the fresh chat they just opened.
// So adopt / arm the fallback ONLY for the still-mounted thread; an
// abandoned one merely refreshes the chat list (so the left-behind chat
// surfaces in history) and does nothing else. A missing key (undefined)
// means "current thread" — keeps old call sites/tests working.
if (
finishingThreadKey !== undefined &&
finishingThreadKey !== threadKeyRef.current
) {
onInvalidateChatList();
return;
}
// Read the live id from the ref, not the closure: on a failed turn this can
// run twice in one turn (onFinish + onError) before any re-render, and the
// primary branch below updates the ref so the second call sees the adopted id.
@@ -258,9 +293,28 @@ export function useChatSession(
pendingNewChatRef.current = null;
}, []);
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
// so "New chat" pressed while a brand-new chat's first turn is still streaming
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
// end of the turn) is a no-op for it and the abandoned thread/stream/history
// would persist. Dispatching reconcile with a fresh key and chatId:null here
// always produces a new mount key, so React remounts ChatThread (a clean useChat
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
// keeps the reconciler from interfering. Also disarms any pending fallback.
const startFreshThread = useCallback(() => {
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: null,
newKey: `new-${generateId()}`,
});
}, []);
return {
threadKey: thread.key,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import type { ReactNode } from "react";
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// useMatch is the only react-router-dom export the hook uses; drive its return
// per test to simulate "on a page" vs "off a page".
const useMatchMock = vi.fn();
vi.mock("react-router-dom", () => ({
useMatch: () => useMatchMock(),
}));
// The bound-chat resolver is the network boundary; stub it per test.
const getBoundChatMock = vi.fn();
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
}));
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
// tests override useMatch to go off-page.
function onPage(pageSlug = "doc-p1") {
useMatchMock.mockReturnValue({ params: { pageSlug } });
}
function offPage() {
useMatchMock.mockReturnValue(null);
}
// Render the hook inside an explicit jotai store so atom side effects are
// assertable; the store is returned for setup + assertions.
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
const store = createStore();
seed?.(store);
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
return { store, open: () => act(() => result.current()) };
}
describe("useOpenAiChatForCurrentPage", () => {
beforeEach(() => {
vi.clearAllMocks();
onPage();
});
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
getBoundChatMock.mockResolvedValue("bound-chat-1");
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
await open();
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
});
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
getBoundChatMock.mockResolvedValue(null);
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("off a page: keeps the current selection and does NOT resolve", async () => {
offPage();
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "keep-me");
s.set(aiChatDraftAtom, "untouched");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
getBoundChatMock.mockResolvedValue("would-switch");
const { store, open } = setup((s) => {
s.set(aiChatWindowOpenAtom, true);
s.set(activeAiChatIdAtom, "current");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("current");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
getBoundChatMock.mockResolvedValue("same");
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "same");
s.set(aiChatDraftAtom, "in-progress");
});
await open();
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
getBoundChatMock.mockRejectedValue(new Error("network"));
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("clears the picked role on a real switch", async () => {
getBoundChatMock.mockResolvedValue("bound");
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
await open();
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useMatch } from "react-router-dom";
import {
aiChatWindowOpenAtom,
activeAiChatIdAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { extractPageSlugId } from "@/lib";
/**
* The generic "open the AI chat" action, WITH document binding: when invoked
* while viewing a page, it resolves that page's bound chat and selects it before
* opening — so the last chat for this document re-opens by itself. With no bound
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
* by the app-header entry point; NOT by the provenance badge (which deep-links).
*/
export function useOpenAiChatForCurrentPage() {
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
// Same route-match trick the window uses: read :pageSlug from the pathname.
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
const pageId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT
// re-resolve and yank the user to another chat: resolve only on a genuine
// closed -> open transition. (`windowOpen` is already true here, so there
// is nothing to set — just bail.)
if (windowOpen) return;
// Open the window FIRST so the control feels instant: the bound-chat
// round-trip below must never gate the window appearing, or on a slow
// connection the first click reads as a hung control until the POST returns.
setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current
if (pageId) {
try {
resolved = await getBoundChat(pageId); // null => fresh chat
} catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback
}
}
// Clear the composer draft / picked role ONLY on an actual switch, so
// reopening the same chat does not wipe an in-progress draft. Applied after
// the resolve so the window is already visible while the switch settles.
if (resolved !== activeChatId) {
setActiveChatId(resolved);
setDraft("");
setSelectedRoleId(null);
}
}, [
windowOpen,
activeChatId,
pageId,
setWindowOpen,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
}

View File

@@ -13,21 +13,40 @@ import {
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles,
importAiRolesFromCatalog,
renameAiChat,
updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
// Catalog reads resolve bundle names per language, so the language is part of
// the cache key (a language switch refetches rather than reusing stale names).
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
"ai-role-catalog",
language,
];
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
bundleId: string,
language: string,
) => ["ai-role-catalog-bundle", bundleId, language];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
},
});
}
/**
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
* fetch runs only when the catalog modal is open. The catalog can 502 when the
* curated source is unreachable; callers handle the error state in the UI.
*/
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
return useQuery<IAiRoleCatalog, Error>({
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
queryFn: () => getAiRoleCatalog(language),
enabled,
});
}
/**
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
* fetch only runs when a bundle is actually expanded.
*/
export function useAiRoleCatalogBundleQuery(
bundleId: string,
language: string,
enabled: boolean,
) {
return useQuery<IAiRoleCatalogBundle, Error>({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled,
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
notifications.show({
color: "red",
message: t("Failed to import {{count}} role(s)", {
count: result.errors.length,
}),
});
}
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// Imported roles can appear in the chat picker / badges.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
mutationFn: (id) => updateAiRoleFromCatalog(id),
onSuccess: (result) => {
// The server returns updated:false with a reason for a no-op (already
// up to date / removed from catalog / language no longer offered). Map
// each reason to a specific message instead of a generic "up to date".
// Narrow the discriminated union via `"reason" in result` (the `updated`
// boolean discriminant does not narrow under this project's
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
// union, so the comparisons below are compiler-checked.
let message: string;
if (!("reason" in result)) {
message = t("Updated to the latest version");
} else if (result.reason === "not-in-catalog") {
message = t("This role is no longer in the catalog");
} else if (result.reason === "language-unavailable") {
message = t("This language is no longer available in the catalog");
} else {
// "up-to-date" (the only remaining reason).
message = t("Already up to date");
}
notifications.show({ message });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
// the result carries partial errors. These tests pin both branches via
// renderHook with a mocked service (twin precedent:
// update-from-catalog-message.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key with interpolated values so we assert against the exact
// English message strings (mirrors react-i18next's default interpolation).
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
: key,
}),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
importAiRolesFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
updateAiRoleFromCatalog: vi.fn(),
}));
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleImportResult) {
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useImportAiRolesFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate({
bundleId: "general",
language: "en",
conflict: "rename",
});
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Imported 3, renamed 1, skipped 2",
});
});
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
await runMutation({
created: 1,
renamed: 0,
skipped: 0,
errors: [
{ slug: "a", message: "name taken" },
{ slug: "b", message: "name taken" },
],
});
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
message: "Imported 1, renamed 0, skipped 0",
});
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
color: "red",
message: "Failed to import 2 role(s)",
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
// a user-facing notification message. These tests pin each of the four branches
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key so we assert against the exact English message strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
updateAiRoleFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
importAiRolesFromCatalog: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
}));
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useUpdateAiRoleFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate("role-1");
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updated:true -> 'Updated to the latest version'", async () => {
await runMutation({
updated: true,
fromVersion: 1,
toVersion: 2,
role: { id: "role-1" } as never,
});
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Updated to the latest version",
});
});
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
await runMutation({ updated: false, reason: "not-in-catalog" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This role is no longer in the catalog",
});
});
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
await runMutation({ updated: false, reason: "language-unavailable" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This language is no longer available in the catalog",
});
});
it("up-to-date -> 'Already up to date'", async () => {
await runMutation({ updated: false, reason: "up-to-date" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Already up to date",
});
});
});

View File

@@ -6,8 +6,13 @@ import {
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -37,6 +42,17 @@ export async function getAiChatMessages(
return req.data;
}
/**
* Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page.
*/
export async function getBoundChat(pageId: string): Promise<string | null> {
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId,
});
return req.data.chatId;
}
/** Rename a chat. */
export async function renameAiChat(data: {
chatId: string;
@@ -68,6 +84,19 @@ export async function exportAiChat(
return req.data.markdown;
}
/**
* Generate a page title from note content (markdown). One-shot, non-streaming
* (#199): the server only summarizes the supplied text and returns a suggestion;
* it never writes the page. The caller applies the title via /pages/update.
*/
export async function generatePageTitle(content: string): Promise<string> {
const req = await api.post<{ title: string }>(
"/ai-chat/generate-page-title",
{ content },
);
return req.data.title;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only
@@ -99,3 +128,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
});
return req.data;
}
/**
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
* Browse a curated catalog, import roles/bundles into the workspace, and update
* an imported role when the catalog ships a newer version. Same `{ data }`
* unwrap convention as above.
*/
/** Browse the catalog, optionally localized to `language`. */
export async function getAiRoleCatalog(
language?: string,
): Promise<IAiRoleCatalog> {
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
language,
});
return req.data;
}
/** Open one catalog bundle in a language (role content + versions). */
export async function getAiRoleCatalogBundle(
bundleId: string,
language: string,
): Promise<IAiRoleCatalogBundle> {
const req = await api.post<IAiRoleCatalogBundle>(
"/ai-chat/roles/catalog/bundle",
{ bundleId, language },
);
return req.data;
}
/** Import roles from a catalog bundle into the workspace (admin). */
export async function importAiRolesFromCatalog(
payload: IAiRoleImportPayload,
): Promise<IAiRoleImportResult> {
const req = await api.post<IAiRoleImportResult>(
"/ai-chat/roles/import",
payload,
);
return req.data;
}
/** Update an already-imported role from its catalog source (admin). */
export async function updateAiRoleFromCatalog(
id: string,
): Promise<IAiRoleUpdateFromCatalogResult> {
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
"/ai-chat/roles/update-from-catalog",
{ id },
);
return req.data;
}

View File

@@ -57,10 +57,79 @@ export interface IAiRole {
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one.
// Admin-only (present only in the admin list view); the picker view omits it.
// The admin UI compares `version` against the catalog to offer an update.
source?: { slug: string; language: string; version: number } | null;
createdAt?: string;
updatedAt?: string;
}
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
export interface IAiRoleCatalogBundleSummary {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}
/** The browsable catalog index (mirrors `getCatalog()`). */
export interface IAiRoleCatalog {
languages: string[];
bundles: IAiRoleCatalogBundleSummary[];
}
/** A single role inside an opened catalog bundle (localized content + version). */
export interface IAiRoleCatalogRole {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
export interface IAiRoleCatalogBundle {
bundleId: string;
language: string;
roles: IAiRoleCatalogRole[];
}
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
export interface IAiRoleImportPayload {
bundleId: string;
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
slugs?: string[];
conflict: "skip" | "rename";
}
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}
/**
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
* to a specific message; a successful update carries the version bump + new role.
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
* comparisons be compiler-checked.
*/
export type IAiRoleUpdateFromCatalogResult =
| {
updated: false;
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
}
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;
@@ -116,6 +185,9 @@ export interface IAiChatMessageRow {
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
// floating window's header badge.
contextTokens?: number;
// The model's max context window (denominator for the header badge); set
// alongside contextTokens on a completed turn; absent on older rows.
maxContextTokens?: number;
// Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread.
error?: string;

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// Build a workspace role with a catalog source. Fields irrelevant to the
// install-state decision are filled with harmless defaults.
function installedRole(
source: { slug: string; language: string; version: number },
overrides: Partial<IAiRole> = {},
): IAiRole {
return {
id: `role-${source.slug}-${source.language}`,
name: source.slug,
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
source,
...overrides,
};
}
const catalogRole = { slug: "writer", version: 3 };
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
// pure function so the import/installed/update decision is testable directly.
describe("catalogRoleInstallState", () => {
it("no matching installed role -> import", () => {
const result = catalogRoleInstallState(catalogRole, [], "en");
expect(result).toEqual({ state: "import" });
});
it("same slug + language, installed version > catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version == catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 3,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version < catalog -> update (from/to)", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 1,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({
state: "update",
installed,
fromVersion: 1,
toVersion: 3,
});
});
it("same slug but DIFFERENT language -> import (a separate install)", () => {
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
// fresh import, not treat the ru copy as already installed.
const installed = installedRole({
slug: "writer",
language: "ru",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "import" });
});
it("matches the right language when the same slug is installed in several", () => {
const ru = installedRole(
{ slug: "writer", language: "ru", version: 5 },
{ id: "ru-role" },
);
const en = installedRole(
{ slug: "writer", language: "en", version: 1 },
{ id: "en-role" },
);
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
expect(result).toEqual({
state: "update",
installed: en,
fromVersion: 1,
toVersion: 3,
});
});
it("ignores manually-created roles (no source) sharing the name", () => {
const manual = installedRole(
{ slug: "writer", language: "en", version: 9 },
{ source: null },
);
const result = catalogRoleInstallState(catalogRole, [manual], "en");
expect(result).toEqual({ state: "import" });
});
});

View File

@@ -0,0 +1,49 @@
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* The install state of a single catalog role relative to the workspace's
* existing roles. Extracted as a pure function so the catalog modal's role-state
* computation is unit-testable without mounting the component (mirrors the
* `roleLaunchMessage` precedent in role-launch.ts).
*
* A catalog role is matched to an installed role by BOTH `source.slug` and
* `source.language`: the same slug in a different language is a separate install
* (so it shows as "import", not "installed"). When matched, the installed source
* version decides the state:
* - no match -> "import"
* - matched & installed version >= catalog version -> "installed"
* - matched & installed version < catalog version -> "update" (from -> to)
*/
export type CatalogRoleInstallState =
| { state: "import" }
| { state: "installed"; installed: IAiRole }
| {
state: "update";
installed: IAiRole;
fromVersion: number;
toVersion: number;
};
export function catalogRoleInstallState(
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
workspaceRoles: IAiRole[],
language: string,
): CatalogRoleInstallState {
const installed = workspaceRoles.find(
(r) => r.source?.slug === role.slug && r.source?.language === language,
);
if (!installed) return { state: "import" };
const fromVersion = installed.source?.version ?? 0;
if (fromVersion >= role.version) {
return { state: "installed", installed };
}
return {
state: "update",
installed,
fromVersion,
toVersion: role.version,
};
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
/**
* Pure-helper tests for the header context badge selection. Covers the two
* non-obvious rules: numerator and denominator are each taken from the most
* recent row carrying THAT value (they may live on different rows), and a fresh
* row with a zero/absent value must NOT shadow an older positive one.
*/
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
id: Math.random().toString(),
role: "assistant",
content: null,
metadata,
createdAt: "2026-01-01T00:00:00.000Z",
});
describe("selectContextBadge", () => {
it("returns zeros for empty / nullish input", () => {
expect(selectContextBadge(undefined)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge(null)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge([])).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
});
it("reads both figures from the most recent row that carries them", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1500, maxContextTokens: 200000 }),
]),
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
});
it("falls back to legacy usage total for older rows without contextTokens", () => {
expect(
selectContextBadge([
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
]),
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
expect(
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
});
it("takes numerator and denominator from different rows", () => {
// Freshest row (an error turn) carries contextTokens but no max; the older
// completed turn carries the max. Each is picked from its own latest row.
expect(
selectContextBadge([
row({ contextTokens: 800, maxContextTokens: 200000 }),
row({ contextTokens: 1200, error: "402: nope" }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("does not let a fresh zero/absent max shadow an older positive max", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1200, maxContextTokens: 0 }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("skips rows with null metadata", () => {
expect(
selectContextBadge([
row({ contextTokens: 500, maxContextTokens: 200000 }),
row(null),
]),
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
});
it("reports current > max as-is (no clamp)", () => {
expect(
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
});
});

View File

@@ -0,0 +1,49 @@
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Derive the header context badge figures from the persisted message rows.
*
* - `contextTokens` (numerator): how much the conversation now occupies in the
* model's context window. Read from the most recent row carrying a context
* figure — `contextTokens` (final-step input+output) on rows recorded after
* this shipped, else that turn's legacy `usage` total for older rows.
* - `maxContextTokens` (denominator): the model's configured max window, stamped
* alongside `contextTokens` on a completed turn.
*
* Each value is taken from the most recent row carrying THAT value
* independently — they may land on different rows (e.g. a fresh error row can
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
* whichever is still unset. `0` means "no row has it" (older rows, or no
* admin-configured limit); the badge then omits the value.
*/
export function selectContextBadge(
messageRows: readonly IAiChatMessageRow[] | undefined | null,
): { contextTokens: number; maxContextTokens: number } {
let contextTokens = 0;
let maxContextTokens = 0;
if (!messageRows) return { contextTokens, maxContextTokens };
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (contextTokens === 0) {
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
contextTokens = meta.contextTokens;
} else if (meta.usage) {
const usage = meta.usage;
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) contextTokens = fallback;
}
}
if (
maxContextTokens === 0 &&
typeof meta.maxContextTokens === "number" &&
meta.maxContextTokens > 0
) {
maxContextTokens = meta.maxContextTokens;
}
if (contextTokens !== 0 && maxContextTokens !== 0) break;
}
return { contextTokens, maxContextTokens };
}

View File

@@ -1,17 +1,5 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import {
estimateTokens,
liveTurnTokens,
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
describe("estimateTokens", () => {
it("returns 0 for the empty string", () => {
@@ -25,147 +13,3 @@ describe("estimateTokens", () => {
expect(estimateTokens("12345678")).toBe(2);
});
});
describe("liveTurnTokens — estimate path", () => {
it("is all zeros for an undefined message", () => {
expect(liveTurnTokens(undefined)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("is all zeros for a parts-less message", () => {
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("estimates output from text parts", () => {
// 8 chars -> 2 tokens.
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
});
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "12345678" },
{ type: "text", text: "abcd" },
]),
);
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
});
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcd" }, // 1
{ type: "tool-getPage", state: "output-available" }, // ignored
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcdefgh" }, // 2
]),
);
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
});
it("ignores non text/reasoning parts (tools, step-start)", () => {
const r = liveTurnTokens(
msg([
{ type: "step-start" },
{ type: "tool-getPage", state: "input-available" },
]),
);
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
});
});
describe("liveTurnTokens — authoritative path", () => {
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
const r = liveTurnTokens(
msg([{ type: "text", text: "estimate would be tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
it("treats missing reasoningTokens as 0 and keeps full output", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "x" }], {
usage: { inputTokens: 10, outputTokens: 42 },
}),
);
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
});
it("never returns a negative output when reasoning exceeds reported output", () => {
const r = liveTurnTokens(
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
);
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
});
it("falls back to the estimate when metadata has no usage object", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
);
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
});
});
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
it("ticks the in-flight step above the completed-steps authoritative base", () => {
// The authoritative usage is the sum over COMPLETED steps (step 1). The
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
// the parts -> the running estimate must push the live figure above the base
// so the badge keeps growing between step boundaries.
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
const r = liveTurnTokens(
msg([{ type: "text", text: longText }], {
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
}),
);
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
expect(r.output).toBe(200);
expect(r.authoritative).toBe(true);
});
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
const r = liveTurnTokens(
msg([{ type: "reasoning", text: longReasoning }], {
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
}),
);
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
expect(r.reasoning).toBe(100);
expect(r.output).toBe(0);
expect(r.authoritative).toBe(true);
});
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
// Short on-screen text (estimate tiny) but a large authoritative output:
// the exact figure wins at the boundary (the counter never under-reports).
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], {
usage: { inputTokens: 10, outputTokens: 5000 },
}),
);
expect(r.output).toBe(5000);
});
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
const r = liveTurnTokens(
msg([{ type: "text", text: "tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
});

View File

@@ -1,18 +1,11 @@
import type { UIMessage } from "@ai-sdk/react";
/**
* Live token counting for a streaming AI-chat turn — split into REASONING
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
* `Thinking… · 60 tokens` next to its thinking indicator.
* Rough client-side token estimation for AI-chat UI affordances.
*
* No provider streams exact per-token usage mid-stream, so the live number is a
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
* once the server attaches it on a step/turn boundary (see the server's
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
* authoritative usage is present we return it verbatim (the number "jumps to
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
* bundle, and be wrong for Gemini/Ollama anyway).
* 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
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
* ("Thinking · N tokens").
*/
/**
@@ -24,90 +17,3 @@ export function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/** Authoritative per-step/turn usage the server attaches to message metadata. */
export interface AuthoritativeUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** Live token split for a turn's tail (streaming) assistant message. */
export interface LiveTurnTokens {
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
reasoning: number;
/** Answer/output tokens (estimate, or authoritative when available). */
output: number;
/** True when the numbers come from authoritative server usage, not estimate. */
authoritative: boolean;
}
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
const meta = message?.metadata as
| { usage?: AuthoritativeUsage }
| undefined;
const usage = meta?.usage;
if (!usage || typeof usage !== "object") return undefined;
return usage;
}
/**
* Token split for the given (streaming) assistant message.
*
* COMBINES the authoritative server usage with the running text estimate so the
* counter ticks in real time AND lands exact. The server only attaches
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
* So a multi-step turn that returned the authoritative figure verbatim would
* FREEZE between boundaries and jump in steps (issue #163).
*
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
* per-component MAX of the authoritative base and the estimate:
* - between boundaries the estimate of the in-flight step ticks the number up;
* - at a boundary the authoritative figure snaps it to exact;
* - because the server's usage is cumulative and we only ever take the max, the
* number is MONOTONIC — it never drops.
*
* Providers that don't stream reasoning text still surface a reasoning count once
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
*/
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
if (!message) return { reasoning: 0, output: 0, authoritative: false };
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
let estReasoning = 0;
let estOutput = 0;
for (const part of message.parts ?? []) {
if (part.type === "reasoning") {
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
} else if (part.type === "text") {
estOutput += estimateTokens((part as { text?: string }).text ?? "");
}
}
const usage = metadataUsage(message);
if (!usage) {
// No authoritative usage streamed yet: the estimate IS the live figure.
return { reasoning: estReasoning, output: estOutput, authoritative: false };
}
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
// figure (never go negative if a provider reports them inconsistently).
const authReasoning = usage.reasoningTokens ?? 0;
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
// Per-component max: the in-flight step's estimate ticks above the completed-
// steps base between boundaries, and the authoritative figure wins once it
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
return {
reasoning: Math.max(authReasoning, estReasoning),
output: Math.max(authOutput, estOutput),
authoritative: true,
};
}

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Pure-helper tests for `messageSignature`, the cheap per-message content
* signature that drives MessageItem's memo (a streaming row's signature must
* change on every delta so it re-renders, while a finalized row's stays stable
* so it is skipped). Each test exercises ONE change signal and asserts it flips
* the signature; a content-identical clone must keep an EQUAL signature.
*
* The signature embeds `message.id` and `message.role`, so the `msg` factory
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
* identical content would get different signatures and the negative case would
* be impossible to express.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: "m1",
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("messageSignature", () => {
it("changes when a text part grows", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([{ type: "text", text: "alpha beta" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a new part is appended", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part's state flips", () => {
const before = msg([
{ type: "tool-getPage", state: "input-streaming" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a tool part gains an output", () => {
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { ok: true },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part gains an errorText", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "boom",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
// The specifically-commented edge case: the authoritative turn total lands on
// the final finish-step AFTER the reasoning text length and state are frozen.
// Only the token count appears between these two snapshots, so the signature
// MUST still flip — otherwise the "Thinking · N tokens" header would never
// snap from the live estimate to the exact figure.
const before = msg([
{ type: "reasoning", text: "thinking", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "thinking", state: "done" } as never],
{ usage: { reasoningTokens: 42 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.error appears", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
// A finalized row that is re-created as a fresh object (different parts array
// by reference, same parts by value) must keep an EQUAL signature, so the
// memo skips re-rendering it.
const a = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
const b = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
expect(a).not.toBe(b);
expect(messageSignature(a)).toBe(messageSignature(b));
});
});
/**
* Per-part-kind coupling guard for the load-bearing invariant documented at the
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
* MessageItem render body draws, or the memo freezes a stale row. This is an
* executable lock for the part kinds rendered TODAY — read alongside
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
* helper (message-content.ts), which "mirrors MessageItem's render decisions
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
* signature. If a new visible field is rendered without being added here AND to
* the signature, the corresponding assertion below should fail — that is the
* guard. (This intentionally stops short of the render-descriptor refactor:
* adding a part kind or a visible field still requires a human to extend both
* the signature and this block.)
*/
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
it("flips when the visible text changes", () => {
// Streaming is append-only, so the visible text only grows; the signature
// samples its length, so the growth is the change signal.
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer extended" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
it("flips when the visible reasoning text changes", () => {
const before = msg([
{ type: "reasoning", text: "think", state: "streaming" } as never,
]);
const after = msg([
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
// finish-step after text length and state are frozen.
const before = msg([
{ type: "reasoning", text: "think", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "think", state: "done" } as never],
{ usage: { reasoningTokens: 99 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
it("flips when the run state changes (running ↔ done icon + label)", () => {
// toolRunState(part.state) selects the spinner/check/error icon.
const before = msg([
{ type: "tool-getPage", state: "input-available" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when output arrives (drives the rendered citation links)", () => {
// toolCitations reads part.output to render the "/p/{id}" anchors.
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { id: "page-1", title: "Doc" },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when errorText appears (the visible red error detail line)", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "permission denied",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("metadata banners — render draws error / aborted notices", () => {
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
});

View File

@@ -0,0 +1,44 @@
import type { UIMessage } from "@ai-sdk/react";
/** Cheap content signature for one message: changes iff something VISIBLE in the
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
* appended, a tool/text part flips state once), so a per-part [type, text
* length, state, error/output presence] tuple + the persisted metadata
* (error/finishReason) is a sufficient change signal without comparing full
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
* text length, state, error/output presence] (e.g. a tool that streams
* `preliminary` output, or a client-side regenerate that edits a finalized
* row in place), extend this signature or the memo will freeze a stale row. */
export function messageSignature(message: UIMessage): string {
const parts = message.parts
.map((p) => {
const any = p as {
type: string;
text?: string;
state?: string;
errorText?: string;
output?: unknown;
};
return [
any.type,
any.text?.length ?? 0,
any.state ?? "",
any.errorText ? 1 : 0,
any.output !== undefined ? 1 : 0,
].join(":");
})
.join("|");
const meta = message.metadata as
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
| undefined;
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
// state are already frozen. Without it in the signature the row's signature would be
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
// to the exact figure.
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
meta?.finishReason ?? ""
}#${meta?.usage?.reasoningTokens ?? ""}`;
}

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
@@ -89,6 +90,52 @@ describe("removeQueuedById", () => {
});
});
describe("promoteToHead", () => {
it("moves the matching id to the front, preserving the rest's order", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
expect(promoteToHead(queue, "c")).toEqual([
{ id: "c", text: "third" },
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("is a no-op order-wise when the id is already the head", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "a")).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "missing")).toEqual(queue);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
promoteToHead(queue, "b");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];

View File

@@ -32,3 +32,16 @@ export function removeQueuedById(
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}
/** Move the queued message with the given id to the FRONT (returns a new array).
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
* "send now" action: promoting a message to the head lets the existing
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
export function promoteToHead(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
const target = queue.find((m) => m.id === id);
if (!target) return queue;
return [target, ...queue.filter((m) => m.id !== id)];
}

View File

@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on

View File

@@ -9,11 +9,10 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu || showLinkMenu) return;
// Hide the bubble menu immediately when the link menu is shown
if (showLinkMenu) return;
return (
<BubbleMenu
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ zIndex: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && (
<>
<NodeSelector

View File

@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
type FixedToolbarProps = {
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />

View File

@@ -1,23 +0,0 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};

View File

@@ -0,0 +1,39 @@
import { FC } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
interface Props {
pageId: string;
color?: string;
iconSize?: number;
}
/**
* AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's AI chat flag is on.
*/
export const GenerateTitleGroup: FC<Props> = ({
pageId,
color = "gray",
iconSize = 20,
}) => {
const { t } = useTranslation();
const gen = useGeneratePageTitle(pageId);
return (
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color={color}
aria-label={t("Generate title with AI")}
loading={gen.isPending}
onClick={() => gen.mutate()}
>
<IconSparkles size={iconSize} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
};

View File

@@ -104,6 +104,19 @@
min-width: 0;
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
which pushes the first text line ~0.5em below the "N." marker (aligned to
flex-start), making the number float above the text. Drop the outer margins
so the marker and the first line share the same top edge — same approach
used for callouts in core.css. */
.definitionContent > :first-child {
margin-top: 0;
}
.definitionContent > :last-child {
margin-bottom: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;

View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
import {
FootnoteReference,
FootnotesList,
FootnoteDefinition,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTE_DEFINITION_NAME,
FOOTNOTES_LIST_NAME,
} from "@docmost/editor-ext";
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
/**
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
* applied with a manual transaction (handlePaste returns true), so it bypasses
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
* out-of-order markdown footnote block come out canonical (issue #228).
*/
const extensions = [
Document,
Paragraph,
Text,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
];
function makeSchema() {
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
const { schema } = editor;
return { editor, schema };
}
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
function listIds(slice: Slice): string[] {
const out: string[] = [];
slice.content.forEach((node: PMNode) => {
if (node.type.name === FOOTNOTES_LIST_NAME) {
node.content.forEach((def: PMNode) => {
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
});
}
});
return out;
}
function hasList(slice: Slice): boolean {
let found = false;
slice.content.forEach((n: PMNode) => {
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
});
return found;
}
describe("canonicalizePastedFootnotes", () => {
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
const { editor, schema } = makeSchema();
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
// (z is an orphan) — the exact shape a markdown paste produces.
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.text("body "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("note A")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("note B")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
schema.nodes.paragraph.create(null, [schema.text("note C")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
]),
]),
]),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
// Reference order, orphan z dropped, reused a appears once.
expect(listIds(out)).toEqual(["c", "a", "b"]);
editor.destroy();
});
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
// A paste that reuses an id defined in the TARGET doc must NOT gain a
// synthesized empty definition here — it carries no footnotesList of its own.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.from(
schema.nodes.paragraph.create(null, [
schema.text("see "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(hasList(out)).toBe(false);
expect(out).toBe(slice); // returned unchanged (same reference)
editor.destroy();
});
it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => {
// A whole-block paste of ONLY definitions (a footnotesList with no matching
// footnoteReference anywhere in the selection). Canonicalizing it would strip
// the reference-less list -> an EMPTY paste, losing the pasted text. The hook
// must leave such a block untouched.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.fromArray([
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("note A")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("note B")]),
]),
]),
]),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(out).toBe(slice); // returned unchanged (same reference, content kept)
expect(listIds(out)).toEqual(["a", "b"]);
editor.destroy();
});
it("leaves an open (partial) slice untouched even if it carries a list", () => {
// An open slice (openStart/openEnd > 0) is a partial selection, not a
// standalone block, so it is returned as-is BEFORE any footnote handling.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("A")]),
]),
]),
]),
1,
1,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(out).toBe(slice);
editor.destroy();
});
});

View File

@@ -3,7 +3,14 @@ import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
import {
markdownToHtml,
htmlToMarkdown,
canonicalizeFootnotes,
FOOTNOTES_LIST_NAME,
FOOTNOTE_REFERENCE_NAME,
} from "@docmost/editor-ext";
import type { Schema } from "@tiptap/pm/model";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
@@ -83,12 +90,25 @@ export const MarkdownClipboard = Extension.create({
const body = elementFromString(parsed);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
const parsedSlice = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(body, {
preserveWhitespace: true,
});
// A markdown paste builds its ProseMirror fragment directly (DOM ->
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
// reorders an existing list. So a pasted markdown block whose footnote
// definitions are out of order (or contains orphan defs) would be
// stored out of order. Canonicalize the self-contained pasted block so
// its footnotes come out reference-ordered, deduped and orphan-free
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
// to whole-block pastes that carry their own footnotesList.
const contentNodes = canonicalizePastedFootnotes(
parsedSlice,
this.editor.schema,
);
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
@@ -133,6 +153,54 @@ export const MarkdownClipboard = Extension.create({
},
});
/**
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
* list, so an out-of-order pasted block would otherwise persist out of order).
*
* Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry
* their OWN footnotesList: canonicalizeFootnotes would synthesize empty
* definitions for any reference lacking a definition, which is correct for a
* standalone block but would be wrong for a reference-only paste that REUSES a
* footnote already defined in the target document — so those are left untouched
* for the paste/sync plugins to merge. Residual: when the pasted block is merged
* into a doc that already has footnotes, ordering RELATIVE to the pre-existing
* footnotes is still governed by the sync plugin (which does not reorder).
*
* Also requires at least one footnoteReference in the selection: a definitions-ONLY
* paste (`[^a]: …` with no `[^a]` reference in the same block) has no references,
* so canonicalizeFootnotes would drop the whole list and the paste would come out
* EMPTY — losing the pasted text. Such a block is left as-is for the sync plugin.
*/
export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice {
if (slice.openStart !== 0 || slice.openEnd !== 0) return slice;
let hasFootnotesList = false;
let hasReference = false;
slice.content.forEach((node) => {
if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true;
// footnoteReference is an inline atom, never a top-level slice child here
// (this function early-returns for open slices, so children are whole
// blocks), so it is only reachable by descending.
node.descendants((child) => {
if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true;
});
});
if (!hasFootnotesList) return slice;
// No reference anywhere -> a definitions-only paste; canonicalizing would strip
// the reference-less list (empty paste). Leave it untouched.
if (!hasReference) return slice;
const content = slice.content.toJSON();
if (!Array.isArray(content)) return slice;
const canonical = canonicalizeFootnotes({ type: "doc", content }) as {
content?: unknown[];
};
const fragment = Fragment.fromJSON(schema, canonical.content ?? []);
return new Slice(fragment, 0, 0);
}
function elementFromString(value) {
// add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`;

View File

@@ -26,17 +26,20 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
import clsx from "clsx";
import {
currentPageEditModeAtom,
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
type PageUser = {
id: string;
@@ -74,6 +77,9 @@ export function FullEditor({
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation is gated by the general AI chat flag (the same toggle
// that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
@@ -103,6 +109,7 @@ export function FullEditor({
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -111,11 +118,13 @@ export function FullEditor({
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
@@ -128,19 +137,23 @@ export function FullEditor({
}
type PageBylineProps = {
pageId: string;
creator?: PageUser;
contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
isTitleGenEnabled?: boolean;
};
function PageByline({
pageId,
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
isTitleGenEnabled,
}: PageBylineProps) {
const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details");
@@ -148,6 +161,9 @@ function PageByline({
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const showTitleGen = Boolean(
isTitleGenEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -238,6 +254,11 @@ function PageByline({
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
{/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
)}
</Group>
</Group>
);

View File

@@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider, createStore } from "jotai";
import type { Editor } from "@tiptap/core";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
// --- Mocks for the hook's collaborators ---------------------------------------
const generatePageTitleMock = vi.fn();
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
generatePageTitle: (content: string) => generatePageTitleMock(content),
}));
const updateTitleMock = vi.fn();
const updatePageDataMock = vi.fn();
vi.mock("@/features/page/queries/page-query.ts", () => ({
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
updatePageData: (page: unknown) => updatePageDataMock(page),
}));
const emitMock = vi.fn();
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
useQueryEmit: () => emitMock,
}));
const localEmitMock = vi.fn();
vi.mock("@/lib/local-emitter.ts", () => ({
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
}));
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
// purely via the fake page editor's getHTML().
vi.mock("@docmost/editor-ext", () => ({
htmlToMarkdown: (html: string) => html,
}));
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Import after mocks are registered.
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
// --- Test helpers -------------------------------------------------------------
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
return {
isDestroyed: false,
getHTML: () => html,
storage: { pageId },
} as unknown as Editor;
}
function makeTitleEditor(): Editor & {
commands: { setContent: ReturnType<typeof vi.fn> };
} {
return {
isDestroyed: false,
isFocused: false,
commands: { setContent: vi.fn() },
} as unknown as Editor & {
commands: { setContent: ReturnType<typeof vi.fn> };
};
}
function setup(pageId: string, store = createStore()) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<Provider store={store}>{children}</Provider>
</QueryClientProvider>
);
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
wrapper,
});
return { result, store };
}
const PAGE_A = {
id: "pageA",
title: "Generated Title",
spaceId: "space1",
slugId: "slugA",
parentPageId: null,
icon: null,
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
describe("useGeneratePageTitle", () => {
it("shows a notice and bails when the editor content is empty", async () => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
store.set(titleEditorAtom as never, makeTitleEditor());
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
);
expect(generatePageTitleMock).not.toHaveBeenCalled();
expect(updateTitleMock).not.toHaveBeenCalled();
});
it("leaves the title untouched when the model returns nothing usable", async () => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, makeTitleEditor());
generatePageTitleMock.mockResolvedValue(" ");
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(updateTitleMock).not.toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({
message: "Could not generate a title",
color: "yellow",
}),
);
});
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
const store = createStore();
const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
generatePageTitleMock.mockResolvedValue("Generated Title");
updateTitleMock.mockResolvedValue(PAGE_A);
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
"Generated Title",
);
expect(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "Title generated" }),
);
});
it("does NOT write the visible title field when the user navigated away during generation", async () => {
const store = createStore();
const titleEditor = makeTitleEditor(); // persistent across navigation
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
// Control when generation resolves so we can navigate mid-flight.
let resolveTitle!: (t: string) => void;
generatePageTitleMock.mockReturnValue(
new Promise<string>((res) => {
resolveTitle = res;
}),
);
updateTitleMock.mockResolvedValue(PAGE_A);
const { result } = setup("pageA", store);
let pending!: Promise<void>;
act(() => {
pending = result.current.mutateAsync();
});
// User navigates to page B: the live page editor now belongs to pageB.
act(() => {
store.set(pageEditorAtom as never, makePageEditor("pageB"));
});
await act(async () => {
resolveTitle("Generated Title");
await pending;
});
// DB write is still correct (keyed by the captured pageId)...
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
// ...but we must NOT stamp page A's title into page B's visible field.
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// The change is still broadcast to other clients.
expect(emitMock).toHaveBeenCalled();
});
it("does NOT write the visible title field when the title editor is focused", async () => {
const store = createStore();
const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
// Resolve generation under our control so we can mark the live title editor
// as focused before the post-generation write runs.
let resolveTitle!: (t: string) => void;
generatePageTitleMock.mockReturnValue(
new Promise<string>((res) => {
resolveTitle = res;
}),
);
updateTitleMock.mockResolvedValue(PAGE_A);
const { result } = setup("pageA", store);
let pending!: Promise<void>;
act(() => {
pending = result.current.mutateAsync();
});
// The user clicked into the title field while the model ran — overwriting it
// now would clobber what they are actively typing.
act(() => {
(titleEditor as { isFocused: boolean }).isFocused = true;
});
await act(async () => {
resolveTitle("Generated Title");
await pending;
});
// The DB write still persists the value...
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
// ...but the visible field is left alone while it is focused.
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// The change is still broadcast to other clients.
expect(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled();
});
it("bails before calling the model when the page editor is destroyed", async () => {
const store = createStore();
const pageEditor = makePageEditor("pageA");
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
store.set(pageEditorAtom as never, pageEditor);
store.set(titleEditorAtom as never, makeTitleEditor());
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(generatePageTitleMock).not.toHaveBeenCalled();
expect(updateTitleMock).not.toHaveBeenCalled();
});
it.each([
[403, "AI title generation is disabled"],
[503, "AI is not configured"],
[429, "Too many requests, please try again later"],
[500, "Failed to generate title"],
])("maps HTTP %s onError to a friendly message", async (status, message) => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, makeTitleEditor());
generatePageTitleMock.mockRejectedValue({ response: { status } });
const { result } = setup("pageA", store);
await act(async () => {
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
});
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message, color: "red" }),
);
});
});

View File

@@ -0,0 +1,134 @@
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query.ts";
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
// Maximum length we send to the model. The server truncates again; this is a
// cheap client-side bound so we never ship a huge body over the wire.
const MAX_CONTENT_CHARS = 20000;
/**
* Generate a title for the given page from the LIVE editor content (#199),
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
* server endpoint only summarizes the supplied markdown — it never writes the
* page; the actual title write goes through the existing /pages/update mutation
* (which enforces edit permission), and is mirrored to the title field + other
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
* button can show a loading state via `isPending`.
*/
export function useGeneratePageTitle(pageId: string) {
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
const emit = useQueryEmit();
// The page/title editors come from GLOBAL atoms that re-point when the user
// navigates to another page. The mutation below awaits the model for 1-3s, and
// its closure captures the editors from the render that started it. Keep a live
// reference so the post-generation write targets whatever page is on screen
// *now*, not the page the generation was started from.
const editorsRef = useRef({ pageEditor, titleEditor });
editorsRef.current = { pageEditor, titleEditor };
return useMutation<void, Error, void>({
mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return;
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
if (!markdown) {
notifications.show({ message: t("The note is empty"), color: "yellow" });
return;
}
const title = (
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
).trim();
if (!title) {
// The model returned nothing usable — keep the existing title untouched.
notifications.show({
message: t("Could not generate a title"),
color: "yellow",
});
return;
}
const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache
// Reflect the new title in the field immediately. The button lives in the
// byline, so the title editor is not focused — setContent is safe and stays
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
//
// Guard against navigation during generation: if the user switched pages
// while the model ran, the (persistent) title editor now shows ANOTHER
// page, so writing here would drop page A's title into page B's visible
// field. page-editor.tsx stamps the live page editor with its pageId
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
// pageId` guard — bail the visible write unless that live editor still
// belongs to the page this title was generated for. The DB write above is
// already correct (keyed by the captured `pageId`), and the broadcast below
// still propagates page A's change to other clients.
const livePageEditor = editorsRef.current.pageEditor;
const liveTitleEditor = editorsRef.current.titleEditor;
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
const livePageId = (livePageEditor?.storage as { pageId?: string })
?.pageId;
const stillOnPage = livePageId === pageId;
if (
stillOnPage &&
liveTitleEditor &&
!liveTitleEditor.isDestroyed &&
!liveTitleEditor.isFocused
) {
liveTitleEditor.commands.setContent(page.title);
}
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
localEmitter.emit("message", event);
emit(event);
notifications.show({ message: t("Title generated") });
},
onError: (err) => {
// Map known HTTP statuses to friendly messages, falling back to generic.
const status = (err as { response?: { status?: number } })?.response
?.status;
const message =
status === 403
? t("AI title generation is disabled")
: status === 503
? t("AI is not configured")
: status === 429
? t("Too many requests, please try again later")
: t("Failed to generate title");
notifications.show({ message, color: "red" });
},
});
}

View File

@@ -10,9 +10,15 @@ ul[data-type="taskList"] {
display: flex;
> label {
padding-top: 0.2rem;
/* Box exactly one text-line tall and center the checkbox in it, so the
checkbox lines up with the first line of the item's text. This tracks
the editor line-height (--mantine-line-height-xl) instead of a magic
padding-top that drifts from the real line box. */
flex: 0 0 auto;
margin-right: 0.5rem;
height: calc(var(--mantine-line-height-xl, 1.65) * 1em);
display: inline-flex;
align-items: center;
user-select: none;
}

View File

@@ -1,5 +1,6 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
// A single create-note action, parametrized by `temporary`. Self-contained: it
// owns its own create mutation so the regular and temporary buttons show
// independent loading state, while the list of writable spaces is resolved once
// by the parent and passed in. With exactly one writable space it creates
// directly; with several it shows a target-space picker.
function CreateNoteButton({
writableSpaces,
temporary,
label,
icon,
color,
}: {
writableSpaces: ISpace[];
temporary: boolean;
label: string;
icon: ReactNode;
// Mantine color token; lets the temporary action tint toward the warm
// orange/amber used by the clock marker + banner while "New note" stays neutral.
color: string;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
// not part of the shared `IPageInput` type; cast to satisfy the mutation
// signature.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
}
};
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
color={color}
fullWidth
leftSection={icon}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
{label}
</Button>
);
}
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
color={color}
fullWidth
leftSection={icon}
loading={isPending}
>
{t("New note")}
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
</Menu>
);
}
// Prominent home-screen actions to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several. Renders two full-width, vertically stacked buttons: a
// neutral regular note and an orange-tinted temporary note (which auto-moves to
// Trash after the workspace lifetime). Stacking full-width keeps the longer
// "New temporary note" label from clipping on narrow mobile widths.
export default function NewNoteButton() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
return (
<Stack gap="sm">
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={false}
label={t("New note")}
icon={<IconPlus size={18} />}
color="gray"
/>
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={true}
label={t("New temporary note")}
icon={<IconHourglass size={18} />}
color="orange"
/>
</Stack>
);
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getDefaultStore } from "jotai";
// Mock the app entry so importing the query module doesn't boot the whole app
// (it only needs queryClient's cache methods, which we stub here). The spies are
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(() => undefined as unknown),
invalidateQueries: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData, getQueryData, invalidateQueries },
}));
import { syncTemporaryExpiresInCache } from "./page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
({
id,
slugId,
name: id,
position: "a0",
spaceId: "space-1",
parentPageId: null,
hasChildren: false,
children: [],
}) as unknown as SpaceTreeNode;
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
beforeEach(() => {
vi.clearAllMocks();
getQueryData.mockReturnValue(undefined);
});
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
store.set(treeDataAtom, tree);
const deadline = "2026-07-01T00:00:00.000Z";
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
const next = store.get(treeDataAtom);
// A new atom value was written...
expect(next).not.toBe(tree);
// ...the matching node gained the deadline...
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
// ...and the untouched sibling is unchanged.
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
});
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1")];
store.set(treeDataAtom, tree);
syncTemporaryExpiresInCache(
{ id: "not-in-tree", slugId: "missing" },
"2026-07-01T00:00:00.000Z",
);
// treeModel.update is a no-op (same reference) for an unknown id, so the
// guard skips the store write entirely — same reference back.
expect(store.get(treeDataAtom)).toBe(tree);
});
});

View File

@@ -1,7 +1,57 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
import { getDefaultStore } from "jotai";
import {
toggleTemplate,
toggleTemporary,
} from "@/features/page-embed/services/page-embed-api";
import type {
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
/**
* After toggling a note's temporary state, mirror the new deadline into the
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
* menu label, the in-page banner, and the tree icon all reflect the change.
* Centralised here so the header menu and the banner can't drift apart on the
* cache-key plumbing.
*/
export function syncTemporaryExpiresInCache(
page: { id: string; slugId: string },
temporaryExpiresAt: string | null,
) {
for (const key of [page.slugId, page.id]) {
const cached = queryClient.getQueryData<any>(["pages", key]);
if (cached) {
queryClient.setQueryData(["pages", key], {
...cached,
temporaryExpiresAt,
});
}
}
// Patch the in-memory sidebar tree node so its temporary clock marker
// appears/disappears immediately — WITHOUT a reload. The page cache update
// above only drives the in-page banner/menu; the sidebar reads
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
// store the sidebar's hooks read from. `treeModel.update` returns the same
// reference (a no-op) when the page isn't in the currently loaded tree.
const store = getDefaultStore();
const prevTree = store.get(treeDataAtom);
const nextTree = treeModel.update(prevTree, page.id, {
temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),
});
}
export function useToggleTemplateMutation() {
return useMutation<
@@ -18,3 +68,20 @@ export function useToggleTemplateMutation() {
},
});
}
export function useToggleTemporaryMutation() {
return useMutation<
ToggleTemporaryResponse,
Error,
{ pageId: string; temporary?: boolean }
>({
mutationFn: (data) => toggleTemporary(data),
onError: (err: any) => {
notifications.show({
message:
err?.response?.data?.message || "Failed to update temporary note",
color: "red",
});
},
});
}

View File

@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
import type {
PageTemplateLookup,
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "../types/page-embed.types";
export async function lookupTemplate(params: {
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
const r = await api.post("/pages/toggle-template", params);
return r.data;
}
export async function toggleTemporary(params: {
pageId: string;
temporary?: boolean;
}): Promise<ToggleTemporaryResponse> {
const r = await api.post("/pages/toggle-temporary", params);
return r.data;
}

View File

@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
pageId: string;
isTemplate: boolean;
};
export type ToggleTemporaryResponse = {
pageId: string;
// null => the note was made permanent; ISO string => armed deadline.
temporaryExpiresAt: string | null;
};

View File

@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
import {
IconArrowRight,
IconArrowsHorizontal,
IconClockHour4,
IconDots,
IconEye,
IconEyeOff,
@@ -24,6 +25,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
@@ -160,6 +165,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!page?.temporaryExpiresAt;
const handleToggleTemporary = async () => {
if (!page?.id) return;
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: next,
});
// Reflect the new deadline in the page cache (menu label + banner) AND in
// the sidebar tree node so its clock marker updates immediately, no reload.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
@@ -309,6 +337,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={handleToggleTemporary}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} />}

View File

@@ -0,0 +1,87 @@
import { Button, Group, Paper, Text } from "@mantine/core";
import { IconClockHour4 } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
type TemporaryNoteBannerProps = {
slugId: string;
};
/**
* Banner shown on an open temporary note ("structure or die"). Mirrors
* DeletedPageBanner: it reads the page from the shared query cache and offers
* the explicit rescue action — "Make permanent". Children ride along to trash
* with the note, which is noted in the copy.
*/
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId: slugId });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
const toggleTemporary = useToggleTemporaryMutation();
// Don't show on a note that is already in trash; the deleted-page banner
// owns that state.
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
const handleMakePermanent = async () => {
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: false,
});
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
} catch {
// mutation surfaces the error via notifications
}
};
return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconClockHour4
size={18}
stroke={1.5}
style={{
flexShrink: 0,
color: "var(--mantine-color-orange-7)",
}}
/>
<Text size="sm">
<Trans
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
values={{ time: expiresTimeAgo }}
/>
</Text>
</Group>
{canEdit && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconClockHour4 size={16} />}
onClick={handleMakePermanent}
loading={toggleTemporary.isPending}
>
{t("Make permanent")}
</Button>
)}
</Group>
</Paper>
);
}

View File

@@ -32,7 +32,7 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
// Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
slugId: restoredPage.slugId,
// Create the tree node data with hasChildren from backend. Routed
// through the canonical mapper so the field copy stays in lockstep with
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
// page is made permanent), so the mapper carries that null through and
// the node correctly shows no clock marker.
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false,
children: [],
};
});
// Determine the parent and index
const parentId = restoredPage.parentPageId || null;
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
slugId: data.slugId,
spaceId: data.spaceId,
title: data.title,
// Carry the death-timer deadline so a note created as temporary keeps its
// sidebar clock marker when the tree is rebuilt from this cached entry
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
// node's marker with `undefined`, hiding it until a reload.
temporaryExpiresAt: data.temporaryExpiresAt,
};
let queryKey: QueryKey = null;

View File

@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconClockHour4,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -30,9 +31,13 @@ import {
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css";
@@ -65,6 +70,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!node.temporaryExpiresAt;
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -84,6 +91,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
}
};
const handleToggleTemporary = async () => {
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: node.id,
temporary: next,
});
// Reflect the new deadline locally so the icon/menu update immediately.
setData((prev) =>
treeModel.update(prev, node.id, {
temporaryExpiresAt: res.temporaryExpiresAt,
} as any),
);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
@@ -101,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
// Routed through the canonical mapper so the field copy stays in lockstep
// with buildTree. The server does NOT arm a death timer on duplicate (the
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
// carries that null through and the duplicated node correctly shows no
// clock marker — matching the server without a reload.
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
canEdit: true,
children: [],
};
});
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),
@@ -248,6 +274,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{isTemplate ? t("Unset as template") : t("Make template")}
</Menu.Item>
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleTemporary();
}}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"

View File

@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconClockHour4,
IconFileDescription,
IconPlus,
IconPointFilled,
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
</Tooltip>
)}
{node.temporaryExpiresAt && (
<Tooltip
// Children ride along to trash with the note (recursive removePage).
label={t("Temporary note — moves to trash unless made permanent")}
withArrow
>
<IconClockHour4
size={14}
stroke={1.5}
// Same visual-only indicator pattern as the template icon, but
// orange to flag the impending death timer.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-orange-6)",
}}
aria-label={t("Temporary note")}
role="img"
/>
</Tooltip>
)}
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />

View File

@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import {
useCreatePageMutation,
@@ -22,7 +23,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
handleCreate: (parentId: string | null) => Promise<void>;
handleCreate: (
parentId: string | null,
opts?: { temporary?: boolean },
) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
@@ -119,9 +123,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
);
const handleCreate = useCallback(
async (parentId: string | null) => {
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
async (parentId: string | null, opts?: { temporary?: boolean }) => {
const payload: {
spaceId: string;
parentPageId?: string;
temporary?: boolean;
} = { spaceId };
if (parentId) payload.parentPageId = parentId;
// Ask the server to arm the death timer for a "temporary note".
if (opts?.temporary) payload.temporary = true;
let createdPage: IPage;
try {
@@ -130,16 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
throw new Error("Failed to create page");
}
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
// Route through the canonical mapper so the field copy (esp.
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
// optimistic insert) can't drift from buildTree. `name: ""` because a
// freshly created page is untitled; `hasChildren: false` because it has no
// children yet.
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [],
};
});
// Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling
@@ -162,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first.
setData((prev) => {
if (treeModel.find(prev, newNode.id)) return prev;
const existing = treeModel.find(prev, newNode.id);
if (existing) {
// The server `addTreeNode` broadcast won the race and already inserted
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
// a temporary note WITHOUT its clock marker until reload; patch it on
// from the authoritative create response so the marker shows now.
if (
newNode.temporaryExpiresAt &&
!(existing as SpaceTreeNode).temporaryExpiresAt
) {
return treeModel.update(prev, newNode.id, {
temporaryExpiresAt: newNode.temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
}
return prev;
}
return treeModel.insert(prev, parentId, newNode, lastIndex);
});

View File

@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
});
});
// handleCreate race-guard temporaryExpiresAt patch: when the server's
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
// updater runs, the updater must not re-insert. Two sub-branches:
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
// omitted it) while the authoritative create response DOES → patch the
// deadline on so the clock marker shows now, without a reload.
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
// `prev` by reference (a no-op write).
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
// Mirrors the setData updater in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: TN[],
parentId: string | null,
node: TN,
index: number,
): TN[] => {
const existing = treeModel.find(tree, node.id) as TN | null;
if (existing) {
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
return treeModel.update(tree, node.id, {
temporaryExpiresAt: node.temporaryExpiresAt,
});
}
return tree;
}
return treeModel.insert(tree, parentId, node, index);
};
const fixtureTN: TN[] = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
];
const deadline = "2026-07-01T00:00:00.000Z";
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
// Server broadcast won the race and inserted the node WITHOUT a deadline.
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
});
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
undefined,
);
// The authoritative create response carries the deadline.
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
const patched = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// A new reference (the patch wrote) and the node now has the deadline...
expect(patched).not.toBe(afterServer);
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
deadline,
);
// ...and still exactly one node (no duplicate re-insert).
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
});
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
const existingDeadline = deadline;
// The node already exists WITH a deadline (the broadcast carried it).
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
temporaryExpiresAt: existingDeadline,
});
// The create response carries a DIFFERENT deadline; the guard must ignore it.
const created: TN = {
id: "new",
name: "",
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
};
const after = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// prev returned by reference (no write) and the original deadline is kept.
expect(after).toBe(afterServer);
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
existingDeadline,
);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
@@ -752,6 +847,27 @@ describe("treeModel.placeByPosition", () => {
});
expect(t.map((n) => n.id)).toEqual(["r1", "child", "r2", "rp"]);
});
it("returns same reference (no-op) when the destination parent is inside the source's own subtree (#206 ui-state-races-1)", () => {
// Moving `a` under its own descendant `b` is a cycle. Without the guard,
// remove(a) drops b too and insertByPosition can't re-place a -> the whole
// subtree silently vanishes. The guard refuses the move (same reference).
const cyclic: P[] = [
{
id: "a",
name: "A",
position: "a0",
children: [{ id: "b", name: "B", position: "a1" }],
},
];
const t = treeModel.placeByPosition(cyclic, "a", {
parentId: "b",
position: "a5",
});
expect(t).toBe(cyclic);
expect(treeModel.find(t, "a")).not.toBeNull();
expect(treeModel.find(t, "b")).not.toBeNull();
});
});
describe("treeModel.move", () => {

View File

@@ -294,6 +294,20 @@ export const treeModel = {
const source = treeModel.find(tree, sourceId);
if (!source) return tree;
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
// Cycle guard, mirroring `move`'s `isDescendant` check (#206 ui-state-races-1).
// If the destination parent is INSIDE the moved node's own subtree (reachable
// when server-authoritative move events arrive out of order — e.g. X moved
// under Y, then Y under X, but on this receiver Y is still inside X), then
// `remove(sourceId)` would drop the future parent along with the whole subtree
// and `insertByPosition` could not find it again — the node and ALL its
// descendants would silently vanish. Refuse the move and return the same
// reference so callers can detect the no-op and reconcile (refetch) instead.
if (
to.parentId !== null &&
treeModel.isDescendant(tree, sourceId, to.parentId)
) {
return tree;
}
const removed = treeModel.remove(tree, sourceId);
// Reuse the same position-ordered insertion as `insertByPosition` by
// stamping the authoritative position onto the moved node first.

View File

@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
hasChildren: boolean;
canEdit?: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
children: SpaceTreeNode[];
};

View File

@@ -9,25 +9,45 @@ export function sortPositionKeys(keys: any[]) {
});
}
/**
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
* materialises a tree node from a page (buildTree, the optimistic insert in
* handleCreate, restore, duplicate) routes through here so the field copy —
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
* so restore (which the server nulls) stays permanent and a temporary create
* keeps its clock marker without a reload.
*/
export function pageToTreeNode(
page: IPage,
overrides?: Partial<SpaceTreeNode>,
): SpaceTreeNode {
return {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
...overrides,
};
}
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = [];
pages.forEach((page) => {
pageMap[page.id] = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
children: [],
};
pageMap[page.id] = pageToTreeNode(page);
});
// Defense-in-depth: a duplicate id in `pages` would push two references to the

View File

@@ -13,6 +13,10 @@ export interface IPage {
workspaceId: string;
isLocked: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
// Create-only input flag: ask the server to arm the timer on a new page.
temporary?: boolean;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { IShareAlias } from "@/features/share/types/share.types";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// The mutation + query hooks reach react-query/network; the availability probe
// hits the API. Stub them so the section renders in isolation and we can drive
// the exact branches (taken name -> hint, 409 -> reassign modal).
const setMutateAsync = vi.fn();
let currentAlias: IShareAlias | null = null;
let availabilityResult: {
valid: boolean;
available: boolean;
currentPageId: string | null;
} = { valid: true, available: true, currentPageId: null };
vi.mock("@/features/share/queries/share-query.ts", () => ({
useShareAliasForPageQuery: () => ({ data: currentAlias }),
useSetShareAliasMutation: () => ({
mutateAsync: setMutateAsync,
isPending: false,
}),
useRemoveShareAliasMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/features/share/services/share-service.ts", () => ({
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
}));
import ShareAliasSection from "./share-alias-section";
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
id: `alias-${alias}`,
workspaceId: "ws-1",
alias,
pageId,
creatorId: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
function renderSection(pageId = "page-Y") {
return render(
<MantineProvider>
<ShareAliasSection pageId={pageId} readOnly={false} />
</MantineProvider>,
);
}
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
beforeEach(() => {
setMutateAsync.mockReset();
currentAlias = null;
availabilityResult = { valid: true, available: true, currentPageId: null };
});
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
// Page Y already owns "bee"; the user retypes a name owned by page X.
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
// The reassign hint replaces the old dead-end red error.
await waitFor(
() =>
expect(
screen.getByText(
"This address is in use. Saving will move it to this page.",
),
).toBeDefined(),
{ timeout: 2000 },
);
// The old terminal "already in use" error must NOT be shown.
expect(screen.queryByText("This address is already in use")).toBeNull();
// Save stays enabled so the confirm-reassign flow can run.
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
// The server rejects the un-confirmed save asking the client to confirm.
setMutateAsync.mockRejectedValueOnce({
status: 409,
response: {
status: 409,
data: {
code: "ALIAS_REASSIGN_REQUIRED",
currentPageId: "page-X",
currentPageTitle: "Alias Test Page X",
},
},
});
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
timeout: 2000,
});
fireEvent.click(saveBtn);
// First save sent WITHOUT confirmReassign.
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: false }),
),
);
// The "Move custom address?" confirm modal must appear (the path forward).
await waitFor(() =>
expect(screen.getByText("Move custom address?")).toBeDefined(),
);
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
// Confirming retries WITH confirmReassign: true.
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: true }),
),
);
});
});

View File

@@ -0,0 +1,274 @@
import {
ActionIcon,
Box,
Button,
Group,
Modal,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import {
useRemoveShareAliasMutation,
useSetShareAliasMutation,
useShareAliasForPageQuery,
} from "@/features/share/queries/share-query.ts";
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
interface ShareAliasSectionProps {
pageId: string;
readOnly: boolean;
}
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
function aliasPrefixLabel(): string {
const url = getAppUrl();
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
return `${host}/l/`;
}
export default function ShareAliasSection({
pageId,
readOnly,
}: ShareAliasSectionProps) {
const { t } = useTranslation();
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
const setAliasMutation = useSetShareAliasMutation();
const removeAliasMutation = useRemoveShareAliasMutation();
const [value, setValue] = useState("");
const [availability, setAvailability] = useState<{
valid: boolean;
available: boolean;
currentPageId: string | null;
} | null>(null);
const [reassign, setReassign] = useState<{
alias: string;
currentPageTitle: string | null;
} | null>(null);
// Seed the input from the page's current alias (if any).
useEffect(() => {
setValue(currentAlias?.alias ?? "");
}, [currentAlias?.alias, pageId]);
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
const isValid = isValidShareAlias(normalized);
const unchanged = currentAlias?.alias === normalized;
// Debounced availability probe (skips when invalid or unchanged).
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setAvailability(null);
if (!isValid || unchanged) return;
debounceRef.current && clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await checkShareAliasAvailability(normalized);
setAvailability({
valid: res.valid,
available: res.available,
currentPageId: res.currentPageId,
});
} catch {
setAvailability(null);
}
}, 400);
return () => {
debounceRef.current && clearTimeout(debounceRef.current);
};
}, [normalized, isValid, unchanged]);
const prettyLink = currentAlias?.alias
? `${getAppUrl()}/l/${currentAlias.alias}`
: null;
const handleSave = async (confirmReassign = false) => {
try {
await setAliasMutation.mutateAsync({
pageId,
alias: normalized,
confirmReassign,
});
setReassign(null);
} catch (error: any) {
// The address already points at another page: prompt to move it here.
if (error?.status === 409 || error?.response?.status === 409) {
const data = error?.response?.data;
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
setReassign({
alias: normalized,
currentPageTitle: data?.currentPageTitle ?? null,
});
}
}
}
};
const handleRemove = async () => {
if (!currentAlias?.id) return;
await removeAliasMutation.mutateAsync(currentAlias.id);
setValue("");
};
const showInvalid = normalized.length > 0 && !isValid;
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
// the "Move custom address?" confirm modal that retargets the address here.
// So surface it as an informational hint (not a terminal red error) and keep
// Save enabled, instead of looking like the address is unusable.
const reassignable =
isValid && !unchanged && !!availability && !availability.available;
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
const prefixLabel = aliasPrefixLabel();
const prefixRef = useRef<HTMLDivElement>(null);
const [prefixWidth, setPrefixWidth] = useState(0);
// Measure the real rendered width of the prefix so the slug input sits flush
// next to it, instead of after an over-estimated character-counted gap.
useLayoutEffect(() => {
if (prefixRef.current) {
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
}
}, [prefixLabel]);
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={6}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
{prettyLink && (
<Group my="xs" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={prettyLink}
readOnly
rightSection={<CopyTextButton text={prettyLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={prettyLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
)}
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
// Show the canonical form once the user pauses so what they type maps
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Box
ref={prefixRef}
style={{
display: "flex",
alignItems: "center",
width: "100%",
height: "100%",
paddingInline: "var(--mantine-spacing-xs)",
whiteSpace: "nowrap",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)",
borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)",
}}
>
{prefixLabel}
</Box>
}
leftSectionWidth={prefixWidth || undefined}
placeholder={t("my-page")}
disabled={readOnly}
error={
showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens")
: undefined
}
description={
reassignable
? t("This address is in use. Saving will move it to this page.")
: undefined
}
/>
<Group mt="sm" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}
loading={setAliasMutation.isPending}
disabled={readOnly || !isValid || unchanged}
>
{t("Save")}
</Button>
{currentAlias?.id && (
<Button
size="compact-sm"
variant="default"
color="red"
onClick={handleRemove}
loading={removeAliasMutation.isPending}
disabled={readOnly}
>
{t("Remove")}
</Button>
)}
</Group>
<Modal
opened={!!reassign}
onClose={() => setReassign(null)}
title={t("Move custom address?")}
centered
size="sm"
>
<Text size="sm">
{reassign?.currentPageTitle
? t(
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
{
alias: reassign?.alias,
title: reassign?.currentPageTitle,
},
)
: t(
'The address "{{alias}}" is already in use. Move it to this page?',
{ alias: reassign?.alias },
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setReassign(null)}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={() => handleSave(true)}
loading={setAliasMutation.isPending}
>
{t("Move here")}
</Button>
</Group>
</Modal>
</>
);
}

View File

@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
disabled={readOnly}
/>
</Group>
{pageId && (
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
)}
</>
)}
</>

View File

@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
IShareAlias,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -20,11 +22,14 @@ import {
import {
createShare,
deleteShare,
getShareAliasForPage,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
removeShareAlias,
setShareAlias,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
});
}
export function useShareAliasForPageQuery(
pageId: string,
): UseQueryResult<IShareAlias | null, Error> {
return useQuery({
// The endpoint resolves to null when the page has no alias; normalize the
// absence so React Query never sees `undefined`.
queryKey: ["share-alias-for-page", pageId],
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
}
export function useSetShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IShareAlias, Error, ISetShareAlias>({
mutationFn: (data) => setShareAlias(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
// A 409 reassign-required is handled inline by the modal (it shows the
// "move address here?" confirmation), so don't surface a generic toast.
if (error?.["status"] === 409) return;
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to set custom address"),
color: "red",
});
},
});
}
export function useRemoveShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (aliasId) => removeShareAlias(aliasId),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to remove custom address"),
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {

View File

@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
IShare,
IShareAlias,
IShareAliasAvailability,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}
export async function getShareAliasForPage(
pageId: string,
): Promise<IShareAlias | null> {
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
pageId,
});
return req.data;
}
export async function setShareAlias(
data: ISetShareAlias,
): Promise<IShareAlias> {
const req = await api.post<IShareAlias>("/share-aliases/set", data);
return req.data;
}
export async function removeShareAlias(aliasId: string): Promise<void> {
await api.post("/share-aliases/remove", { aliasId });
}
export async function checkShareAliasAvailability(
alias: string,
): Promise<IShareAliasAvailability> {
const req = await api.post<IShareAliasAvailability>(
"/share-aliases/availability",
{ alias },
);
return req.data;
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
// Mirrors the server-side util so the modal's live feedback matches what the
// server will accept/store.
describe("normalizeShareAlias", () => {
it("lowercases, trims and maps separators to single hyphens", () => {
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
});
it("collapses repeated hyphens and trims edges", () => {
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
});
});
describe("isValidShareAlias", () => {
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
expect(isValidShareAlias("hello-world")).toBe(true);
expect(isValidShareAlias("a".repeat(60))).toBe(true);
});
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
expect(isValidShareAlias("a")).toBe(false);
expect(isValidShareAlias("-a")).toBe(false);
expect(isValidShareAlias("a--b")).toBe(false);
expect(isValidShareAlias("Hello")).toBe(false);
expect(isValidShareAlias("привет")).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Client copy of the vanity share-alias helpers. Kept in sync with the server
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
* separated, length 2..60.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
export function normalizeShareAlias(raw: string): string {
return (raw ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
}
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === "string" &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -75,6 +75,30 @@ export interface IShareInfoInput {
pageId: string;
}
// Vanity /l/:alias pointer.
export interface IShareAlias {
id: string;
workspaceId: string;
alias: string;
pageId: string | null;
creatorId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISetShareAlias {
pageId: string;
alias: string;
confirmReassign?: boolean;
}
export interface IShareAliasAvailability {
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;

View File

@@ -13,6 +13,7 @@ import {
IconEye,
IconEyeOff,
IconFileExport,
IconHourglass,
IconPlus,
IconSettings,
IconStar,
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
handleCreate(null);
}
function handleCreateTemporaryPage() {
handleCreate(null, { temporary: true });
}
return (
<>
<div className={classes.navbar}>
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label={t("Create page")}
<>
<Tooltip
label={t("Create page")}
withArrow
position="right"
>
<IconPlus />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label={t("Create page")}
>
<IconPlus />
</ActionIcon>
</Tooltip>
{/* Standalone second button: a "temporary note" auto-moves to
trash after the workspace lifetime unless made permanent. */}
<Tooltip
label={t("New temporary note")}
withArrow
position="right"
>
<ActionIcon
variant="default"
size={18}
onClick={handleCreateTemporaryPage}
aria-label={t("New temporary note")}
>
<IconHourglass />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
</Group>

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import { Button, Stack } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
// Space-overview quick actions: create a regular note or a temporary note
// (which auto-moves to Trash after the workspace lifetime) directly in the
// current space and open it. Mirrors the sidebar's create buttons but lives on
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
// page is optimistically inserted into the sidebar tree and navigated to.
export default function SpaceCreateNoteButtons() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// `handleCreate` is read unconditionally to keep hook order stable; it is
// only invoked after the permission guard below confirms a loaded space.
const { handleCreate } = useTreeMutation(space?.id ?? "");
// Which create action is in flight: drives the per-button spinner and the
// shared disabled state so a slow create round-trip cannot be double-fired.
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
// Render nothing until the space loads, or when the user cannot manage pages.
if (!space) return null;
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
return null;
}
const createNote = (temporary: boolean) => {
if (pending) return;
setPending(temporary ? "temporary" : "regular");
// handleCreate creates the page then navigates away (unmounting this
// component); the create mutation already shows a red notification on
// failure, so swallow the rejection and just clear the pending flag.
handleCreate(null, temporary ? { temporary: true } : undefined)
.catch(() => {})
.finally(() => setPending(null));
};
// Two full-width, vertically stacked buttons: a neutral regular note and an
// orange-tinted temporary note. Stacking full-width keeps the longer "New
// temporary note" label from clipping on narrow mobile widths.
return (
<Stack gap="sm">
<Button
size="md"
variant="light"
color="gray"
fullWidth
leftSection={<IconPlus size={18} />}
loading={pending === "regular"}
disabled={pending !== null}
onClick={() => createNote(false)}
>
{t("New note")}
</Button>
<Button
size="md"
variant="light"
color="orange"
fullWidth
leftSection={<IconHourglass size={18} />}
loading={pending === "temporary"}
disabled={pending !== null}
onClick={() => createNote(true)}
>
{t("New temporary note")}
</Button>
</Stack>
);
}

View File

@@ -183,6 +183,34 @@ describe("applyMoveTreeNode", () => {
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
// applied yet). An out-of-order "move a under b" event now arrives — b is a
// descendant of a, so re-parenting would make placeByPosition remove a (and
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
// and b silently vanished; now the reducer leaves the tree untouched.
const tree: SpaceTreeNode[] = [
node("a", {
position: "a0",
hasChildren: true,
children: [node("b", { position: "a1", parentPageId: "a" })],
}),
];
const next = applyMoveTreeNode(tree, {
id: "a",
parentId: "b",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// No silent data loss: both nodes survive.
expect(treeModel.find(next, "a")).not.toBeNull();
expect(treeModel.find(next, "b")).not.toBeNull();
// The cyclic move is refused as a no-op (same reference) pending reconcile.
expect(next).toBe(tree);
});
});
describe("applyDeleteTreeNode", () => {
@@ -295,4 +323,18 @@ describe("applyAddTreeNode", () => {
"child",
]);
});
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
// A note created as temporary broadcasts addTreeNode with the death-timer
// deadline in its payload; the receiver's inserted node must keep it so
// space-tree-row renders the orange clock marker immediately.
const tree = roots();
const expiresAt = "2026-06-27T21:00:00.000Z";
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
});
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});

View File

@@ -76,6 +76,19 @@ export function applyMoveTreeNode(
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = payload.parentId as string | null;
// Cyclic / out-of-order move guard (#206 ui-state-races-1): if the
// authoritative new parent is currently INSIDE the moved node's own subtree on
// this client (e.g. server moved X under Y then Y under X and the events
// arrived such that Y is still nested in X here), re-parenting is impossible to
// represent locally. `placeByPosition` returns `prev` for this, but the
// `placed === prev` fallback below would then `remove` the source — dropping
// the node AND every descendant (incl. the would-be parent) silently. Leave the
// tree untouched instead; a later corrective event or a reconnect refetch
// reconciles it. Never delete a subtree we cannot safely re-place.
if (newParentId && treeModel.isDescendant(prev, payload.id, newParentId)) {
return prev;
}
// Place the node by its fractional `position` among the new siblings — NOT by
// the sender's absolute `index` (the sender computed that against its own
// loaded set, which differs from this receiver's). Using the position keeps

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