Compare commits

...

92 Commits

Author SHA1 Message Date
claude_code
2a32077a42 docs(qa): point TC-DICT-12 unit spec to Gitea issue #139
The backlog file docs/backlog/qa-plan-unit-test-candidates.md was moved
into Gitea issue #139 and removed, so repoint the only reference to it.

Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:57:49 +03:00
claude_code
3b790852b3 docs(qa): drop reference to the scratch gap-audit file
The standalone gap-audit doc was a working artifact (never part of this
PR branch) and has been removed; all its cases now live in Section V, so
the "full rationale in docs/qa-plan-gaps-pr136.md" pointer is dropped to
avoid referencing a deleted file.

Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:53:24 +03:00
claude_code
1f5f2b60a8 docs(qa): restore the 8 cases trimmed from the first Section V pass
The first pass dropped 8 gap-audit findings "to keep it tight" — but those
ARE forgotten cases, so they belong in the plan. Add them with full context
(scenario → expected, file:line, defect caught):

- TC-DICT-12  encodeWavPcm16 WAV header/clipping (unit)
- TC-EMBED-05 getEmbedUrlAndProvider 11-provider URL parsing (unit+manual)
- TC-LINK-03  sanitizeUrl/isInternalFileUrl XSS gate (unit+manual, security)
- TC-SPACE-12 space slug @IsAlphanumeric rejects hyphen/underscore/unicode [BUG?]
- TC-ATT-DEDUP-01 diagram attachmentId overwrite authorization
- TC-STOR-DIV-01  local vs S3 missing-file behavior divergence
- TC-LIMIT-QUOTA-01 no per-workspace storage quota (verify-only)
- TC-CMT-09  realtime commentCreated appends only to last loaded page

Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:48:54 +03:00
claude_code
c23ca101f1 docs(qa): add forgotten-case pass (Section V) to the manual QA plan
Append Section V — ~75 additional manual/integration cases surfaced by a
code-grounded gap audit (8 read-only zone audits) of this plan, and correct
two now-stale cases:
- TC-TRASH-01: no confirm dialog / "30-day note" anymore — delete is
  immediate with an 8s Undo toast (page-query.ts:132-144).
- TC-SPACE-03: server slugExists does not exclude self (bug to verify),
  see new TC-SPACE-11.

New cases cover the fork's recently shipped, uncovered behavior (AI-chat
message queue / stopped-notice / partial-answer persistence, streaming
dictation via Silero VAD, trash undo-toast, MCP write-only headers) and
code-grounded server branches (notification CASL count leak, 3s
restriction-cache realtime leak, MovePageDto bound vs fractional-index
keys, to_tsquery 500, import zip-bomb / HTML-XSS, attachment download
authZ). Cases tagged [BUG?] double as candidate defects. Full rationale in
docs/qa-plan-gaps-pr136.md.

Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:26:18 +03:00
claude code agent 227
c00e270756 docs: add manual QA test plan
Add docs/manual-qa-test-plan.md — the structured manual test plan used for the
full-product QA pass against develop: ~190 cases across auth, spaces, pages/tree,
editor & blocks, media/embeds, comments, search, notifications, AI chat &
dictation, public sharing, permission matrix, cross-feature interactions, and a
cross-cutting UI/consistency sweep. Intended as a reusable manual-QA checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:09:28 +03:00
claude_code
f6a4df1b08 fix(editor): stop title input losing characters while typing
The fork's server-authoritative WS redesign rebroadcasts PAGE_UPDATED
(updateOne) title changes to the whole space room including the author.
The author's own client applied that echo to the ["pages", slugId] cache,
which feeds the title prop; the setContent effect then overwrote the
in-progress title with the lagging echo, dropping just-typed characters
and jumping the cursor.

Guard the setContent effect so it skips while the title editor is focused
(and when destroyed): external/echo title updates are applied only when
the field is not being actively edited. Page navigation is unaffected
because TitleEditor remounts per page (key={page.id}) and seeds content
at creation.
2026-06-22 19:20:46 +03:00
claude_code
e423c35676 feat(ai-chat): queue messages typed while the agent is streaming
Previously a message composed while the AI agent was streaming a reply was
silently dropped (the composer early-returned on isStreaming). Now such
messages are queued FIFO and sent automatically once the current turn
finishes cleanly.

- chat-input: submit() enqueues while streaming (via new onQueue prop) and
  sends otherwise; during streaming show a queue Send button (when text is
  present) alongside the Stop button; the textarea stays usable.
- chat-thread: per-conversation queue in local state (mirrored in a ref);
  flush the next message in onFinish ONLY on a clean finish - ai@6 useChat
  fires onFinish from a finally on Stop/disconnect/error too, where the queue
  must be preserved. Pending messages render as removable chips above the
  composer. Queue is cleared on chat switch (parent remount) and survives
  in-place new-chat id adoption.
- queue-helpers: pure FIFO helpers (enqueue/dequeue/removeQueuedById) + tests.
- i18n: add en-US/ru-RU keys (Queue message, Remove queued message,
  Send when the agent finishes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:53:31 +03:00
claude_code
e598394f46 docs(agents): translate task lifecycle section to English
Update the task lifecycle documentation from Russian to English to improve
readability for English‑speaking contributors and ensure consistency across
the repository.
2026-06-22 18:53:31 +03:00
claude_code
8f01a01122 fix(dictation): start streaming dictation on the first click
The streaming mic button only began recording on the SECOND click. The VAD
library creates its AudioContext inside vad.start() and never resumes it; on the
first click the lazy model load (import + MicVAD.new) ran first, so the context
was created after the user-gesture window expired and started suspended — the
audio worklet never ran, so nothing happened. The second click was fast (model
cached) so the context landed inside the gesture and worked.

Create and resume our own AudioContext synchronously at the top of start()
(inside the click gesture, before the model load) and inject it into MicVAD,
which then does not take ownership of it; it is reused across start/stop and
closed only on unmount. Add a "loading" status so the first click is shown as a
spinner (disabled) while the model loads, which also blocks a confusing second
click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:42:44 +03:00
claude_code
14e26aab70 fix(ai-chat): copy/export the full chat history
The "copy chat" button serialized `messageRows` (persisted rows loaded via
`useAiChatMessagesQuery`), which were incomplete in two ways, so the exported
Markdown dropped messages (e.g. "Messages: 2" for a multi-turn chat).

- Exhaust pagination: `useAiChatMessagesQuery` is a useInfiniteQuery that only
  ever loaded the first page (server page size 50, oldest-first), silently
  truncating longer chats. Add an effect that calls `fetchNextPage()` until
  `hasNextPage` is false. Guard on `isFetchNextPageError` so a failed page fetch
  does not loop on the app's global `retry: false`.
- Re-sync after each turn: `onTurnFinished` invalidated only the chat-list
  query, never the per-chat messages query, so `messageRows` went stale during
  a live session. Also invalidate `AI_CHAT_MESSAGES_RQ_KEY(activeChatId)` so the
  export and token counters reflect the just-finished turn.
- Avoid tearing down the live thread: a render-phase latch (`historyLoadedKeyRef`)
  keeps the history loader gating the FIRST mount only, so the post-turn
  background refetch (which can transiently flip `hasNextPage` for a chat whose
  message count is an exact multiple of the page size) no longer unmounts the
  open thread and loses its in-progress useChat state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:17:06 +03:00
claude_code
44fa11e6eb fix(server,mcp): repair createPage import and sidebar subpages lookup
createPage always failed with "generateJSON can only be used in a Node
environment". Root cause: the MCP module (packages/mcp/.../collaboration.ts)
sets `global.window = dom.window` (jsdom) at load time and is imported
in-process by the server's AI-chat tools, leaking a global `window` into the
Node process. The server's self-contained ProseMirror helpers guarded with
`if (typeof window !== 'undefined') throw`, which then became a false positive
and broke POST /pages/import (the endpoint createPage calls).

- server: drop the vestigial `typeof window` guard in generateJSON.ts and
  generateHTML.ts; both helpers create their own happy-dom Window and never
  read the global one. Replace it with an explanatory comment.
- mcp: in DocmostClient.getPage, pass the resolved UUID (resultData.id) to
  listSidebarPages instead of the original pageId, which may be a slugId and
  triggered a Postgres "invalid input syntax for type uuid" (and a silent
  empty subpages list).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:06:15 +03:00
claude_code
373c56c0d3 fix(dictation): cut on ~1.5s silence instead of 0.64s
Streaming dictation sends one transcription request per ended speech segment.
With redemptionMs=640 the VAD cut on every ~0.64s gap, so normal halting speech
fragmented into many segments and flooded /ai-chat/transcribe — tripping the
per-user rate limit even at modest real usage.

Raise redemptionMs to 1500 so a cut only happens on a real sentence/thought
pause (~the "couple seconds" the feature was meant to use). Request count now
tracks actual pauses rather than inter-word gaps; the server throttle is left
unchanged (the earlier limit bump was treating the symptom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:04:35 +03:00
claude_code
6a85680a7d feat(ai-chat): clarify Authorization header field in MCP server form
Add a description to the "Authorization header" input in the external
MCP server form explaining that the entered value is sent verbatim as
the Authorization HTTP header value (e.g. "Bearer <token>" or
"Basic <base64>"), since there is no implicit Bearer prefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:45:29 +03:00
claude_code
39ce47a11f feat(client): remove Tavily preset button from MCP server form
Drop the "Use Tavily preset" quick-fill button from the Add server form
and its now-dead supporting code:
- remove the preset JSX block, applyTavilyPreset handler and TAVILY_PRESET
  constant in ai-mcp-server-form.tsx
- drop the now-unused McpTransport import
- remove the unused "Use Tavily preset" i18n key from en-US translations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:41:35 +03:00
claude_code
e826d7a008 Merge branch 'feat/dictation-silence-vad' into develop
Ship the plain onnxruntime-web wasm variant alongside JSEP so production builds
can load the VAD backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:40:45 +03:00
claude_code
0d2bff07ce fix(dictation): also ship the plain onnxruntime-web wasm variant
The production (rolldown) build resolves onnxruntime-web to the plain wasm
backend and fetched /vad/ort-wasm-simd-threaded.mjs, which we did not ship —
we only copied the JSEP variant that the Vite dev build uses. That 404'd into
the SPA fallback, reproducing "no available backend found / Failed to fetch
dynamically imported module" in production.

Copy BOTH the JSEP and the plain ort-wasm-simd-threaded.{mjs,wasm} into
public/vad/, so the runtime fetch finds a real file regardless of which build
the bundler picked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:40:45 +03:00
claude_code
6b09c43344 Merge branch 'feat/dictation-silence-vad' into develop
Self-host Silero VAD / onnxruntime-web assets under /vad/ so streaming
dictation loads its wasm backend (fixes the 'text/html' MIME runtime error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:19:17 +03:00
claude_code
7093f184b2 fix(dictation): self-host Silero VAD / onnxruntime-web assets
Streaming dictation failed at runtime with "no available backend found /
'text/html' is not a valid JavaScript MIME type": @ricky0123/vad-web 0.0.30
defaults baseAssetPath/onnxWASMBasePath to "./" (relative to the page URL),
so the worklet, Silero model and ORT wasm/mjs were requested against the SPA
catch-all and came back as index.html.

Serve them from a fixed /vad/ instead:
- scripts/copy-vad-assets.mjs copies the 4 runtime assets (vad worklet,
  silero_vad_v5.onnx, ort-wasm-simd-threaded.jsep.{mjs,wasm}) from node_modules
  into apps/client/public/vad/ (gitignored — the ORT wasm is ~26 MB)
- client dev/build scripts run the copy first so the assets are always present
- useStreamingDictation points both path constants at "/vad/"

Verified: dev server serves all four under /vad/ with HTTP 200 and correct
Content-Type (js/wasm, never text/html); tsc clean. Prod (Docker) build runs
the copy step, so dist/vad/* ships in the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:19:11 +03:00
claude_code
7bcb5ffcca Merge branch 'feat/trash-undo-toast' into develop 2026-06-22 17:08:21 +03:00
claude_code
2bd75edacc feat(page): replace move-to-trash confirm dialog with undo toast
Soft-deleting a page no longer opens a "Move this page to trash?"
confirmation modal. The page is moved to trash immediately and the
"Page moved to trash" toast now exposes an inline Undo action that
restores the page via the existing restore flow.

- Add move-to-trash-notification.tsx helper that builds the toast body
  (status text + Undo button) as a ReactNode, so it can be used from the
  non-TSX page-query module.
- useRemovePageMutation: show the toast with a stable id, 8s autoClose,
  and an Undo handler that hides the toast and triggers restore.
- space-tree-node-menu / page-header-menu: call handleDelete directly and
  drop the now-unused useDeletePageModal usage. Permanent delete keeps its
  confirmation modal.
- useRestorePageMutation: read the live tree from the jotai store at
  execution time and insert via a functional updater, so Undo restores
  child pages correctly even after the originating component unmounted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:07:37 +03:00
claude_code
c114806382 Merge branch 'feat/dictation-silence-vad' into develop
Streaming dictation via silence cut (Silero VAD), reusing the existing
/ai-chat/transcribe endpoint. Batch dictation kept as fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:52:13 +03:00
claude_code
4f0da42d88 feat(dictation): streaming STT via silence cut (Silero VAD)
Add a lightweight "streaming" dictation mode as a simpler alternative to the
realtime-websocket path: detect speech with Silero VAD (@ricky0123/vad-web),
cut each segment on a pause and POST it to the existing /ai-chat/transcribe
endpoint, so text appears progressively. No server changes.

- new useStreamingDictation hook (same API as useDictation), lazy-loads VAD,
  in-order seq emission, session-epoch guard against stop->start races
- new encodeWavPcm16 util (Float32 -> mono PCM16 WAV, accepted by the server)
- MicButton gains a `streaming` prop; enabled in the editor toolbar and chat
- VAD tuning: redemptionMs 640 / preSpeechPadMs 320 / minSpeechMs 96
- batch dictation kept as the fallback (streaming=false)
- deps: @ricky0123/vad-web@0.0.30, onnxruntime-web@1.27.0

Note: VAD assets load from the library CDN by default; for self-hosted/offline
set VAD_BASE_ASSET_PATH/VAD_ONNX_WASM_BASE_PATH and copy assets to public/vad/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:52:05 +03:00
claude_code
7ce1a24f82 feat(ai-chat): show creation time and origin document in chat list
Each chat row in the AI-chat history now shows a dimmed second line with
how long ago the chat was created and the document it was created in
("N ago / <document>", or "No document" when started outside a page).

Server:
- New migration: nullable ai_chats.page_id (FK pages.id, ON DELETE SET NULL).
- Capture the origin page at chat creation from the client-supplied openPage,
  but validate it first: it must be a real page in the same workspace that the
  user may read (PageAccessService.validateCanView), else null. This keeps the
  "openPage.id is attacker-controllable but harmless" invariant - preventing a
  cross-workspace/cross-space page-title leak and a post-hijack FK crash.
- findByCreator left-joins pages (scoped by workspace, defense-in-depth) and
  returns pageTitle.

Client:
- IAiChat gains pageId/pageTitle; ConversationList renders a ChatMetaLine
  (useTimeAgo + origin document) as a dimmed second line.
- Add i18n key "No document" (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:16:26 +03:00
claude_code
89ac8fa37b style(dictation): match mic button halo radius to button shape
Update the halo's border-radius from a fixed 50% circle to the theme's default radius variable. This ensures the red pulse follows the button's rounded‑square outline instead of appearing circular.
2026-06-22 16:01:53 +03:00
claude_code
cbd980f6e4 fix(ai-chat): handle browser fetch failures as lost connection
Add detection for browser fetch‑failure messages (e.g., “Failed to fetch”, “Load failed”, “NetworkError”) and return a clear error indicating the streaming connection to the server was lost. Refine the connection‑error regex to avoid overlapping patterns while preserving provider‑side error handling.
2026-06-22 03:49:36 +03:00
claude_code
3c3fb0816a Merge fix/embed-indexer-failfast-auth: fail-fast embeddings reindex on fatal provider errors 2026-06-22 03:46:24 +03:00
claude_code
f543e79c3e fix(ai-embedding): abort bulk reindex on fatal provider errors
reindexWorkspace isolated every per-page failure, so an invalid/missing
API key (401 "User not found") made all pages fail identically while the
batch kept issuing hundreds of doomed requests against the provider.

Add isFatalProviderError() (401/403 auth, 402 billing) and abort the
whole batch on such errors; 429 rate-limit and embedding timeouts stay
per-page isolated. Adds unit tests for the predicate and a regression
test for the abort/iterate control flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:46:17 +03:00
claude_code
1c9785997a fix(ai-chat): surface dropped-stream errors clearly + log client disconnects
A mid-stream connection drop showed a generic "Something went wrong / Load
failed" banner and left no server-side trace.

- error-message: classify the browsers' own fetch-failure strings ("Load
  failed" on WebKit, "Failed to fetch" on Chrome, "NetworkError" on Firefox)
  as a lost connection, so the banner names the cause instead of the generic
  heading.
- ai-chat.controller: log a warning in the request close handler when the
  client disconnects before completion, so a drop that reaches the app (e.g. a
  reverse proxy cutting the SSE) is visible in the server logs before the abort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:44:25 +03:00
claude_code
b60190ff1e style(dashboard): make home page container responsive padding
Adjust AppShell padding to responsive values and add a CSS module that
handles container top and side padding for different breakpoints,
replacing the previous fixed `pt="xl"` usage.
2026-06-22 03:27:40 +03:00
claude_code
2846830bf7 style(home): use neutral gray "New note" button instead of blue primary
The button rendered with Mantine's default blue primary, which clashes
with the app's neutral/dark accent design. Switch both the single-space
and the space-picker variants to variant="light" color="gray".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:17:02 +03:00
claude_code
f218852184 Merge branch 'fix/home-new-note-visibility' into develop
fix(home): make the "New note" button visible (role-based writability)
2026-06-22 02:49:17 +03:00
claude_code
93d8c1f775 fix(home): show "New note" button by resolving writability from role
The button never rendered: it filtered spaces to writable ones via a
CASL ability built from membership.permissions, but the /spaces list
endpoint returns membership.role only (permissions come from
/spaces/info). The empty ability hid the button for everyone.

Resolve writability from membership.role instead, mirroring the server
space-ability mapping (ADMIN and WRITER can manage pages, READER is
read-only). Drop the now-unused CASL imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:49:17 +03:00
claude_code
ef74058301 style(editor): align byline dictation mic with the info icon
The byline mic rendered blue and with a smaller (16px) glyph next to the
gray 20px info icon, so it looked misaligned with an uneven gap. Add
optional color/iconSize props to MicButton (forwarded through
DictationGroup) and render the byline mic gray at 20px, wrapping it and
the info icon in a tight nowrap group so they read as a snug, aligned
pair. The AI chat mic is unchanged (passes neither prop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:48:50 +03:00
claude_code
3a3d22ac55 refactor(editor): remove duplicate dictation mic from the fixed toolbar
The dictation button now lives in the always-rendered page byline, so the
copy in the fixed toolbar was redundant: with the toolbar enabled the mic
showed up twice. Drop the DictationGroup render, its isDictationEnabled
guard, and the unused import from the toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:33:07 +03:00
claude_code
4281a370b1 Merge branch 'feat/stt-language' into develop
Add dictation language selection to STT settings.
2026-06-22 02:29:13 +03:00
claude_code
a16ef2346f feat(ai/stt): add dictation language selection to STT settings
Add a per-workspace `sttLanguage` setting (ISO-639-1 hint; empty =
auto-detect) and a searchable language picker in the Voice / STT settings
card. The hint is forwarded to the transcription endpoint:
- multipart path via the AI SDK `providerOptions.openai.language`
- JSON (OpenRouter) path via a top-level `language` body field
only when non-empty, so auto-detect behaves exactly as before.

Threaded through the whole stack: ai.types, update DTO, AiSettingsService
(resolve/getMasked/update), the workspace.repo SQL allowlist, the client
ai-settings service types, and the provider-settings form. Adds en-US
source keys and ru-RU translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:29:07 +03:00
claude_code
01d7c2b465 Merge branch 'feature/home-new-note' into develop
feat(home): prominent "New note" button on the dashboard
2026-06-22 02:19:07 +03:00
claude_code
6d0ee6c61f feat(home): add prominent "New note" button to dashboard
Add a big "New note" action to the Home screen that creates a new page
and opens it. Since the home screen has no active space, the target
space is resolved from the user's writable spaces (CASL Manage/Page
gate, mirroring the space sidebar): created directly when there is one
writable space, picked from a dropdown when there are several, hidden
when there are none. Menu items are disabled while a create is in
flight to avoid duplicate pages.

- New component features/home/components/new-note-button.tsx
- Render it at the top of pages/dashboard/home.tsx (above the carousel)
- Add i18n keys "New note" / "Create in space" to en-US and ru-RU

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:18:31 +03:00
claude_code
6347708605 Merge branch 'feature/byline-dictation' into develop
Add dictation mic button to the page byline next to the info icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:15:01 +03:00
claude_code
fae8418fa2 feat(editor): add dictation mic to page byline next to the info icon
The editor dictation button previously lived only in the fixed toolbar,
which is hidden by default (gated by the per-user editorToolbar
preference), so dictation was effectively unavailable in the editor. Add
the same dictation control to the always-rendered page byline row, right
next to the Details "i" icon, so voice input stays reachable.

It is shown only when workspace dictation is enabled, the page is
editable, and the editor is in edit mode. Reuses the existing
DictationGroup/MicButton and its caret-insertion logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:14:46 +03:00
claude_code
8f994460ad refactor(comments): tidy comments panel header and quote layout
- Merge the comments side-panel header into the Open/Resolved tab row,
  then drop the now-redundant "Comments" title; the panel keeps its
  accessible name via the AppShell.Aside aria-label.
- Overlay the close (X) button on the right of the tab row and nudge it
  up 4px to align with the tab labels; the tab list stays full-width so
  its bottom border line is preserved. The toc/details tabs keep their
  existing shared header and scroll area unchanged.
- Quote block (.textSelection): increase top margin (2px -> 8px) so it
  no longer sticks to the timestamp when it is the first block, and add
  margin-left: 6px so the quote's left bar lines up with the comment
  body text left edge.
2026-06-22 02:12:13 +03:00
claude_code
c83343d3a3 refactor(comments): move panel title and close button into the tabs row
Merge the comments side-panel header into the Open/Resolved tab row to
save vertical space: title on the left, tabs centered, close button on
the right.

- comment-list-with-tabs: add optional `title`/`onClose` props; render
  the title and close button as absolutely-positioned overlays around a
  full-width centered Tabs.List. Keeping them outside Tabs.List preserves
  the tablist ARIA contract (only role="tab" children) while the tab
  list's full-width bottom border line is retained.
- aside: pass `title`/`onClose` to CommentListWithTabs for the comments
  tab and drop the shared header for that tab; the toc/details tabs keep
  their existing shared header and scroll area unchanged.
2026-06-22 00:37:53 +03:00
claude_code
4f035b8e19 feat(client): declutter space sidebar and global header
- Remove the large active-space name header in the space sidebar;
  the active space stays highlighted in the spaces grid below.
- Move "Space settings" into the user avatar (top) menu next to
  "Workspace settings"; it shows only while viewing a space and is
  detected via useMatch("/s/:spaceSlug/*").
- Make the brand logo non-selectable/non-draggable (user-select:none
  on .brand, draggable=false on the img).
- Remove the redundant "Home" button next to the logo (the logo
  already links to /home).
- Remove the version label under the Settings sidebar menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:50:30 +03:00
claude_code
0deded342d fix(dictation): drive the recording halo from mic level under reduced-motion
The live mic-level halo around the stop button was frozen at a constant
scale (1.15) whenever the OS "Reduce motion" setting was on, so it never
reacted to the voice while dictating. Make haloScale unconditional so it
always follows audioLevel (amplitude 0.9), and drop the now-unused
useReducedMotion import and reduceMotion local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:34:07 +03:00
claude_code
ebfb947ba2 style(comments): tighten aside panel spacing and widen it
- Widen the comments/aside panel from 350 to 420 (~20% wider)
- Remove double padding around the panel: AppShell.Aside p="md"->"sm"
  and inner Box p="md"->p={0}; reduce header-to-tabs gap mb="md"->"sm"
- Reduce empty space below the add-comment input (paddingBottom 25->10),
  align the avatar with the input box (marginTop 10->2) and re-anchor the
  send button (bottom 30->15)
- Pull the timestamp closer to the nickname via tighter line-height
  (lh 1.2 on the name, 1.1 on the "… ago" text)
2026-06-21 23:09:11 +03:00
claude_code
43f8c9ab99 Merge branch 'feat/ai-comments-inline-anchor' into develop
Make AI-created comments inline-only and reliably anchored: forbid
page-type comments for the agent, throw + roll back when a selection
cannot be anchored, and add robust text matching (normalization +
cross-text-node anchoring within a block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:07:01 +03:00
claude_code
03e2f444ae ix(ai-chat): keep typing dots bouncing under reduced-motion
The "Thinking…" indicator's bounce was fully disabled by the
prefers-reduced-motion rule (animation: none), leaving the dots
frozen for users with "Reduce motion" enabled. Drive the bounce
height with a --bounce custom property: -6px by default and a
smaller -3px under reduced-motion, so the indicator stays visibly
active everywhere instead of freezing.
2026-06-21 23:06:56 +03:00
claude_code
4201f0a313 feat(comments): make AI comments inline-only with robust anchoring
The in-app AI chat hardcoded type='page' and the shared createComment
swallowed anchoring failures silently, so agent comments never got a
text anchor/highlight.

- Forbid page-type comments for the agent: top-level comments are always
  inline and require an exact `selection`; replies inherit the parent
  anchor (stored as the historical `page` type).
- Throw and roll back the just-created comment when the selection cannot
  be anchored, instead of leaving an orphan unanchored comment.
- Add comment-anchor module: text normalization (smart quotes, dashes,
  nbsp, collapsed whitespace) and matching across adjacent text nodes
  within a block, so selections crossing inline-code/bold/link anchor.
- Update create_comment (MCP) and createComment (ai-chat) tool schemas
  and descriptions; add unit + mock-HTTP orchestration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:06:49 +03:00
claude_code
47c4e547e7 docs(agents): restrict git worktrees to the .claude folder
Add a rule to the "Реализация" section of AGENTS.md stating that git
worktrees may only be created inside the .claude directory
(e.g. .claude/worktrees/<name>); creating them anywhere else is forbidden.
2026-06-21 22:17:03 +03:00
claude_code
eb1e233d46 fix(ai-chat): keep the live thread on new-chat adoption; log stream errors
A brand-new chat's first turn streamed and finished successfully, but the
whole assistant response vanished from the UI. On finish the window adopts
the server-created chat id, which changed the <ChatThread> key and remounted
it — discarding the live useChat store (the full answer) and re-seeding from
not-yet-persisted history, so only the user message remained.

- chat-thread: pin the useChat store id to a per-mount value so adopting the
  chatId prop no longer recreates the store and wipes the live turn.
- ai-chat-window: derive the thread mount key via setState-during-render and
  move the live-thread marker in lockstep with the adopted id, so in-place
  adoption keeps the same mounted thread while real chat switches still
  remount and re-seed; gate the history loader to a freshly opened chat.
- cancel a pending adoption on New chat / explicit chat selection.
- log the raw stream error to the browser console for debugging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:14:32 +03:00
claude_code
69f385ccb7 docs(agents): note release tags must be pushed to the CI build remote
The back-merge alone does not fix the develop version: git describe names
a tag ref, and the :develop image is built on GitHub Actions, so the tag
must exist on the `github` remote. git push of a branch does not push
tags. Document the multi-remote (gitea + github) tag-push requirement and
a recovery checklist when develop still shows the previous version.
2026-06-21 21:48:21 +03:00
claude_code
ccbd3e1962 i18n(ai-chat): rename typing indicator to "Thinking…"
Replace the AI chat typing indicator text "AI is typing…" with
"Thinking…".

- typing-indicator.tsx: use t("Thinking…") instead of t("AI is typing…")
- en-US: drop the now-redundant "AI is typing…" key (the "Thinking…"
  key already existed and was unused)
- ru-RU: rename the key to "Thinking…" with value "Думаю…"
- update related comments in message-list.tsx and the test file
2026-06-21 21:35:29 +03:00
claude_code
18ef18fb6a docs(agents): document develop version lag and release back-merge step
The UI version comes from `git describe --tags`, which resolves the nearest
tag in the current commit's ancestry. Release tags are created on main's
merge commit, which is not in develop's history, so develop builds keep
reporting the previous tag (e.g. v0.91.0-NNN) until main is merged back.

Add step 7 (back-merge main -> develop) to the "Cutting a release"
checklist and a subsection explaining why develop lags and how to fix it.
2026-06-21 21:24:38 +03:00
claude_code
810228a3e2 Merge branch 'main' into develop 2026-06-21 21:22:25 +03:00
claude_code
9a9b61b9a3 feat(ai-chat): log aborted stream turns in onAbort
The onAbort terminal path persisted the partial turn but wrote nothing
to the log, so a turn killed by a client disconnect / proxy drop / stop()
was invisible in the logs (unlike onError and the controller catch, which
both log). Add a logger.warn with the chat id, completed step count and
partial-text length so an aborted turn is traceable.
2026-06-21 21:21:48 +03:00
claude_code
79c3c86b82 fix(ai-chat): show typing indicator while the agent thinks between tool calls
showTypingIndicator treated any tool part in the latest assistant message
as visible content, so the "AI is typing…" dots were suppressed for the
rest of the turn once the first tool call appeared. During the model's
"thinking" pauses after a completed tool call, the chat showed only static
tool cards and no activity.

Inspect the last part of the assistant message instead of any part: hide
the dots only while output is actively rendering (a non-empty streaming
text part, or a tool still in the "running" state — which shows its own
Loader). Finished/errored tools and empty trailing text now keep the dots
visible, so the indicator reappears while the model thinks between steps.

Add tests covering the post-tool thinking gap and the running-tool case.
2026-06-21 21:10:38 +03:00
claude_code
55625874c5 feat(dictation): show live mic level while recording
Add a pulsing halo behind the stop button that scales with the
microphone input level, giving real-time feedback that recording is
active and the mic is picking up sound.

- use-dictation: meter the captured MediaStream via AudioContext +
  AnalyserNode (analyser only, never connected to destination), compute
  a smoothed RMS audioLevel (0..1) in a requestAnimationFrame loop, and
  tear the meter down on every recording-end path (stop/cancel/auto-stop/
  unmount); meter failure is non-fatal to recording
- mic-button: render a translucent red halo whose scale follows
  audioLevel; honor prefers-reduced-motion with a static halo
- stop(): recover and release resources when no live recorder remains
- fix unhandled rejection from AudioContext.resume()
2026-06-21 21:04:22 +03:00
claude_code
71d908c6b5 docs(backlog): remove STT providers and async design doc
Delete the backlog markdown file that outlined additional STT providers and the future async transcription architecture, as the content is now superseded by newer implementation plans.
2026-06-21 20:58:08 +03:00
claude_code
d188c9e876 docs(backlog): add design for AI attribution of MCP-authored comments
Document Variant B for showing MCP-created comments (and pages) as AI
rather than as the service-account user, reusing the existing agent
provenance infrastructure (§15 C3).

- Root cause: MCP logs in via a plain service-account token, so
  provenance.actor stays 'user' and created_source defaults to 'user';
  the comment sidebar also renders no AI badge.
- B1 (backend): mark the MCP identity as agent via a new users.is_agent
  flag; jwt.strategy derives req.raw.actor from it (non-spoofable).
  Relax the provenance aiChatId type to string | null for external MCP.
- B2 (frontend): extend IComment with createdSource/aiChatId, extract a
  shared AiAgentBadge, render it in comment-list-item.
- Includes edge cases, tests, scope decisions, and acceptance criteria.
2026-06-21 20:58:02 +03:00
claude_code
59c2913d72 style(ai-chat): widen role cards to fill the chat window
Role cards in the new-chat empty state were capped at max-width 200px and
never grew, leaving large side gaps in a wide window. Make the cards flex
to fill each row (flex: 1 1 240px) and raise min/max width so they get
wider and use the available window width while still wrapping to ~2 columns
at the default window size.
2026-06-21 20:51:44 +03:00
claude_code
7171dfbdf0 fix(ai): classify AI provider error status in logs and UI
Provider auth failures were logged with the provider's opaque message only
(e.g. OpenRouter returns "401: User not found." for a bad/missing API key),
which reads like a missing wiki user rather than a credentials problem.

describeProviderError now prepends a clear, human-readable English label for
a small set of well-known HTTP statuses while keeping the original detail
(status + provider message + truncated response-body snippet):
  - 401/403 -> authentication failed (invalid or missing API key)
  - 402     -> insufficient credits or quota
  - 429     -> rate limit exceeded
Other statuses and status-less errors are formatted exactly as before. The
label is a static string and never contains the API key. Benefits every
caller (embedding processor, indexer, AI "Test endpoint" UI) at once.

Tests: switch the plain status+message case to a non-classified status (500);
add 401/403/402/429 cases; keep 502/503 as regression guards for the
unchanged path.
2026-06-21 19:55:45 +03:00
claude_code
4f8015b342 Merge branch 'develop' into test/coverage-refactor 2026-06-21 19:12:13 +03:00
claude_code
3d4ad664b3 test(refactor-tail): extract pure cores + cover collab/share/ai-chat/client gate
Batches 6-9: behaviour-preserving extractions of testable pure cores plus the
tests they unblock, and a fix for the broken client test environment.
Full suites green: server 113 suites / 1117 + 1 todo, client 30 files / 338.

client (R0 infra):
- vitest.setup.ts: in-memory localStorage/sessionStorage Storage stub wired via
  setupFiles. Unblocks menu-items.gating.test.ts (was 9 failing) -> client suite
  fully green. + menu-items.suggestions.test.ts (getSuggestionItems filter/sort).

share:
- extract buildShareMetaHtml (share-seo.util.ts) from the SEO controller; tests
  for reflected-XSS escaping in <title>/og/twitter meta, noindex, truncation;
  extractPageSlugId; updateAttachmentAttr; prepareContentForShare comment-strip
  (anonymous-viewer metadata-leak guard).

ai-chat (security extractions):
- selectAccessibleHits: CASL post-filter for semantic search (restricted page in
  an accessible space must NOT leak to the agent).
- validateResolvedAddresses: SSRF connect-time guard (block if ANY resolved
  address is private).
- resolveAudioFormat: mime whitelist (dead `?? 'webm'` fallback dropped, set
  unchanged). + mcp-servers toView header-leak guard, MCP tool namespacing.

collaboration (data-loss area):
- extract computeHistoryJob (pins the "agent delay MUST stay 0" invariant) and
  resolveSource. Integration: onAuthenticate read-only matrix (collab auth
  bypass), HistoryProcessor (contributor restore on save failure), onStoreDocument
  Approach-A boundary snapshot (human revision pinned before agent overwrite).

Reviewed (APPROVE WITH SUGGESTIONS): extractions behaviour-preserving, security
tests mutation-resistant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:10:27 +03:00
claude_code
cdcf3c0639 Merge branch 'refactor/ai-tools-spec-registry' into develop
Shared zod-agnostic tool-spec registry for the 14 identical AI tools across
the standalone MCP server and the in-app AI-SDK chat (keeps execute/auth and
the ~17 intentionally-divergent guardrail tools per-layer), folds in the
edit_page_text drift-bug fix, and formalizes the integration-test db factory.
2026-06-21 18:57:10 +03:00
claude_code
f3fa15e746 refactor(ai-chat): shared tool-spec registry for identical tools; formalize integration db factory
Implements two architecture follow-ups from the multi-aspect review.

1. Shared, zod-agnostic tool-spec registry (packages/mcp/src/tool-specs.ts)
   for the 14 AI tools whose name + schema + model-facing description are
   genuinely identical across the standalone MCP server and the in-app
   AI-SDK chat. Both layers consume it (registerShared in index.ts;
   sharedTool in ai-chat-tools.service.ts) and keep their own execute/auth.
   - Zod-agnostic builders (z) => ZodRawShape bridge the zod v3 (mcp) vs
     zod v4 (server) split; the registry imports no zod.
   - Folds in the documented edit_page_text drift-bug fix: the stale
     "strip-and-retry tolerated" claim is gone; canonical wording states a
     formatting-only change is refused into failed[].
   - Sibling-tool references in shared descriptions are transport-neutral so
     one description is correct for both snake_case (MCP) and camelCase
     (in-app) tool names.
   - Loader fail-fast guard for a stale @docmost/mcp build.
   - The ~17 intentionally-divergent tools (security guardrails, tuned UX)
     stay per-layer, untouched.
   - Rebuilt committed mcp artifacts (also regenerates a previously stale
     build/lib/docmost-schema.js to match its already-committed source).

2. Formalize apps/server/test/integration/db.ts as the canonical
   integration-test seed factory (module doc + a shortId helper); the
   hand-written minimal seeders are kept on purpose, decoupled from the
   app service-layer side effects.

Verified: server tsc + lint clean, mcp build clean; mcp unit tests 261 pass,
ai-chat-tools.service 16 pass, public-share-chat-tools 8 pass, ai-chat suite
224 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:57:00 +03:00
claude_code
0bbf94c154 feat(ai-chat): surface the real cause in the error banner
The AI chat error banner always showed a generic "Something went wrong"
with no reason. The server already forwards the provider cause into the
stream (e.g. "Cannot connect to API: read ECONNRESET"), but the client
hid it behind a static heading.

- describeChatError now returns { title, detail }: a short heading naming
  the cause category plus a one-line explanation.
- Add classifyProviderError: maps connection reset, timeout, rate limit,
  context-window overflow, quota and auth failures to clear categories;
  the 403/503 gating responses are preserved; unknown errors fall back to
  the verbatim provider text.
- Match HTTP status codes only as the leading token and textual signatures
  only against the message head (before "| response body:"), so a number
  or phrase in the response-body snippet never mislabels the cause.
- Use the new {title, detail} in all three banners: chat-thread,
  share-ai-widget and the persisted-error banner in message-item.
- Cover the classifier with 20 unit tests (categories + regressions).
2026-06-21 18:54:43 +03:00
claude_code
0cfc3c8f89 Merge branch 'develop' into test/coverage-batch1 2026-06-21 18:51:14 +03:00
claude_code
4df79aafd3 test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy
report. Full server suite: 100 suites / 1031 passed + 1 todo, green.

Authorization (privilege-escalation catches):
- workspace/space ability factories: exact can/cannot per (action,subject) —
  admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc.
- findHighestUserSpaceRole, isAdminActingOnOwner.
- WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target.
- SpaceMemberService.validateLastAdmin: never orphan a space without an admin.
- GroupService: default-group immutability, name uniqueness.

Access / data integrity:
- PageAccessService: restriction-vs-space-ability branches for view/edit/comment.
- TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts
  NO attachment write or ref-row delete on rejection; lookupWithAccessSet
  positional status mapping; listReferences drops private/cross-ws/deleted refs;
  syncPageTransclusions/References diff (no-op on unchanged content).
- SearchService.searchPage: query-mode scoping; leakage modes return empty
  before executing the query.
- CommentService: reply-to-reply guard, agent provenance, self-mention filter,
  no double-notify.

Pure helpers:
- prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID
  validation, removeMarkTypeFromDoc), collaboration.util (getPageId,
  isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement).

Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:40:07 +03:00
claude_code
0b2af34029 test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal,
behaviour-preserving extraction in file.utils.ts. All suites green:
server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86.

integrations (server):
- file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from
  extractZipInternal so the zip-slip/path-traversal guard is unit-testable;
  call site rerouted, behaviour identical (only a warn-message string merged).
- file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion
  cases (mutation-resistant: fails if containment loses the path.sep).
- import-formatter / import.utils / table-utils / export utils / import.service
  extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki
  formatting, table colspan widths (idempotent), slug/link rewriting.

client:
- safeRedirectPath: open-redirect guard, every reject branch independently.
- buildChatMarkdown (fence anti-breakout), label-colors, normalize-label,
  share tree build, page URL builders, notification time-grouping (fake clock).

packages:
- editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted
  values, orphan footnote extraction.
- mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits
  idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder.

Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:22:15 +03:00
claude_code
74e2b7ad7f Merge branch 'fix/ai-chat-role-cards-fit' into develop
Fit full role-card description text in the AI chat empty state and show a
generic "AI is typing…" indicator (role name kept only as the dimmed
interlocutor label).
2026-06-21 17:11:56 +03:00
claude_code
a86d0c7c3b fix(ai-chat): always show generic "AI is typing…" indicator
The typing indicator rendered "<role name> is typing…". Show a generic
"AI is typing…" instead and keep the role/identity name only in the
dimmed interlocutor label above the typing dots.

- typing line now always renders t("AI is typing…")
- add the "AI is typing…" key to en-US and ru-RU locales
- sync stale doc comments that referenced the old text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:11:21 +03:00
claude_code
569da822b6 fix(ai-chat): fit full role-card description text in new-chat empty state
The colored role cards in the AI chat empty state truncated their
admin-configured description with an ellipsis and could clip the top row
when the cards overflowed. Make the full text fit:

- drop the description lineClamp so the whole text renders
- add overflow-wrap: anywhere so long unbreakable tokens (URLs) wrap
- switch the cards container to align-content: flex-start so an
  overflowing top row stays reachable while scrolling (the parent
  Mantine Center still vertically centers the block when it fits)
- widen the card max-width 180px -> 200px for more text room

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:11:21 +03:00
claude_code
f8e8ada581 test(server): add behavioural unit tests for auth + common security helpers
Batch 1 of the test-strategy rollout. Fills the highest-value gaps where
existing specs were only `toBeDefined()` smoke tests or absent. Test-only,
no production source touched.

- token.service.behavior.spec.ts: verifyJwt type-mismatch rejection (confused
  deputy), generateAccessToken/generateCollabToken disabled-user -> Forbidden,
  agent `actor` claim only from signed provenance, correct expiry.
- auth.util.spec.ts: computeEmailSignature (stable HMAC, case-normalized),
  throwIfEmailNotVerified, validateSsoEnforcement, validateAllowedEmail;
  it.todo flags the unguarded `@`-less email TypeError.
- guards/setup.guard.spec.ts: cloud blocks setup, first-run allows, re-run on
  an initialised instance is forbidden (privilege escalation guard).
- security-headers.spec.ts: resolveFrameHeader clickjacking/CSP branches.
- utils.security.spec.ts: redactSensitiveUrl, extractBearerTokenFromHeader,
  parseRedisUrl, normalizePostgresUrl, diffAuditTrackedFields, isUserDisabled.

60 tests + 1 todo, all green. Reviewed for mutation resistance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:00:09 +03:00
claude_code
4720705155 Merge branch 'docs/review-followups' into develop
Review follow-ups (docs only):
- CHANGELOG [Unreleased]: post-0.93.0 share-AI cap lowered 300->100 (#62)
- backlog: track deferred AiChatService.stream integration coverage
2026-06-21 16:57:13 +03:00
claude_code
ce60498a90 docs: track post-0.93.0 share-AI cap change + deferred stream-coverage debt
Follow-ups from the multi-aspect review of the e5bc82c7..d4658d4c range.

- CHANGELOG: document under [Unreleased] that the default per-workspace
  hourly public-share assistant cap was lowered 300 -> 100 after the
  v0.93.0 tag (#62). v0.93.0 shipped 300, so existing deployments that
  never set SHARE_AI_WORKSPACE_MAX_PER_HOUR drop to 100 on upgrade.
- Recreate the still-open Section 3 (AiChatService.stream integration
  coverage) of the deleted feature-test-coverage-deferred.md as a focused
  backlog doc so the test debt stays tracked; Sections 1-2 are already
  closed by the integration harness (PR #115).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:54:56 +03:00
4a22cc1955 Merge pull request 'feat(ai-chat): role cards start the chat and show role identity' (#121) from feat/ai-chat-role-cards-ux into develop
Reviewed-on: #121
2026-06-21 16:28:51 +03:00
claude_code
b83a5d4597 feat(ai-chat): role cards start the chat and show role identity
Rework the new-chat role-card empty state:
- Remove the "Universal assistant" card; universal assistant is now the
  implicit default the user gets by typing without picking a card.
- Show each role's description on its card (under the emoji and name).
- Clicking a card immediately starts the chat: it binds the role to the
  new chat and sends the default opening prompt "Take a look at the
  current document" (one click, no separate select step). roleIdRef is
  set synchronously before sendMessage so the create request carries the
  role.
- Show the current role's name in the window header badge and as the
  assistant's display name (transcript label + "… is typing…"), falling
  back to "AI agent" for a role-less chat. selectChat resets the picked
  role so it cannot leak into an unrelated existing chat.
- Add the "Take a look at the current document" i18n key (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:20:36 +03:00
claude_code
d4658d4cb3 Merge pull request '#114 refactor(ai-chat): shared parseNodeArg helper; keep duplication backlog doc' (#114) from refactor/ai-chat-tool-spec-registry into develop
# Conflicts:
#	apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
2026-06-21 14:45:20 +03:00
claude_code
4105836a2d Merge pull request '#112 test(ai-chat): current-page coverage + getCurrentPage helper' (#112) from feat/ai-chat-current-page-robustness into develop 2026-06-21 14:31:12 +03:00
claude_code
f5a45d5453 Merge pull request '#115 test(server): integration harness + deferred coverage' (#115) from test/deferred-integration-coverage into develop 2026-06-21 14:31:12 +03:00
claude_code
9fad6ab73b Merge pull request '#113 feat(ai-chat): role-selection cards empty-state' (#113) from feat/ai-chat-role-cards into develop 2026-06-21 14:31:11 +03:00
claude_code
194924c3ba Merge pull request '#111 feat(ai-chat): collapse-on-page-focus (remove completed backlog doc)' (#111) from feat/ai-chat-collapse-on-page-focus into develop 2026-06-21 14:31:11 +03:00
claude_code
c7f0b51389 fix(ai-chat): keep tool-duplication backlog doc; fix parseNodeArg comment
Pre-merge review follow-up for the parseNodeArg dedupe (PR #114):
- Restore docs/backlog/ai-chat-tool-definitions-duplicated.md instead of
  deleting it: it still tracks open debt (unified spec registry + ProseMirror
  <-> Markdown converter unification) that this branch defers, and
  docs/git-sync-plan.md links to its converter section. Mark the node-arg
  quirk as done and add a Progress section.
- Reword the in-app helper header from "byte-for-byte" to "behaviorally
  identical": the two copies differ in comments/quote style; only the logic,
  throw messages and branch order match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:30:37 +03:00
claude_code
ebfe56a684 Merge pull request 'chore: finish the 3 remaining open issues (#93 move-snapshot, #62 cap, #109 ru-RU i18n)' (#117) from chore/finish-open-issues into develop 2026-06-21 14:27:02 +03:00
claude_code
e12ddaa2c8 i18n(ai-chat): complete the ru-RU AI-chat strings + record locale policy (#109)
ru-RU was missing most AI-chat keys, so the chat/typing widgets rendered
mixed-language (some keys fell back to en-US). Fill the full AI-chat string
set in ru-RU and document the maintenance policy.

- ru-RU/translation.json: add the 24 missing AI-chat keys (labels, typing
  indicator, Ask-AI widget, public-share, error messages); keep the typing
  keys grouped; existing translations untouched.
- i18n.ts: add a policy comment near fallbackLng — en-US is the source of
  truth; en-US + ru-RU are fully maintained; the other 10 locales
  intentionally rely on the en-US fallback until contributed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
6397b500ba fix(share-ai): lower default per-workspace cap to 100 (#62)
The fail-closed limiter behavior (#62 primary item) already shipped; this
finishes the issue by lowering the default hourly per-workspace cap from 300
to 100 to better fit real anonymous-assistant load. Still overridable via
SHARE_AI_WORKSPACE_MAX_PER_HOUR.

- public-share-workspace-limiter.ts: SHARE_AI_WORKSPACE_MAX_PER_WINDOW 300 -> 100.
- .env.example: documented default + example value 300 -> 100.
- public-share-chat.spec.ts: update the default-cap assertion to 100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
c3161a05dd refactor(ws): single-snapshot move audience to close the restricted-move race (#93)
Implements Option 2 of #93. The restricted branch of broadcastPageMoved
previously resolved its audience twice — emitToAuthorizedUsers and
emitDeleteToUnauthorized each ran an independent fetchSockets +
getUserIdsWithPageAccess — leaving a race window between the two snapshots
where a socket could receive both the move and the delete (leak) or neither
(lost compensating delete).

- ws.service.ts: add emitMoveWithRestrictionSplit() that takes ONE socket
  snapshot and ONE authorization resolution, then partitions the room:
  authorized users get the moveTreeNode, everyone else (unauthorized +
  anonymous) get the compensating deleteTreeNode. Disjoint + complete by
  construction. Remove the now-unused emitToAuthorizedUsers /
  emitDeleteToUnauthorized; keep private broadcastToAuthorizedUsers (still
  used by emitRestrictedAwareToSpace).
- ws-tree.service.ts: broadcastPageMoved restricted branch now drives move +
  delete from the single method.
- specs: assert the single method is used and that fetchSockets /
  getUserIdsWithPageAccess are each called exactly once (single snapshot);
  re-route ws-service.spec to emitTreeEvent after the method removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude code agent 227
04f05626ad test(server): integration harness + deferred coverage vs real Postgres/Redis
Builds the deferred integration tests from docs/backlog/feature-test-coverage-
deferred.md that needed real infra (a test Postgres + real Redis) which the repo
lacked. Runs against an isolated, auto-created docmost_test database and Redis
logical DB 15 — never the dev data.

Harness (apps/server/test/integration/, run via new `pnpm --filter server test:int`
=> jest --config test/jest-integration.json; default unit `jest` is untouched and
excludes these via the *.int-spec.ts name + rootDir):
- db.ts: buildTestDb() mirrors database.module.ts exactly (PostgresJSDialect,
  CamelCasePlugin, bigint to:20/from:[20,1700] parsing) + minimal seed helpers.
- global-setup.ts: DROP/CREATE docmost_test, CREATE EXTENSION vector, migrate to
  latest via Kysely Migrator (fails loud on any errored migration).
- global-teardown.ts: closes the pool.

Coverage (5 suites, 16 tests, all green against live PG+Redis):
- WorkspaceRepo.updateSetting: jsonb-merge persists htmlEmbed without clobbering
  sibling ai/sharing namespaces (the kill-switch write half).
- AiAgentRoleRepo: soft-delete exclusion, cross-workspace tenant isolation,
  duplicate (name,workspace) -> 23505, name reusable after softDelete (partial
  unique index WHERE deleted_at IS NULL), same name across workspaces allowed.
- page_template_references: deleting either source or referenced page cascades
  the link row (onDelete cascade) — real FK, not mocked.
- PublicShareWorkspaceLimiter vs REAL Redis: real ioredis EVAL of the sliding-
  window Lua — max boundary (3 admit / 4th deny), re-admit after the window
  slides, same-ms distinct members. Catches Lua bugs a FakeRedis cannot.
- AiChatRepo.findByCreator: role-badge join (enabled->badge; soft-deleted or
  disabled role -> null).

Review: APPROVE; applied its two hardening suggestions (fail loud on errored
migration result even without a top-level error; TEST_REDIS_URL override + ping
preflight). tsc clean; unit run excludes int-spec (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:02:55 +03:00
claude code agent 227
f9757fda12 refactor(ai-chat): dedupe node-arg JSON normalization into a shared helper
First, safe step of docs/backlog/ai-chat-tool-definitions-duplicated.md: the
"node may be a JSON object OR a JSON string" quirk was hand-copied at 6 tool
sites. Extract it into a single parseNodeArg() helper per package and call it at
every site. Behavior-preserving — each site's throw message is byte-identical
(patch/insert: 'node was a string but not valid JSON'; update_page_json: 'content
was a string but not valid JSON'); no tool name/description/schema changed.

Two helper copies (packages/mcp/src/lib/parse-node-arg.ts and
apps/server/src/core/ai-chat/tools/parse-node-arg.ts) are intentional: the
ESM-only @docmost/mcp cannot be imported by the CommonJS server (it is loaded at
runtime via the Function('import()') trick), so runtime code cannot cross that
boundary by a normal import. Each copy is now the single source within its
package (6 inline copies -> 2 helpers). packages/mcp/build rebuilt in sync.

Tests: parse-node-arg.spec.ts (server, Jest) + parse-node-arg.test.mjs (mcp,
node:test) — object passthrough, valid-string parse, invalid-string throw with
the right message. Server tsc clean; mcp suite 254 pass; agent structural-edit
path verified live in-browser (agent inserted a node, persisted to the doc).

Deferred (documented for the record, since the backlog doc is removed with this
commit): the FULL transport-agnostic tool-spec registry (one name+schema+
description per tool shared by both transports) and deriving DocmostClientLike
from the real client type. Both are blocked by the current architecture, not by
effort: (1) @docmost/mcp ships no type declarations and is ESM-only, so a
type-only derivation needs declaration emission + tsconfig path wiring, and the
real client's precise return types break the in-app tool test stubs (attempted,
reverted to keep tsc green); (2) the two transports intentionally DIVERGE in tool
NAMES (snake_case x38 vs camelCase x41), membership (in-app adds getCurrentPage/
listSidebarPages, omits delete_comment/image tools) and model-facing
DESCRIPTIONS, so a unified registry would change behavior on BOTH the agent and
external MCP clients and needs its own verification pass. This is forward-looking
debt (the code is correct today), to be done incrementally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:51:09 +03:00
claude code agent 227
19cd73a5aa feat(ai-chat): role-selection cards as new-chat empty-state
Replace the new-chat <Select label="Agent role"> picker with colored role
cards rendered as the empty-state of a brand-new chat (centered in the window),
per docs/backlog/ai-chat-role-cards-empty-state.md. Clicking a card selects that
identity; sending without a pick falls back to the Universal assistant; the
cards disappear once the chat is non-empty. Purely client-side — the existing
selectedAiRoleIdAtom + roleId request wiring (server role fixation on chat
creation) is unchanged.

- new RoleCards rendered through the existing emptyState prop chain
  (AiChatWindow -> ChatThread -> MessageList); MessageList already supported it.
- Universal assistant card (gray, value null, default-selected) + one card per
  enabled role, color cycled from a 10-name Mantine palette via the pure
  roleCardColor() helper; theme-aware CSS vars (light/-light-color/-filled).
- each card is an UnstyledButton with aria-pressed for a11y + testability.
- tests: role-card-color (palette cycling, negative-safe) + role-cards.test.tsx
  (render, emoji/name, selection highlight, click -> onSelect). 9 tests green,
  client tsc clean.

Verified live in-browser: cards (not a Select) show for a new chat; selecting
Пират binds the chat to that role end-to-end (badge + pirate reply); no pick =>
Universal; cards vanish after the first message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:32:16 +03:00
claude code agent 227
e6b1170553 test(ai-chat): cover current-page injection; extract resolveCurrentPageResult
The 'current page' feature (client useMatch openPage + server getCurrentPage
tool + system-prompt injection) was already implemented & merged; this backfills
its missing test coverage and removes the completed backlog doc.

- extract pure resolveCurrentPageResult(openedPage) into current-page.util.ts
  (byte-identical to the prior inline getCurrentPage tool body) so it is
  unit-testable without the dynamically-imported ESM Docmost client; the tool
  now delegates to it.
- current-page.util.spec.ts: 7 cases (null/undefined/no-id/empty-id/full/no-title).
- ai-chat.prompt.spec.ts: +8 cases for the openedPage context line (title+pageId
  present, Untitled fallback for blank/whitespace title, no line when absent/blank
  id, and sandwich ordering before the trailing safety block).

Verified live in-browser: client sends openPage{id,title} on a page and null
off-page; the agent invokes getCurrentPage and answers with the real title+id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:21:38 +03:00
claude code agent 227
2e0f4456e1 docs: remove completed backlog doc for ai-chat collapse-on-page-focus
The feature is already implemented and merged into develop (f6e216cb):
auto-collapse the AI chat window into its header on outside-page pointer,
expand on header click, with keyboard a11y. Verified live in-browser and
covered by collapse-helpers.test.ts (9 tests). Removing the now-completed
planning doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:11:52 +03:00
191 changed files with 16138 additions and 2557 deletions

View File

@@ -147,8 +147,8 @@ MCP_DOCMOST_PASSWORD=
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and # per-IP limit is fully evaded. It is a COST backstop, not an access control, and
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going # FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
# offline is safer than an unbounded bill). Override the hourly cap below # offline is safer than an unbounded bill). Override the hourly cap below
# (default: 300 calls per workspace per rolling hour). # (default: 100 calls per workspace per rolling hour).
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 # SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
# #
# Per-request output-token ceiling for the anonymous assistant (default: 512). # Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value. # Worst-case output per accepted call = agent steps (5) × this value.

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ lerna-debug.log*
# TypeScript incremental build artifacts # TypeScript incremental build artifacts
*.tsbuildinfo *.tsbuildinfo
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/

180
AGENTS.md
View File

@@ -5,45 +5,48 @@ repository. It has two layers: **how to run a task end-to-end** (the
sections below), and **how the codebase is built** (the technical sections sections below), and **how the codebase is built** (the technical sections
further down, formerly in `CLAUDE.md`). further down, formerly in `CLAUDE.md`).
## Жизненный цикл задачи ## Task lifecycle
### 1. Старт: синхронизация с develop ### 1. Start: sync with develop
Перед началом **любой** работы обнови локальный `develop` и ветвись от него: Before starting **any** work, update your local `develop` and branch off it:
```bash ```bash
git checkout develop git checkout develop
git fetch gitea git fetch gitea
git pull --ff-only gitea develop git pull --ff-only gitea develop
git checkout -b <короткое-имя-фичи> git checkout -b <short-feature-name>
``` ```
Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего Never build a feature directly on `develop`, and never branch off a stale
`develop`иначе PR будет содержать лишние коммиты или конфликтовать. `develop`otherwise the PR will carry extra commits or conflict.
### 2. Реализация ### 2. Implementation
Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3 Run the task through the workflow from the system prompt (Phase 1 analysis →
реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие Phase 3 implementation → Phase 4 review → Phase 5 verification → Phase 6
изменения делегируй в general subagent, ревьюй через review subagent. report). Delegate large changes to a general subagent; review via the review
subagent.
### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code` **Create worktrees only inside the `.claude` folder** (e.g.
`.claude/worktrees/<name>`). Creating a git worktree anywhere else — the repo
root, sibling directories, or temp folders — is forbidden.
Это правило без исключений: ### 3. Commit — ONLY to Gitea and ONLY as `claude_code`
- **Куда:** единственный remote для коммитов/пушей — **`gitea`** This rule has no exceptions:
(`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и
тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется - **Where:** the only remote for commits/pushes is **`gitea`**
CI-процессом владельца, не агентом. (`gitea.vvzvlad.xyz`). **Never** push to `origin` (the GitHub mirror), and
- **От кого:** коммить **только** от агентского identity. Любой коммит, especially not to `upstream` (the original Docmost). The GitHub mirror is
у которого author или committer — `vvzvlad`, считается ошибкой и должен updated by the owner's CI process, not by the agent.
быть переписан. - **Who:** commit **only** as the agent identity. Any commit whose author or
committer is `vvzvlad` is an error and must be rewritten.
- **name:** `claude_code` - **name:** `claude_code`
- **email:** `claude_code@vvzvlad.xyz` - **email:** `claude_code@vvzvlad.xyz`
Используй `--reset-author` при amend, иначе git оставит оригинального Use `--reset-author` when amending, otherwise git keeps the original author
автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй (the default config on this machine is `vvzvlad`, so check after every commit):
после каждого коммита):
```bash ```bash
GIT_AUTHOR_NAME="claude_code" \ GIT_AUTHOR_NAME="claude_code" \
@@ -53,34 +56,33 @@ GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \
git commit --amend --no-edit --reset-author git commit --amend --no-edit --reset-author
``` ```
Для обычного нового коммита достаточно один раз выставить локальный For a regular new commit, set the branch-local config once and commit normally:
config ветки и коммитить штатно:
```bash ```bash
git config user.name "claude_code" git config user.name "claude_code"
git config user.email "claude_code@vvzvlad.xyz" git config user.email "claude_code@vvzvlad.xyz"
``` ```
Проверка перед push: Check before push:
```bash ```bash
git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>' git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
# обе строки должны показать claude_code <claude_code@vvzvlad.xyz> # both lines must show claude_code <claude_code@vvzvlad.xyz>
``` ```
### 4. Push и PR в develop ### 4. Push and PR to develop
PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как PRs always target `develop`. The `claude_code` password lives in the macOS
**generic password** под service `gitea-claude-code` (не дублируй его как keychain as a **generic password** under service `gitea-claude-code` (do not
internet-password для `gitea.vvzvlad.xyz`это создаст конфликт с учёткой duplicate it as an internet-password for `gitea.vvzvlad.xyz`that creates a
владельца в git credential helper): conflict with the owner's account in the git credential helper):
```bash ```bash
AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w) AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w)
``` ```
Push — через временную подстановку кредов в remote URL, после чего URL Push by temporarily injecting the credentials into the remote URL, then always
обязательно возвращается в чистый вид (пароль не должен оседать в git restore the URL to its clean form (the password must not linger in git
config / reflog): config / reflog):
```bash ```bash
@@ -92,7 +94,7 @@ git remote set-url gitea "$ORIG_URL"
unset AGENT_PASS SAFE_PASS unset AGENT_PASS SAFE_PASS
``` ```
PR создаётся через Gitea REST API (Basic Auth от `claude_code`): The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
```bash ```bash
curl -s -X POST \ curl -s -X POST \
@@ -102,63 +104,62 @@ curl -s -X POST \
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls" "https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
``` ```
`base: develop`, `head: <branch>`. В теле PR — что сделано, что вне scope, `base: develop`, `head: <branch>`. In the PR body: what was done, what is out
результаты верификации (tsc/lint/tests). of scope, verification results (tsc/lint/tests).
> Если push падает с `User permission denied for writing` — значит у > If push fails with `User permission denied for writing`, then `claude_code`
> `claude_code` нет коллабораторских прав на репо. Попроси владельца > lacks collaborator rights on the repo. Ask the owner to add them (once, via
> добавить (один раз, через Gitea UI или > the Gitea UI or `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code`
> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с > with `{"permission":"write"}` from their account).
> `{"permission":"write"}` от его учётки).
### 5. Мерж и cleanup ### 5. Merge and cleanup
- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт - **The user merges the PR into develop** (not the agent). The agent does not
кнопку merge. press the merge button.
- **После реализации задачи удали её план из `docs/backlog/<task>.md`** — - **After implementing a task, delete its plan from `docs/backlog/<task>.md`** —
это часть закрытия задачи, не пользовательская работа. Файлы в this is part of closing the task, not the user's work. Files in
`docs/backlog/` — это очередь работы, выполненное из неё вычищается. `docs/backlog/` are the work queue; completed items get cleaned out of it.
Сделай это в отдельном коммите от того же `claude_code` в той же ветке Do this in a separate commit from the same `claude_code` on the same branch
(или попроси пользователя удалить, если PR уже открыт и ты не хочешь (or ask the user to delete it if the PR is already open and you don't want to
его перепушивать). repush it).
- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед - Any junk left uncommitted in the working tree? Check `git status` before the
финальным отчётом. final report.
## Релизный цикл: набор на новую версию ## Release cycle: staging a new version
Когда в `develop` накопилось достаточно изменений для релиза, запускается When enough changes have accumulated on `develop` for a release, a **final
**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом: review by three orchestrator skills** runs before the merge/tag:
1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на 1. **test-orchestrator** (the `code-review-orchestrator` skill focused on test
тестовом покрытии) — проверяет, что новый код покрыт тестами и нет coverage) — verifies new code is covered by tests and there are no
регрессий в существующих. regressions in existing ones.
2. **review-orchestrator** (skill `code-review-orchestrator`) — 2. **review-orchestrator** (the `code-review-orchestrator` skill) —
мульти-аспектный код-ревью: безопасность, стабильность, соответствие multi-aspect code review: security, stability, convention conformance,
конвенциям, регрессии, перегруженность. regressions, over-complexity.
3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ 3. **red-team-orchestrator** (the red-team skill) — adversarial analysis of
атакующих сценариев на затронутые компоненты. attack scenarios against the affected components.
Порядок: оркестраторы возвращают списки находок → агент правит всё, что Order: the orchestrators return finding lists → the agent fixes everything they
они нашли (через subagent или сам, по правилам делегирования) → повторно found (via a subagent or itself, per the delegation rules) → re-runs the review
прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a on the affected areas → cuts the tag per the "Cutting a release" procedure
release» ниже. below.
## Шпаргалка по учёткам и endpoint'ам ## Accounts & endpoints cheat sheet
| Что | Значение | | Item | Value |
| --- | --- | | --- | --- |
| Единственный remote для коммитов | `gitea``https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` | | Only remote for commits | `gitea``https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
| Агентский user (Gitea/git) | `claude_code` | | Agent user (Gitea/git) | `claude_code` |
| Агентский email | `claude_code@vvzvlad.xyz` | | Agent email | `claude_code@vvzvlad.xyz` |
| Пароль в keychain | `security find-generic-password -s gitea-claude-code -w` | | Keychain password | `security find-generic-password -s gitea-claude-code -w` |
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (тут `gitmost` — реальный slug репо на сервере) | | PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
| Базовая ветка | `develop` | | Base branch | `develop` |
| `origin` | GitHub-зеркало `vvzvlad/gitmost`**не пушить**, обновляется CI владельца | | `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | Оригинальный Docmost — **не пушить никогда** | | `upstream` | The original Docmost — **never push** |
--- ---
# Архитектура и кодовая база # Architecture and codebase
## What this is ## What this is
@@ -277,6 +278,29 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
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. 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`. 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). 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).
#### Why develop keeps showing the *previous* version (and why step 7 matters)
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>`.
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`.
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.
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
`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`.
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.)
## Planning docs ## Planning docs

View File

@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- **Public share AI: default per-workspace hourly assistant cap lowered
300 → 100.** The limiter falls back to this default whenever
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
never set the env var has its anonymous public-share assistant hourly cap
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
keep the previous limit. (#62)
## [0.93.0] - 2026-06-21 ## [0.93.0] - 2026-06-21
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles, This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,

View File

@@ -3,8 +3,8 @@
"private": true, "private": true,
"version": "0.93.0", "version": "0.93.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "tsc && vite build", "build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
@@ -28,6 +28,7 @@
"@mantine/modals": "8.3.18", "@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18", "@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18", "@mantine/spotlight": "8.3.18",
"@ricky0123/vad-web": "^0.0.30",
"@slidoapp/emoji-mart": "5.8.7", "@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4", "@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5", "@slidoapp/emoji-mart-react": "1.1.5",
@@ -53,6 +54,7 @@
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0", "mermaid": "11.15.0",
"mitt": "3.0.1", "mitt": "3.0.1",
"onnxruntime-web": "^1.27.0",
"posthog-js": "1.372.2", "posthog-js": "1.372.2",
"react": "18.3.1", "react": "18.3.1",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",

View File

@@ -119,6 +119,8 @@
"Name": "Name", "Name": "Name",
"New email": "New email", "New email": "New email",
"New page": "New page", "New page": "New page",
"New note": "New note",
"Create in space": "Create in space",
"New password": "New password", "New password": "New password",
"No group found": "No group found", "No group found": "No group found",
"No page history saved yet.": "No page history saved yet.", "No page history saved yet.": "No page history saved yet.",
@@ -706,7 +708,6 @@
"Authorization header": "Authorization header", "Authorization header": "Authorization header",
"Tool allowlist": "Tool allowlist", "Tool allowlist": "Tool allowlist",
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.", "Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Use Tavily preset": "Use Tavily preset",
"Test": "Test", "Test": "Test",
"Available tools": "Available tools", "Available tools": "Available tools",
"No tools available": "No tools available", "No tools available": "No tools available",
@@ -951,6 +952,7 @@
"Try a different search term.": "Try a different search term.", "Try a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "Untitled chat": "Untitled chat",
"No document": "No document",
"You": "You", "You": "You",
"What can I help you with?": "What can I help you with?", "What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}", "Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
@@ -1144,9 +1146,13 @@
"Minimize": "Minimize", "Minimize": "Minimize",
"Current context size": "Current context size", "Current context size": "Current context size",
"AI agent": "AI agent", "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…", "AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…", "{{name}} is typing…": "{{name}} is typing…",
"Send": "Send", "Send": "Send",
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Stop": "Stop", "Stop": "Stop",
"Chat menu": "Chat menu", "Chat menu": "Chat menu",
"No chats yet.": "No chats yet.", "No chats yet.": "No chats yet.",
@@ -1220,6 +1226,9 @@
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)", "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)",
"Dictation language": "Dictation language",
"Auto-detect": "Auto-detect",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
"Agent role": "Agent role", "Agent role": "Agent role",
"Universal assistant": "Universal assistant", "Universal assistant": "Universal assistant",
"Add role": "Add role", "Add role": "Add role",

View File

@@ -119,6 +119,8 @@
"Name": "Имя", "Name": "Имя",
"New email": "Новый электронный адрес", "New email": "Новый электронный адрес",
"New page": "Новая страница", "New page": "Новая страница",
"New note": "Новая заметка",
"Create in space": "Создать в пространстве",
"New password": "Новый пароль", "New password": "Новый пароль",
"No group found": "Группа не найдена", "No group found": "Группа не найдена",
"No page history saved yet.": "История страниц ещё не сохранена.", "No page history saved yet.": "История страниц ещё не сохранена.",
@@ -669,8 +671,37 @@
"AI Answer": "Ответ ИИ", "AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ", "Ask AI": "Спросить ИИ",
"AI agent": "AI-агент", "AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"AI agent is typing…": "AI-агент печатает…", "AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…", "{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
"Ask a question…": "Задайте вопрос…",
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
"Universal assistant": "Универсальный ассистент",
"You": "Вы",
"AI is thinking...": "ИИ обрабатывает запрос...", "AI is thinking...": "ИИ обрабатывает запрос...",
"Thinking": "Думаю", "Thinking": "Думаю",
"Ask a question...": "Задайте вопрос...", "Ask a question...": "Задайте вопрос...",
@@ -926,6 +957,7 @@
"Try a different search term.": "Попробуйте другой поисковый запрос.", "Try a different search term.": "Попробуйте другой поисковый запрос.",
"Try again": "Попробовать снова", "Try again": "Попробовать снова",
"Untitled chat": "Чат без названия", "Untitled chat": "Чат без названия",
"No document": "Без документа",
"What can I help you with?": "Чем я могу вам помочь?", "What can I help you with?": "Чем я могу вам помочь?",
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}", "Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.", "Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
@@ -1097,5 +1129,8 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное", "Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного", "Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}", "Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}" "Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
} }

View File

@@ -0,0 +1,70 @@
// Self-host the @ricky0123/vad-web + onnxruntime-web runtime assets under
// apps/client/public/vad/.
//
// WHY THIS EXISTS:
// Both vad-web and onnxruntime-web resolve their assets by URL *at runtime* (the
// VAD audio worklet + Silero model, and ORT's wasm/mjs backend). In vad-web
// 0.0.30 the default baseAssetPath / onnxWASMBasePath is "./" — i.e. relative to
// the current page URL — NOT a CDN. In this SPA that "./" request hits the
// client-side catch-all route and gets served index.html (text/html), so the
// onnxruntime ESM/wasm backend fails to initialize ("'text/html' is not a valid
// JavaScript MIME type"). We fix that by copying the needed runtime files into
// public/vad/ and pointing both path constants at the fixed absolute "/vad/".
//
// These copies are NOT committed (the ORT wasm is ~26 MB); this script runs
// before `dev` and `build` (see package.json) to repopulate them from
// node_modules. It is idempotent: it (re)creates the dir and overwrites.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(here, "..", "public", "vad");
// vad-web exposes ./package.json, so derive its dist dir from there.
const vadDist = path.join(
path.dirname(require.resolve("@ricky0123/vad-web/package.json")),
"dist",
);
// onnxruntime-web's "exports" map does NOT expose ./package.json, so resolving
// it would throw ERR_PACKAGE_PATH_NOT_EXPORTED. It DOES export the exact asset
// subpaths we need, so resolve those files directly.
//
// ORT ships several wasm backends and which one the app bundle references depends
// on the resolver: Vite dev resolves the JSEP build (ort-wasm-simd-threaded.jsep.*)
// while the production rolldown build resolves the plain build
// (ort-wasm-simd-threaded.*). Ship BOTH variants so the runtime fetch hits a real
// file under /vad/ regardless of which the bundle picked (each .mjs proxy fetches
// its matching .wasm at init).
const ortJsepMjs = require.resolve(
"onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs",
);
const ortJsepWasm = require.resolve(
"onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm",
);
const ortMjs = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.mjs");
const ortWasm = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.wasm");
// [absolute source path, output filename]
const files = [
[path.join(vadDist, "vad.worklet.bundle.min.js"), "vad.worklet.bundle.min.js"],
[path.join(vadDist, "silero_vad_v5.onnx"), "silero_vad_v5.onnx"],
[ortJsepMjs, "ort-wasm-simd-threaded.jsep.mjs"],
[ortJsepWasm, "ort-wasm-simd-threaded.jsep.wasm"],
[ortMjs, "ort-wasm-simd-threaded.mjs"],
[ortWasm, "ort-wasm-simd-threaded.wasm"],
];
fs.mkdirSync(outDir, { recursive: true });
for (const [src, name] of files) {
if (!fs.existsSync(src)) {
console.error(`[copy-vad-assets] missing source: ${src}`);
process.exit(1);
}
fs.copyFileSync(src, path.join(outDir, name));
console.log(`[copy-vad-assets] ${name}`);
}

View File

@@ -13,6 +13,7 @@
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
user-select: none;
} }
.brandIcon { .brandIcon {
@@ -33,21 +34,3 @@
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */ that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
margin-bottom: rem(7px); margin-bottom: rem(7px);
} }
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}

View File

@@ -10,7 +10,6 @@ import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo"; import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx"; import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
@@ -30,10 +29,6 @@ import {
} from "@/features/search/constants.ts"; } from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx"; import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [
{ link: APP_ROUTE.HOME, label: "Home" },
];
export function AppHeader() { export function AppHeader() {
const { t } = useTranslation(); const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
@@ -47,12 +42,6 @@ export function AppHeader() {
// AI chat entry point: only shown when the workspace enables it (A7 gate). // AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true; const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{t(link.label)}
</Link>
));
return ( return (
<> <>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}> <Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
@@ -97,10 +86,6 @@ export function AppHeader() {
</Text> </Text>
</Tooltip> </Tooltip>
</Group> </Group>
<Group ml="xl" gap={5} className={classes.links} visibleFrom="sm">
{items}
</Group>
</Group> </Group>
<div> <div>

View File

@@ -27,7 +27,7 @@ export default function Aside() {
switch (tab) { switch (tab) {
case "comments": case "comments":
component = <CommentListWithTabs />; component = <CommentListWithTabs onClose={closeAside} />;
title = "Comments"; title = "Comments";
break; break;
case "toc": case "toc":
@@ -44,26 +44,27 @@ export default function Aside() {
} }
return ( return (
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}> <Box p={0} style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && ( {component &&
<> (tab === "comments" ? (
<Group justify="space-between" wrap="nowrap" mb="md"> component
<Title order={2} size="h6" fw={500}>{t(title)}</Title> ) : (
<Tooltip label={t("Close")} withArrow> <>
<ActionIcon <Group justify="space-between" wrap="nowrap" mb="sm">
variant="subtle" <Title order={2} size="h6" fw={500}>
color="gray" {t(title)}
onClick={closeAside} </Title>
aria-label={t("Close")} <Tooltip label={t("Close")} withArrow>
> <ActionIcon
<IconX size={18} /> variant="subtle"
</ActionIcon> color="gray"
</Tooltip> onClick={closeAside}
</Group> aria-label={t("Close")}
>
{tab === "comments" ? ( <IconX size={18} />
component </ActionIcon>
) : ( </Tooltip>
</Group>
<ScrollArea <ScrollArea
style={{ height: "85vh" }} style={{ height: "85vh" }}
scrollbarSize={5} scrollbarSize={5}
@@ -71,9 +72,8 @@ export default function Aside() {
> >
<div style={{ paddingBottom: "200px" }}>{component}</div> <div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea> </ScrollArea>
)} </>
</> ))}
)}
</Box> </Box>
); );
} }

View File

@@ -94,12 +94,12 @@ export default function GlobalAppShell({
}} }}
aside={ aside={
isPageRoute && { isPageRoute && {
width: 350, width: 420,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen }, collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
} }
} }
padding="md" padding={{ base: "xs", sm: "md" }}
> >
<AppShell.Header px="md" className={classes.header}> <AppShell.Header px="md" className={classes.header}>
<AppHeader /> <AppHeader />
@@ -138,7 +138,7 @@ export default function GlobalAppShell({
id={ASIDE_PANEL_ID} id={ASIDE_PANEL_ID}
tabIndex={-1} tabIndex={-1}
className={classes.aside} className={classes.aside}
p="md" p="sm"
withBorder={false} withBorder={false}
aria-label={ aria-label={
asideTab === "comments" asideTab === "comments"

View File

@@ -20,18 +20,29 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom"; import { Link, useMatch } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts"; import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
export default function TopMenu() { export default function TopMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth(); const { logout } = useAuth();
const { colorScheme, setColorScheme } = useMantineColorScheme(); const { colorScheme, setColorScheme } = useMantineColorScheme();
// Detect the currently viewed space so the "Space settings" item is only
// offered while the user is inside a space. The "/*" splat also matches the
// bare "/s/:spaceSlug" route (the splat matches an empty segment).
const spaceMatch = useMatch("/s/:spaceSlug/*");
const spaceSlug = spaceMatch?.params?.spaceSlug;
const [
spaceSettingsOpened,
{ open: openSpaceSettings, close: closeSpaceSettings },
] = useDisclosure(false);
const user = currentUser?.user; const user = currentUser?.user;
const workspace = currentUser?.workspace; const workspace = currentUser?.workspace;
@@ -41,124 +52,143 @@ export default function TopMenu() {
} }
return ( return (
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}> <>
<Menu.Target> <Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<UnstyledButton> <Menu.Target>
<Group gap={7} wrap={"nowrap"}> <UnstyledButton>
<CustomAvatar <Group gap={7} wrap={"nowrap"}>
avatarUrl={workspace?.logo} <CustomAvatar
name={workspace?.name} avatarUrl={workspace?.logo}
variant="filled" name={workspace?.name}
size="sm" variant="filled"
type={AvatarIconType.WORKSPACE_ICON} size="sm"
/> type={AvatarIconType.WORKSPACE_ICON}
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}> />
{workspace?.name} <Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
</Text> {workspace?.name}
<IconChevronDown size={16} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text> </Text>
<Text size="xs" c="dimmed" truncate="end"> <IconChevronDown size={16} />
{user.email} </Group>
</Text> </UnstyledButton>
</div> </Menu.Target>
</Group> <Menu.Dropdown>
</Menu.Item> <Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
{t("My profile")}
</Menu.Item>
<Menu.Item <Menu.Item
component={Link} component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES} to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconBrush size={16} />} leftSection={<IconSettings size={16} />}
> >
{t("My preferences")} {t("Workspace settings")}
</Menu.Item> </Menu.Item>
<Menu.Sub> {spaceSlug && (
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item <Menu.Item
onClick={() => setColorScheme("light")} onClick={openSpaceSettings}
leftSection={<IconSun size={16} />} leftSection={<IconSettings size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
> >
{t("Light")} {t("Space settings")}
</Menu.Item> </Menu.Item>
<Menu.Item )}
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider /> <Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
{t("Manage members")}
</Menu.Item>
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}> <Menu.Divider />
{t("Logout")}
</Menu.Item> <Menu.Label>{t("Account")}</Menu.Label>
</Menu.Dropdown> <Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
</Menu> <Group wrap={"nowrap"}>
<CustomAvatar
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text size="xs" c="dimmed" truncate="end">
{user.email}
</Text>
</div>
</Group>
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
{t("My profile")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
{t("My preferences")}
</Menu.Item>
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Light")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{spaceSlug && (
<SpaceSettingsModal
spaceId={spaceSlug}
opened={spaceSettingsOpened}
onClose={closeSpaceSettings}
/>
)}
</>
); );
} }

View File

@@ -20,7 +20,6 @@ import {
prefetchSpaces, prefetchSpaces,
prefetchWorkspaceMembers, prefetchWorkspaceMembers,
} from "@/components/settings/settings-queries.tsx"; } from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
@@ -141,8 +140,6 @@ export default function SettingsSidebar() {
</Group> </Group>
<ScrollArea w="100%">{menuItems}</ScrollArea> <ScrollArea w="100%">{menuItems}</ScrollArea>
<AppVersion />
</div> </div>
); );
} }

View File

@@ -27,6 +27,7 @@ export function BrandLogo({
src={src} src={src}
alt="Gitmost" alt="Gitmost"
className={className} className={className}
draggable={false}
style={{ height, width: "auto", display: "block", userSelect: "none" }} style={{ height, width: "auto", display: "block", userSelect: "none" }}
/> />
); );

View File

@@ -6,7 +6,8 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Group, Loader, Select, Tooltip } from "@mantine/core"; import { generateId } from "ai";
import { Group, Loader, Tooltip } from "@mantine/core";
import { import {
IconArrowsDiagonal, IconArrowsDiagonal,
IconCheck, IconCheck,
@@ -31,6 +32,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { import {
AI_CHATS_RQ_KEY, AI_CHATS_RQ_KEY,
AI_CHAT_MESSAGES_RQ_KEY,
useAiChatMessagesQuery, useAiChatMessagesQuery,
useAiChatsQuery, useAiChatsQuery,
useAiRolesQuery, useAiRolesQuery,
@@ -134,6 +136,26 @@ export default function AiChatWindow() {
// can adopt it once the chat list refreshes after the first turn finishes. // can adopt it once the chat list refreshes after the first turn finishes.
const adoptNewChat = useRef(false); const adoptNewChat = useRef(false);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// Mount key for ChatThread + the chat the currently-mounted thread represents.
// `threadKey` normally tracks the active chat, so selecting a different chat
// (incl. from page history) remounts and re-seeds. The ONE exception is
// in-place adoption of a brand-new chat's server id: the adopt effect moves
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
// check below does not fire and the SAME thread stays mounted (its useChat
// already holds the just-finished turn) instead of being re-seeded from
// not-yet-persisted history.
const [threadKey, setThreadKey] = useState<string>(
() => activeChatId ?? `new-${generateId()}`,
);
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
activeChatId,
);
const { data: chats } = useAiChatsQuery(); const { data: chats } = useAiChatsQuery();
// Roles for the new-chat picker (any member may list them). Only fetched while // Roles for the new-chat picker (any member may list them). Only fetched while
// the window is open. // the window is open.
@@ -145,6 +167,7 @@ export default function AiChatWindow() {
() => (roles ?? []).filter((r) => r.enabled === true), () => (roles ?? []).filter((r) => r.enabled === true),
[roles], [roles],
); );
const { data: messageRows, isLoading: messagesLoading } = const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined); useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -166,6 +189,9 @@ export default function AiChatWindow() {
: null; : null;
const startNewChat = useCallback((): void => { const startNewChat = useCallback((): void => {
// Cancel any pending adoption so a just-finished new chat can't yank the user
// back here after they explicitly started a fresh one.
adoptNewChat.current = false;
setActiveChatId(null); setActiveChatId(null);
setHistoryOpen(false); setHistoryOpen(false);
setDraft(""); setDraft("");
@@ -175,11 +201,16 @@ export default function AiChatWindow() {
const selectChat = useCallback( const selectChat = useCallback(
(chatId: string): void => { (chatId: string): void => {
// Cancel any pending adoption so it can't override an explicit selection.
adoptNewChat.current = false;
setActiveChatId(chatId); setActiveChatId(chatId);
setHistoryOpen(false); setHistoryOpen(false);
setDraft(""); setDraft("");
// Reset the card-picked role so a stale pick can't leak into the existing
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
}, },
[setActiveChatId, setDraft], [setActiveChatId, setDraft, setSelectedRoleId],
); );
// After a turn finishes, refresh the chat list. For a brand-new chat (no id // After a turn finishes, refresh the chat list. For a brand-new chat (no id
@@ -189,6 +220,18 @@ export default function AiChatWindow() {
const onTurnFinished = useCallback(() => { const onTurnFinished = useCallback(() => {
if (activeChatId === null) adoptNewChat.current = true; if (activeChatId === null) adoptNewChat.current = true;
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }); queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
// Re-sync the persisted message rows for the active chat so the Markdown
// export and the token counters reflect the turn that just finished. The
// live thread renders from its own useChat store (stable threadKey / store
// id), so refetching these rows never re-seeds or tears down the open
// thread. For a brand-new chat activeChatId is still null here; that chat's
// first row load happens right after id adoption, and every later turn hits
// this invalidation with the adopted id.
if (activeChatId) {
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
});
}
}, [activeChatId, queryClient]); }, [activeChatId, queryClient]);
// The active chat object (for its title) and an export gate: only enable the // The active chat object (for its title) and an export gate: only enable the
@@ -199,6 +242,18 @@ export default function AiChatWindow() {
); );
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0; const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
// The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
}
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
return picked ? { name: picked.name, emoji: picked.emoji } : null;
}, [activeChat, enabledRoles, selectedRoleId]);
// Build a Markdown export from the already-loaded persisted rows (no network // Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the // call) and copy it to the clipboard. The "Copied" notification is the
// feedback. // feedback.
@@ -221,15 +276,54 @@ export default function AiChatWindow() {
const newest = chats?.items?.[0]; const newest = chats?.items?.[0];
if (newest) { if (newest) {
adoptNewChat.current = false; adoptNewChat.current = false;
// In-place adoption: move the active chat AND the live-thread marker to the
// new id together, so the threadKey derivation below sees no "switch" and
// keeps the SAME mounted thread (its useChat already holds the finished
// turn) instead of remounting and re-seeding from not-yet-persisted history.
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
// render so the render-phase guard never observes the new activeChatId with
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
// batching inside this effect callback guarantees that; if the store/atom
// mechanism ever changes, gate adoption on an explicit flag instead.
setLiveThreadChatId(newest.id);
setActiveChatId(newest.id); setActiveChatId(newest.id);
} }
}, [chats, setActiveChatId]); }, [chats, setActiveChatId]);
// The thread is remounted when the active chat changes so initial messages // Adjust the derived thread state during render when the active chat genuinely
// re-seed. For a new chat we key on "new"; adopting the id remounts the // changes — the React-sanctioned alternative to an effect (it re-renders before
// thread with the persisted history loaded. // paint, no extra commit, and converges since the next render finds them equal).
const threadKey = activeChatId ?? "new"; // In-place adoption of a new chat's id never reaches here because the adopt
const waitingForHistory = activeChatId !== null && messagesLoading; // effect moves liveThreadChatId in lockstep with activeChatId.
if (activeChatId !== liveThreadChatId) {
setLiveThreadChatId(activeChatId);
setThreadKey(activeChatId ?? `new-${generateId()}`);
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages
// invalidation, which can transiently flip hasNextPage for a chat whose
// message count is an exact multiple of the server page size) does not tear
// the live thread down to a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
threadKey === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so we keep showing the live thread instead of unmounting it behind a
// loader; and once a chat's history has loaded, a later background refetch no
// longer tears the thread back down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
threadKey === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
// Current context size for the active chat: how much the conversation now // Current context size for the active chat: how much the conversation now
// occupies in the model's context window — NOT the cumulative tokens spent. // occupies in the model's context window — NOT the cumulative tokens spent.
@@ -430,12 +524,13 @@ export default function AiChatWindow() {
{t("AI chat")} {t("AI chat")}
</span> </span>
{/* Role badge for the active chat (emoji + name). Shown only when the {/* Role badge (emoji + name). Shows the persisted role of an existing
chat is bound to a role that still exists. */} chat, or the role picked via a card for a brand-new chat. Hidden for
{activeChat?.roleName && ( a universal (no-role) chat. */}
{currentRole && (
<span className={classes.badge} title={t("Agent role")}> <span className={classes.badge} title={t("Agent role")}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} {currentRole.emoji ? `${currentRole.emoji} ` : ""}
{activeChat.roleName} {currentRole.name}
</span> </span>
)} )}
@@ -537,28 +632,10 @@ export default function AiChatWindow() {
)} )}
</div> </div>
{/* Role picker — only for a NEW chat (before it is created). Once the {/* The role picker for a NEW chat is rendered as the chat's empty-state
chat exists, its role is fixed and shown as a header badge instead. (colored role cards centered in the empty window) by ChatThread
Defaults to "Universal assistant" (no role). */} itself — clicking a card starts the chat with that role. Once the
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && ( chat exists, its role is fixed and shown as a header badge instead. */}
<div style={{ padding: "4px 8px 0" }}>
<Select
size="xs"
label={t("Agent role")}
value={selectedRoleId ?? ""}
onChange={(value) => setSelectedRoleId(value || null)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
data={[
{ value: "", label: t("Universal assistant") },
...enabledRoles.map((r) => ({
value: r.id,
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
})),
]}
/>
</div>
)}
{/* body: active chat thread */} {/* body: active chat thread */}
<div className={classes.body}> <div className={classes.body}>
@@ -574,6 +651,11 @@ export default function AiChatWindow() {
openPage={openPage} openPage={openPage}
// Honoured only for a new chat; null = universal assistant. // Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null} roleId={activeChatId === null ? selectedRoleId : null}
// Role cards are the new-chat empty-state; offered only when this
// is a brand-new chat. Clicking a card starts the chat with it.
roles={activeChatId === null ? enabledRoles : undefined}
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished} onTurnFinished={onTurnFinished}
/> />
)} )}

View File

@@ -88,16 +88,18 @@
opacity: 0.4; opacity: 0.4;
} }
40% { 40% {
transform: translateY(-3px); /* Bounce height is driven by --bounce so reduced-motion can dampen it
(below) without disabling the animation outright. */
transform: translateY(var(--bounce, -6px));
opacity: 1; opacity: 1;
} }
} }
/* Respect reduced-motion preferences: fall back to a static dimmed state. */ /* Respect reduced-motion preferences: keep a smaller bounce instead of a full
stop, so the "thinking" indicator still reads as active rather than frozen. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.typingDots span { .typingDots span {
animation: none; --bounce: -3px;
opacity: 0.6;
} }
} }
@@ -126,3 +128,29 @@
.conversationItemActive { .conversationItemActive {
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
} }
/* Pending messages queued by the user while a turn is still streaming. They
are sent automatically, FIFO, once the current turn finishes. */
.queuedList {
padding-bottom: var(--mantine-spacing-xs);
}
.queuedItem {
background: var(--mantine-color-gray-light);
border-radius: var(--mantine-radius-sm);
padding: 4px 8px;
}
.queuedIcon {
flex: none;
color: var(--mantine-color-dimmed);
}
.queuedText {
flex: 1;
min-width: 0;
color: var(--mantine-color-dimmed);
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}

View File

@@ -9,18 +9,24 @@ import { MicButton } from "@/features/dictation/components/mic-button";
interface ChatInputProps { interface ChatInputProps {
onSend: (text: string) => void; onSend: (text: string) => void;
/** Called instead of `onSend` while a turn is streaming: the text is queued
* and sent automatically once the current turn finishes. */
onQueue: (text: string) => void;
onStop: () => void; onStop: () => void;
isStreaming: boolean; isStreaming: boolean;
disabled?: boolean; disabled?: boolean;
} }
/** /**
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent * Message composer. Enter submits, Shift+Enter inserts a newline. While the
* is streaming, the send button becomes a Stop button (calls `stop()`); the * agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
* textarea stays usable so the user can draft the next turn. * dropping it — it is sent automatically once the current turn finishes; the
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
* user can draft / queue the next turn while the agent is busy.
*/ */
export default function ChatInput({ export default function ChatInput({
onSend, onSend,
onQueue,
onStop, onStop,
isStreaming, isStreaming,
disabled, disabled,
@@ -30,17 +36,18 @@ export default function ChatInput({
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true; const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const send = (): void => { const submit = (): void => {
const text = value.trim(); const text = value.trim();
if (!text || isStreaming || disabled) return; if (!text || disabled) return;
onSend(text); if (isStreaming) onQueue(text);
else onSend(text);
setValue(""); setValue("");
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
send(); submit();
} }
}; };
@@ -64,28 +71,43 @@ export default function ChatInput({
{isDictationEnabled && ( {isDictationEnabled && (
<MicButton <MicButton
size="lg" size="lg"
streaming
disabled={isStreaming || disabled} disabled={isStreaming || disabled}
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))} onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
/> />
)} )}
{isStreaming ? ( {isStreaming ? (
<Tooltip label={t("Stop")} withArrow> <Group gap="xs" wrap="nowrap">
<ActionIcon {value.trim().length > 0 && (
size="lg" <Tooltip label={t("Send when the agent finishes")} withArrow>
color="red" <ActionIcon
variant="light" size="lg"
onClick={onStop} variant="filled"
aria-label={t("Stop")} onClick={submit}
> aria-label={t("Queue message")}
<IconPlayerStopFilled size={18} /> >
</ActionIcon> <IconSend size={18} />
</Tooltip> </ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Stop")} withArrow>
<ActionIcon
size="lg"
color="red"
variant="light"
onClick={onStop}
aria-label={t("Stop")}
>
<IconPlayerStopFilled size={18} />
</ActionIcon>
</Tooltip>
</Group>
) : ( ) : (
<Tooltip label={t("Send")} withArrow> <Tooltip label={t("Send")} withArrow>
<ActionIcon <ActionIcon
size="lg" size="lg"
variant="filled" variant="filled"
onClick={send} onClick={submit}
disabled={disabled || value.trim().length === 0} disabled={disabled || value.trim().length === 0}
aria-label={t("Send")} aria-label={t("Send")}
> >

View File

@@ -1,14 +1,24 @@
import { useMemo, useRef } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { generateId } from "ai"; import { generateId } from "ai";
import { Alert, Box, Stack } from "@mantine/core"; import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react"; import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx"; import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx"; import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts"; import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import {
dequeue,
enqueueMessage,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
/** The page the user is currently viewing, sent as chat context. */ /** The page the user is currently viewing, sent as chat context. */
@@ -29,6 +39,15 @@ interface ChatThreadProps {
* in the request body so the server persists it on chat creation; ignored by * in the request body so the server persists it on chat creation; ignored by
* the server for existing chats (the role is read from the chat row). */ * the server for existing chats (the role is read from the chat row). */
roleId?: string | null; roleId?: string | null;
/** Enabled roles for the new-chat empty state (only meaningful when
* `chatId === null`). Rendered as the colored role cards. */
roles?: IAiRole[];
/** Notify the parent which role was picked via a card, so it can update the
* header badge / assistant name for the brand-new chat. */
onRolePicked?: (role: IAiRole) => void;
/** Display name for the assistant label / typing line (the role name);
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for /** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */ * a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void; onTurnFinished: () => void;
@@ -66,6 +85,9 @@ export default function ChatThread({
initialRows, initialRows,
openPage, openPage,
roleId, roleId,
roles,
onRolePicked,
assistantName,
onTurnFinished, onTurnFinished,
}: ChatThreadProps) { }: ChatThreadProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -113,7 +135,55 @@ export default function ChatThread({
// The id only needs to be stable per mount — the parent remounts this via // The id only needs to be stable per mount — the parent remounts this via
// `key` on chat switch, which re-seeds cleanly. // `key` on chat switch, which re-seeds cleanly.
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`); const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
const chatStoreId = chatId ?? stableIdRef.current; // Stable for the LIFETIME of this mount. When a brand-new chat adopts its
// server id, the parent now updates the `chatId` prop WITHOUT remounting this
// thread, so the store id must NOT follow `chatId`: recreating the useChat
// store would wipe the live (just-finished) turn. The server still resolves
// the real chat from `chatId` in the request body (see chatIdRef /
// prepareSendMessagesRequest), so this purely-client store key can stay fixed.
const chatStoreId = stableIdRef.current;
// Pending messages the user composed WHILE a turn was streaming. They are sent
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
// LOCAL state so it is scoped to this conversation: it is cleared when the user
// deliberately switches chat / starts a new chat (the parent remounts this via
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
// message queued during a brand-new chat's first turn is not lost. On Stop or
// error the queue is intentionally preserved (onFinish does not fire then) so
// the user decides what to do with the pending messages.
const [queued, setQueued] = useState<QueuedMessage[]>([]);
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
const queuedRef = useRef<QueuedMessage[]>([]);
const setQueue = useCallback((next: QueuedMessage[]) => {
queuedRef.current = next;
setQueued(next);
}, []);
// Capture the latest `sendMessage` (returned by useChat below) so the flush
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
}, [setQueue]);
const enqueue = useCallback(
(text: string) => {
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
},
[setQueue],
);
const removeQueued = useCallback(
(id: string) => {
setQueue(removeQueuedById(queuedRef.current, id));
},
[setQueue],
);
const transport = useMemo( const transport = useMemo(
() => () =>
@@ -147,37 +217,107 @@ export default function ChatThread({
id: chatStoreId, id: chatStoreId,
messages: initialMessages, messages: initialMessages,
transport, transport,
onFinish: () => onTurnFinished(), // `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand // — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// new chat that fails on its first turn would never invalidate the chat list // stream error (`isError`). Keep calling `onTurnFinished()` on all of them
// nor adopt the server-created chat id (the server still creates the row and // (chat-list refresh + new-chat id adoption must happen even on a failed
// saves the error message). Run the same post-turn path on error so the // first turn), but flush the pending queue ONLY on a clean finish: auto-
// failed chat appears in history immediately instead of after a manual // sending after the user hit Stop — or blindly retrying after a failure —
// refresh. The error itself is still surfaced via `error` below. // would be wrong, so on Stop/disconnect/error the queue is left intact for
onError: () => onTurnFinished(), // the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
if (isAbort || isDisconnect || isError) return;
flushNext();
},
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
// Log the raw failure here for devtools; the UI shows a friendly classified
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
// the onFinish call) so a brand-new chat that fails its first turn is adopted
// and the chat list refreshes immediately rather than after a manual refresh.
onError: (streamError) => {
// 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();
},
}); });
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
const isStreaming = status === "submitted" || status === "streaming"; const isStreaming = status === "submitted" || status === "streaming";
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
return ( return (
<Box className={classes.panel}> <Box className={classes.panel}>
<MessageList messages={messages} isStreaming={isStreaming} /> <MessageList
messages={messages}
isStreaming={isStreaming}
emptyState={roleCardsEmptyState}
assistantName={assistantName}
/>
{error && ( {errorView && (
<Alert <Alert
variant="light" variant="light"
color="red" color="red"
icon={<IconAlertTriangle size={16} />} icon={<IconAlertTriangle size={16} />}
mb="xs" mb="xs"
title={t("Something went wrong")} title={errorView.title}
> >
{describeChatError(error.message ?? "", t)} {errorView.detail}
</Alert> </Alert>
)} )}
<Stack gap={0} className={classes.inputWrapper}> <Stack gap={0} className={classes.inputWrapper}>
{queued.length > 0 && (
<Stack gap={4} className={classes.queuedList}>
{queued.map((m) => (
<Group
key={m.id}
gap={6}
wrap="nowrap"
className={classes.queuedItem}
>
<IconClockHour4 size={14} className={classes.queuedIcon} />
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => removeQueued(m.id)}
aria-label={t("Remove queued message")}
>
<IconX size={12} />
</ActionIcon>
</Group>
))}
</Stack>
)}
<ChatInput <ChatInput
onSend={(text) => sendMessage({ text })} onSend={(text) => sendMessage({ text })}
onQueue={enqueue}
onStop={stop} onStop={stop}
isStreaming={isStreaming} isStreaming={isStreaming}
/> />

View File

@@ -18,8 +18,31 @@ import {
useRenameAiChatMutation, useRenameAiChatMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts"; } from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts"; import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
/**
* The dimmed second line of a chat row: how long ago the chat was created and
* the document it was created in. Its own component so the self-updating
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
*/
function ChatMetaLine({
createdAt,
pageTitle,
}: {
createdAt: string;
pageTitle?: string | null;
}) {
const { t } = useTranslation();
const ago = useTimeAgo(createdAt);
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
return (
<Text size="xs" c="dimmed" lineClamp={1}>
{ago} · {pageTitle || t("No document")}
</Text>
);
}
interface ConversationListProps { interface ConversationListProps {
activeChatId: string | null; activeChatId: string | null;
onSelect: (chatId: string) => void; onSelect: (chatId: string) => void;
@@ -127,16 +150,24 @@ export default function ConversationList({
} }
}} }}
> >
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
{chat.roleName && ( <Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}> {chat.roleName && (
{chat.roleEmoji || "🤖"} <Text
size="sm"
span
title={chat.roleName}
style={{ flex: "none" }}
>
{chat.roleEmoji || "🤖"}
</Text>
)}
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{chat.title || t("Untitled chat")}
</Text> </Text>
)} </Group>
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}> <ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
{chat.title || t("Untitled chat")} </Box>
</Text>
</Group>
<Menu shadow="md" width={180} position="bottom-end"> <Menu shadow="md" width={180} position="bottom-end">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon

View File

@@ -114,14 +114,18 @@ export default function MessageItem({
{(() => { {(() => {
const errorText = (message.metadata as { error?: string } | undefined)?.error; const errorText = (message.metadata as { error?: string } | undefined)?.error;
if (!errorText) return null; if (!errorText) return null;
// Same classified-error banner as the live chat: a heading naming the
// cause plus a one-line detail.
const errorView = describeChatError(errorText, t);
return ( return (
<Alert <Alert
variant="light" variant="light"
color="red" color="red"
icon={<IconAlertTriangle size={16} />} icon={<IconAlertTriangle size={16} />}
mt={4} mt={4}
title={errorView.title}
> >
{describeChatError(errorText, t)} {errorView.detail}
</Alert> </Alert>
); );
})()} })()}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx"; import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx"; import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps { interface MessageListProps {
@@ -43,23 +43,38 @@ interface MessageListProps {
const BOTTOM_THRESHOLD = 40; const BOTTOM_THRESHOLD = 40;
/** /**
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the * Whether to show the standalone "Thinking…" indicator. It bridges every
* gap between sending and the first streamed content, so it shows only while a * gap in a turn where the assistant is working but nothing visible is actively
* turn is in flight AND the latest assistant message has nothing visible yet: * being produced yet — so it shows while a turn is in flight AND the latest
* assistant message's LAST part is not live output:
* - the last message is still the user's (assistant hasn't started a row), or * - the last message is still the user's (assistant hasn't started a row), or
* - the last (assistant) message has no non-empty text and no tool part. * - the assistant row has no parts yet, or
* Once any text/tool part arrives, MessageItem renders it and this hides. * - its last part is an empty/whitespace text part, or
* - its last part is a finished/errored tool (the model is thinking about the
* next step between tool calls).
* It hides only while output is actively rendering: a non-empty streaming text
* part, or a tool that is still running (ToolCallCard shows its own Loader).
*/ */
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean { export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false; if (!isStreaming) return false;
const last = messages[messages.length - 1]; const last = messages[messages.length - 1];
if (!last) return true; // submitted with nothing rendered yet. if (!last) return true; // submitted with nothing rendered yet.
if (last.role !== "assistant") return true; // assistant row not started. if (last.role !== "assistant") return true; // assistant row not started.
const hasVisible = last.parts.some( const lastPart = last.parts[last.parts.length - 1];
(p) => if (!lastPart) return true; // assistant row exists but has no parts yet.
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type), // The answer text is actively streaming in -> MessageItem renders it; no dots.
); if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false;
return !hasVisible; // A tool still in flight shows its own Loader in ToolCallCard -> no dots.
if (
isToolPart(lastPart.type) &&
toolRunState((lastPart as unknown as ToolUiPart).state) === "running"
) {
return false;
}
// Otherwise the turn is in flight but nothing is actively producing visible
// output yet: a finished/errored tool with no follow-up content, or an empty
// trailing text part. The model is thinking between steps -> show the dots.
return true;
} }
/** /**

View File

@@ -0,0 +1,65 @@
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* flex-start keeps the first row reachable when the wrapped cards overflow and
the container scrolls. With align-content: center, an overflowing top row is
pushed out of the scrollable area and becomes unreachable. The parent Mantine
Center still vertically centers the whole block when it fits. */
align-content: flex-start;
gap: 10px;
/* Cap the height so a large number of roles scrolls instead of blowing out
the empty chat area. */
max-height: 100%;
overflow-y: auto;
padding: 8px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
/* Grow to fill the row so cards use the available window width instead of
leaving large side gaps; the flex-basis sets how many fit per row before
wrapping (≈2 columns at the default window width, more as it widens). */
flex: 1 1 240px;
min-width: 200px;
max-width: 360px;
min-height: 90px;
padding: 12px 10px;
border-radius: var(--mantine-radius-md);
border: 2px solid transparent;
cursor: pointer;
text-align: center;
transition:
transform 120ms ease,
box-shadow 120ms ease,
border-color 120ms ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
}
.emoji {
font-size: 22px;
line-height: 1;
}
/* The description: small and slightly muted, inheriting the card's color. We
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
with the card's inline color. */
.description {
opacity: 0.8;
line-height: 1.3;
/* Break long unbreakable tokens (URLs, long foreign words) in the
admin-configured description so they wrap instead of overflowing the card
width now that the line clamp no longer caps the text. */
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RoleCards from "./role-cards";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
// does not implement. Provide a minimal stub so the provider can render.
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
});
const roles: IAiRole[] = [
{
id: "r1",
name: "Pirate",
emoji: "🏴‍☠️",
description: "Talks like a pirate",
enabled: true,
},
{
id: "r2",
name: "Grandpa",
emoji: null,
description: null,
enabled: true,
},
];
function renderCards(onPick = vi.fn()) {
render(
<MantineProvider>
<RoleCards roles={roles} onPick={onPick} />
</MantineProvider>,
);
return onPick;
}
describe("RoleCards", () => {
it("renders one card per role with name, emoji, and description", () => {
renderCards();
expect(screen.getByText("Pirate")).toBeDefined();
expect(screen.getByText("Talks like a pirate")).toBeDefined();
expect(screen.getByText("Grandpa")).toBeDefined();
// The emoji is shown for the role that has one.
expect(screen.getByText("🏴‍☠️")).toBeDefined();
});
it("does NOT render a Universal assistant card", () => {
renderCards();
expect(screen.queryByText("Universal assistant")).toBeNull();
});
it("calls onPick with the role object when a card is clicked", () => {
const onPick = renderCards();
fireEvent.click(screen.getByText("Pirate"));
expect(onPick).toHaveBeenCalledWith(roles[0]);
});
});

View File

@@ -0,0 +1,78 @@
import { UnstyledButton, Text } from "@mantine/core";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
import classes from "@/features/ai-chat/components/role-cards.module.css";
interface RoleCardsProps {
/** The enabled roles to render (one card each). */
roles: IAiRole[];
/** Called with the picked role when a card is clicked. The parent starts the
* chat with this role (binds it and sends the opening message). */
onPick: (role: IAiRole) => void;
}
/**
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
* they render correctly in both light and dark themes; the CSS module owns only
* the layout. The card shows the emoji (if any), the role name, and a small
* dimmed description line (if any).
*/
function RoleCard({
color,
name,
emoji,
description,
onClick,
}: {
color: string;
name: string;
emoji?: string | null;
description?: string | null;
onClick: () => void;
}) {
return (
<UnstyledButton
className={classes.card}
style={{
backgroundColor: `var(--mantine-color-${color}-light)`,
color: `var(--mantine-color-${color}-light-color)`,
}}
title={description ?? name}
onClick={onClick}
>
{emoji && <span className={classes.emoji}>{emoji}</span>}
<Text size="sm" fw={600} lineClamp={2}>
{name}
</Text>
{description && (
<Text size="xs" className={classes.description}>
{description}
</Text>
)}
</UnstyledButton>
);
}
/**
* Colored role cards rendered as the empty-state of a brand-new chat. There is
* no Universal assistant card — the universal assistant is the implicit default
* the user gets by simply typing into the composer without picking a card.
* Clicking a card immediately STARTS the chat with that role (the parent binds
* the role to the new chat and sends the opening message).
*/
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
return (
<div className={classes.container}>
{roles.map((role, index) => (
<RoleCard
key={role.id}
color={roleCardColor(index)}
name={role.name}
emoji={role.emoji}
description={role.description}
onClick={() => onPick(role)}
/>
))}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { showTypingIndicator } from "@/features/ai-chat/components/message-list.
/** /**
* Pure-helper tests for the typing-indicator bridging logic that the internal * Pure-helper tests for the typing-indicator bridging logic that the internal
* chat and the public share widget now share. This is the behavior that decides * chat and the public share widget now share. This is the behavior that decides
* whether the animated "AI agent is typing…" placeholder shows in the gap * whether the animated "Thinking…" placeholder shows in the gap
* between sending and the first streamed token. * between sending and the first streamed token.
*/ */
const msg = ( const msg = (
@@ -52,4 +52,34 @@ describe("showTypingIndicator", () => {
showTypingIndicator([msg("assistant", [toolPart])], true), showTypingIndicator([msg("assistant", [toolPart])], true),
).toBe(false); ).toBe(false);
}); });
it("shows while streaming after a tool has finished (thinking between steps)", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [doneTool])], true),
).toBe(true);
});
it("shows while streaming when a finished tool is the last part after some text", () => {
const text = { type: "text", text: "Let me check" } as unknown as UIMessage["parts"][number];
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [text, doneTool])], true),
).toBe(true);
});
it("hides while a tool is still running", () => {
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [runningTool])], true),
).toBe(false);
});
it("hides once the assistant streams non-empty text after a finished tool", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
const text = { type: "text", text: "The answer is 42" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [doneTool, text])], true),
).toBe(false);
});
}); });

View File

@@ -19,8 +19,10 @@ interface TypingIndicatorProps {
* the real assistant message once content starts arriving. * the real assistant message once content starts arriving.
* *
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads * Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
* as the assistant's bubble taking shape. The label and typing line use the * as the assistant's bubble taking shape. The dimmed label uses the configured
* configured identity name when provided, otherwise the generic "AI agent". * identity name when provided (otherwise the generic "AI agent"), while the
* typing line is always the generic "Thinking…" (it never includes the
* role/identity name).
*/ */
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) { export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -38,7 +40,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
<span /> <span />
</span> </span>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")} {t("Thinking…")}
</Text> </Text>
</Group> </Group>
</Box> </Box>

View File

@@ -4,7 +4,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
@@ -75,6 +75,31 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
enabled: !!chatId, enabled: !!chatId,
}); });
// useInfiniteQuery only fetches the first page on its own. The hook's contract
// (and both the Markdown export and the model-history seed) require the
// COMPLETE thread, so keep pulling subsequent pages until the server reports
// none remain. The isFetchingNextPage guard issues one request at a time;
// when chatId is undefined the query is disabled and hasNextPage is false, so
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
// and isFetchingNextPage false — without this guard the effect would re-fire
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
// latches the last next-page failure and clears once a fetch succeeds.
useEffect(() => {
if (
query.hasNextPage &&
!query.isFetchingNextPage &&
!query.isFetchNextPageError
) {
void query.fetchNextPage();
}
}, [
query.hasNextPage,
query.isFetchingNextPage,
query.isFetchNextPageError,
query.fetchNextPage,
]);
const data = useMemo<IAiChatMessageRow[] | undefined>(() => { const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
if (!query.data) return undefined; if (!query.data) return undefined;
return query.data.pages.flatMap((p) => p.items); return query.data.pages.flatMap((p) => p.items);

View File

@@ -19,6 +19,12 @@ export interface IAiChat {
// Null when the chat has no role or the role was soft-deleted. // Null when the chat has no role or the role was soft-deleted.
roleName?: string | null; roleName?: string | null;
roleEmoji?: string | null; roleEmoji?: string | null;
// The document the chat was created in (ai_chats.page_id). Null when started
// outside any document.
pageId?: string | null;
// Denormalized via a JOIN in the chat list response: the origin page's title.
// Null when there is no origin page (or it was hard-deleted).
pageTitle?: string | null;
} }
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */ /** Supported model drivers (mirrors the server `AI_DRIVERS`). */

View File

@@ -0,0 +1,317 @@
import { describe, it, expect } from "vitest";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Tests for the client-only Markdown export builder. The output embeds a live
* `new Date().toISOString()` export timestamp; we never assert that value, only
* the deterministic structure (headings, numbering, fenced blocks, totals).
*
* A pass-through translator keeps role/tool labels predictable so the
* structural assertions are stable without an i18n runtime.
*/
const t = (key: string, values?: Record<string, unknown>): string => {
if (values && typeof values.name === "string") {
return key.replace("{{name}}", values.name);
}
return key;
};
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
return {
id: partial.id ?? "id",
role: partial.role ?? "user",
content: partial.content ?? null,
metadata: partial.metadata ?? null,
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
};
}
describe("buildChatMarkdown — structure", () => {
it("emits the title heading, chat id and message count", () => {
const md = buildChatMarkdown({
title: "My chat",
chatId: "chat-123",
rows: [],
t,
});
expect(md).toContain("# My chat");
expect(md).toContain("- Chat ID: `chat-123`");
expect(md).toContain("- Messages: 0");
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
});
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
expect(
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
expect(
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
});
it("numbers rows sequentially with role headings", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({ role: "assistant", content: "hello" }),
row({ role: "user", content: "again" }),
],
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("## 3. You");
// Heading numbering is strictly index+1, not e.g. role-relative.
expect(md).not.toContain("## 0.");
});
it("renders the per-row text content from `content` when no metadata.parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "plain body" })],
t,
});
expect(md).toContain("plain body");
});
});
describe("buildChatMarkdown — text parts", () => {
it("skips empty / whitespace-only text parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "ignored-content",
metadata: {
parts: [
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "kept line" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
},
}),
],
t,
});
expect(md).toContain("kept line");
// Whitespace-only part contributed no block of its own.
expect(md).not.toContain(" \n\n");
// When metadata.parts exists, the plain `content` fallback is NOT used.
expect(md).not.toContain("ignored-content");
});
});
describe("buildChatMarkdown — tool parts", () => {
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: { pageId: "p1" },
output: { id: "p1", title: "Home" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// Known tool name maps to its label key; raw name in backticks; done state.
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
expect(md).toContain("Input:");
expect(md).toContain("Output:");
// Fenced JSON blocks contain the stringified payloads.
expect(md).toContain('"pageId": "p1"');
expect(md).toContain('"title": "Home"');
expect(md).toContain("```json");
});
it("renders the generic label for an unknown tool and surfaces errorText", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-mysteryTool",
state: "output-error",
input: { a: 1 },
errorText: "boom",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
expect(md).toContain("**Error:** boom");
});
it("does not throw on a circular tool input (falls back to String)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circular: any = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "input-available",
input: circular,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
}),
).not.toThrow();
});
});
describe("buildChatMarkdown — fence anti-breakout", () => {
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
// Tool input whose stringified string form contains a literal ``` run.
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
// A bare string passes through stringify() verbatim.
input: "before ``` after",
output: "x",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// The fence around the 3-backtick content must use at least 4 backticks so
// the embedded ``` run cannot terminate the block.
expect(md).toContain("````json\nbefore ``` after\n````");
// Robust anti-breakout check: the opening fence delimiter is strictly
// longer than the longest backtick run inside the wrapped content. (A naive
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
// textually contains the 3-backtick substring.)
const open = md.match(/(`{3,})json\nbefore/);
expect(open).not.toBeNull();
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
});
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: "a ```` b",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("`````json\na ```` b\n`````");
});
});
describe("buildChatMarkdown — token totals", () => {
it("prints the total-tokens line only when the summed usage is > 0", () => {
const withTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(withTokens).toContain("- Total tokens: 15");
// Per-row usage footer too.
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
});
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
const noTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
}),
],
t,
});
expect(noTokens).not.toContain("- Total tokens:");
});
it("uses totalTokens when present rather than summing in/out", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
}),
],
t,
});
expect(md).toContain("- Total tokens: 99");
});
});

View File

@@ -6,48 +6,163 @@ import { describeChatError } from "./error-message";
const t = (key: string) => key; const t = (key: string) => key;
describe("describeChatError", () => { describe("describeChatError", () => {
it('surfaces a provider "402: ..." stream error verbatim', () => { it('maps a {"statusCode":403} body to the disabled heading', () => {
expect(describeChatError("402: Insufficient credits", t)).toBe(
"402: Insufficient credits",
);
});
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
// A provider message mentioning the number 403 must be surfaced verbatim,
// never folded into the "AI chat is disabled" gating message.
const msg = "429: rate limited after 403 attempts";
expect(describeChatError(msg, t)).toBe(msg);
});
it('maps a {"statusCode":403} body to the disabled message', () => {
const body = '{"statusCode":403,"message":"Forbidden"}'; const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toBe( expect(describeChatError(body, t)).toEqual({
"AI chat is disabled for this workspace.", title: "AI chat is disabled",
); detail: "AI chat is disabled for this workspace.",
});
}); });
it('maps a {"statusCode":503} body to the not-configured message', () => { it('maps a {"statusCode":503} body to the not-configured heading', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}'; const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toBe( expect(describeChatError(body, t)).toEqual({
"The AI provider is not configured. Ask an administrator to set it up.", title: "AI provider not configured",
detail:
"The AI provider is not configured. Ask an administrator to set it up.",
});
});
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
expect(
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
).toBe("Lost connection to the AI provider");
});
it('classifies "fetch failed" as a lost-connection error', () => {
expect(describeChatError("fetch failed", t).title).toBe(
"Lost connection to the AI provider",
); );
}); });
it('falls back to the generic message for "An error occurred."', () => { it("classifies ETIMEDOUT as a timeout", () => {
expect(describeChatError("An error occurred.", t)).toBe( expect(describeChatError("ETIMEDOUT", t).title).toBe(
"The AI agent could not respond. Please try again.", "The AI provider timed out",
); );
}); });
it('falls back to the generic message for "Internal server error"', () => { it('classifies "504: Gateway Timeout" as a timeout', () => {
expect(describeChatError("Internal server error", t)).toBe( expect(describeChatError("504: Gateway Timeout", t).title).toBe(
"The AI agent could not respond. Please try again.", "The AI provider timed out",
); );
}); });
it("falls back to the generic message for empty input", () => { it('classifies "429: Too Many Requests" as rate limited', () => {
expect(describeChatError("", t)).toBe( expect(describeChatError("429: Too Many Requests", t).title).toBe(
"The AI agent could not respond. Please try again.", "Rate limited by the AI provider",
);
});
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
// Regression intent: a provider message mentioning the number 403 must never
// be folded into the "AI chat is disabled" gating heading. Here the 429
// signature wins (checked before any bare-403 logic exists), so it maps to
// the rate-limit category instead.
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it("classifies a context-window overflow as too-large", () => {
expect(
describeChatError(
"This model's maximum context length is 128000 tokens",
t,
).title,
).toBe("The conversation is too large");
});
it('classifies "402: Insufficient credits" as quota exceeded', () => {
expect(describeChatError("402: Insufficient credits", t).title).toBe(
"AI provider quota exceeded",
);
});
it('classifies "401: Unauthorized" as an auth failure', () => {
expect(describeChatError("401: Unauthorized", t).title).toBe(
"AI provider authentication failed",
);
});
it("falls back to the generic heading + detail for empty input", () => {
expect(describeChatError("", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it('falls back to the generic heading + detail for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toEqual({
title: "Something went wrong",
detail: "The AI agent could not respond. Please try again.",
});
});
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
expect(describeChatError("418: I'm a teapot", t)).toEqual({
title: "Something went wrong",
detail: "418: I'm a teapot",
});
});
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
// The real status (500) leads the string; the "401" lives in the snippet and
// must not trigger the auth category. The verbatim provider text is surfaced.
const body =
"500: Server error | response body: model gpt-4o-401-preview not found";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it("does NOT treat a passing mention of billing as a quota error", () => {
// "billing" is no longer a quota signature; the verbatim text is surfaced.
const body = "502: Bad Gateway | response body: see our billing page";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
});
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
const view = describeChatError("429: rate limited after 403 attempts", t);
expect(view.title).toBe("Rate limited by the AI provider");
expect(view.title).not.toBe("AI chat is disabled");
});
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
// The textual rate-limit phrase lives only in the response-body snippet, and
// the leading 500 is not a classified numeric code, so it must not leak into
// the rate-limit category. (The detail itself falls back to the generic line
// here because the leading message contains "Internal Server Error", which
// providerDetail suppresses — the title is what this case pins.)
const body =
"500: Internal Server Error | response body: rate limit info: see our docs";
expect(describeChatError(body, t).title).toBe("Something went wrong");
expect(describeChatError(body, t).title).not.toBe(
"Rate limited by the AI provider",
);
});
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
// The 503 leads the string but is not a classified numeric code, and the
// ETIMEDOUT signature appears only in the body, so it must not leak into the
// timeout category; the verbatim text is surfaced under the generic heading.
const body = "503: x | response body: ETIMEDOUT appears in this log line";
expect(describeChatError(body, t)).toEqual({
title: "Something went wrong",
detail: body,
});
expect(describeChatError(body, t).title).not.toBe(
"The AI provider timed out",
); );
}); });
}); });

View File

@@ -1,24 +1,174 @@
/** /**
* Turn an AI chat error message into a friendly inline string. Used for BOTH the * A classified AI chat error: a short bold heading naming the cause category and
* live `useChat().error` (its `.message`) and a persisted assistant error stored * a one-line human-readable detail / next step. Both strings are already passed
* in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error * through `t`, so callers render them directly.
* body carrying a numeric "statusCode" field (matched precisely, not by bare */
* substring, so a provider message that merely contains "403"/"503"/"disabled" is export interface ChatErrorView {
* never misclassified). Everything else — provider stream failures forwarded as title: string;
* "<status>: <message>" (402 credits, 429 rate limit, ...) — is surfaced verbatim. detail: string;
}
/**
* Turn an AI chat error message into a friendly heading + detail. Used for BOTH
* the live `useChat().error` (its `.message`) and a persisted assistant error in
* `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
* body carrying a numeric "statusCode" (matched precisely, not by bare substring,
* so a provider message that merely contains "403"/"503" is never misclassified).
* Known provider/network failures (connection reset, timeout, rate limit, context
* overflow, quota, auth) are mapped to a clear category; anything else falls back
* to the raw provider detail (or a generic line) under the original heading.
*/ */
export function describeChatError( export function describeChatError(
message: string, message: string,
t: (key: string) => string, t: (key: string) => string,
): string { ): ChatErrorView {
const msg = message ?? ""; const msg = message ?? "";
if (/"statusCode"\s*:\s*403\b/.test(msg)) { if (/"statusCode"\s*:\s*403\b/.test(msg)) {
return t("AI chat is disabled for this workspace."); return {
title: t("AI chat is disabled"),
detail: t("AI chat is disabled for this workspace."),
};
} }
if (/"statusCode"\s*:\s*503\b/.test(msg)) { if (/"statusCode"\s*:\s*503\b/.test(msg)) {
return t("The AI provider is not configured. Ask an administrator to set it up."); return {
title: t("AI provider not configured"),
detail: t(
"The AI provider is not configured. Ask an administrator to set it up.",
),
};
} }
return providerDetail(msg) ?? t("The AI agent could not respond. Please try again.");
const category = classifyProviderError(msg);
if (category) {
return { title: t(category.title), detail: t(category.detail) };
}
// Unknown error: surface the raw provider detail when it is informative,
// otherwise a generic line. The heading stays the original generic one.
return {
title: t("Something went wrong"),
detail:
providerDetail(msg) ??
t("The AI agent could not respond. Please try again."),
};
}
interface ErrorCategory {
/** English key for the bold heading. */
title: string;
/** English key for the one-line explanation. */
detail: string;
}
/**
* Map a provider/network error string to a friendly category. Order matters: the
* most specific signatures are tested first. Returns null when nothing matches,
* so the caller can fall back to the raw provider text. The English keys returned
* here are passed through `t` by the caller.
*
* The server formats provider errors as "<statusCode>: <message> | response body:
* <snippet>" (see server-side describeProviderError), so the HTTP status is always
* the LEADING token. We match a numeric code only when it leads the string, so a
* number inside the response-body snippet never triggers a category; textual
* signatures are matched only against the leading message (before the response
* body), so a phrase inside the snippet never triggers a category either.
*/
function classifyProviderError(msg: string): ErrorCategory | null {
const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
// The server appends "| response body: <snippet>" to provider errors; match
// textual signatures only against the leading provider message so a phrase
// inside the response-body snippet never triggers a wrong category. The numeric
// status code is read from the start of the full string above.
const head = msg.split(/\|\s*response body:/i)[0];
// The browser's OWN fetch-failure messages — WebKit/Safari "Load failed",
// Chrome "Failed to fetch", Firefox "NetworkError when attempting to fetch
// resource". These mean the streaming connection between the browser and THIS
// server (/api/ai-chat/stream) dropped mid-answer: the browser<->server link,
// NOT the server<->AI-provider link, so do NOT blame the provider. A failed
// fetch carries no status/body, so the browser has no further detail — the real
// cause is in the server logs (the stream controller logs the disconnect) and
// the reverse proxy (often buffering or timing out the long-lived SSE).
if (/failed to fetch|load failed|networkerror/i.test(head)) {
return {
title: "Lost connection to the server",
detail:
"The streaming connection to the server dropped before the answer finished. The browser reports no further detail — the cause is in the server logs and the reverse proxy (often buffering or timing out the stream). Reload and try again.",
};
}
// Connection dropped / provider unreachable. ECONNRESET is the production case:
// the LLM socket was reset mid-stream (surfaced by the server's error
// formatter). "terminated" is scoped to a connection/stream context so it does
// not match benign "... was terminated" messages.
if (
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
head,
)
) {
return {
title: "Lost connection to the AI provider",
detail:
"The connection to the AI provider dropped before the answer finished. Please try again.",
};
}
// Timeout.
if (
code === "504" ||
code === "408" ||
/ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
) {
return {
title: "The AI provider timed out",
detail: "The AI provider took too long to respond. Please try again.",
};
}
// Rate limited.
if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
return {
title: "Rate limited by the AI provider",
detail:
"The AI provider is rate-limiting requests. Wait a moment and try again.",
};
}
// Context window / token budget exceeded.
if (
code === "413" ||
/context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
head,
)
) {
return {
title: "The conversation is too large",
detail:
"The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
};
}
// Out of credits / quota / payment required.
if (
code === "402" ||
/payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
head,
)
) {
return {
title: "AI provider quota exceeded",
detail:
"The AI provider rejected the request because of credits or quota. Check the provider account.",
};
}
// Authentication / bad API key.
if (
code === "401" ||
/\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
) {
return {
title: "AI provider authentication failed",
detail:
"The AI provider rejected the credentials. Ask an administrator to check the API key.",
};
}
return null;
} }
/** /**

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
describe("enqueueMessage", () => {
it("appends a message to the end of the queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
const next = enqueueMessage(queue, { id: "b", text: "second" });
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
enqueueMessage(queue, { id: "b", text: "second" });
expect(queue).toEqual([{ id: "a", text: "first" }]);
});
});
describe("dequeue", () => {
it("returns {head:null, rest:[]} for an empty queue", () => {
expect(dequeue([])).toEqual({ head: null, rest: [] });
});
it("returns the first item as head and the remainder as rest", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const { head, rest } = dequeue(queue);
expect(head).toEqual({ id: "a", text: "first" });
expect(rest).toEqual([
{ id: "b", text: "second" },
{ id: "c", text: "third" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
dequeue(queue);
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("removeQueuedById", () => {
it("removes the matching id and leaves the others", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const next = removeQueuedById(queue, "b");
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "c", text: "third" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
expect(removeQueuedById(queue, "missing")).toEqual([
{ id: "a", text: "first" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
removeQueuedById(queue, "a");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];
queue = enqueueMessage(queue, { id: "1", text: "one" });
queue = enqueueMessage(queue, { id: "2", text: "two" });
queue = enqueueMessage(queue, { id: "3", text: "three" });
const order: string[] = [];
while (queue.length > 0) {
const { head, rest } = dequeue(queue);
if (head) order.push(head.text);
queue = rest;
}
expect(order).toEqual(["one", "two", "three"]);
});
});

View File

@@ -0,0 +1,34 @@
// Pure FIFO helpers for the AI-chat "send while the agent is busy" queue.
// Kept side-effect free so they can be unit-tested without React.
export interface QueuedMessage {
id: string;
text: string;
}
/** Append a message to the end of the queue (returns a new array). */
export function enqueueMessage(
queue: QueuedMessage[],
message: QueuedMessage,
): QueuedMessage[] {
return [...queue, message];
}
/** Split the queue into its first item (`head`) and the remainder (`rest`).
* `head` is null when the queue is empty. Does not mutate the input. */
export function dequeue(queue: QueuedMessage[]): {
head: QueuedMessage | null;
rest: QueuedMessage[];
} {
if (queue.length === 0) return { head: null, rest: [] };
const [head, ...rest] = queue;
return { head, rest };
}
/** Remove the queued message with the given id (returns a new array). */
export function removeQueuedById(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
describe("roleCardColor", () => {
it("has a 10-color palette", () => {
expect(ROLE_CARD_PALETTE).toHaveLength(10);
});
it("maps index 0 to the first palette color (blue)", () => {
expect(roleCardColor(0)).toBe("blue");
expect(roleCardColor(1)).toBe("grape");
});
it("wraps around at the end of the palette", () => {
expect(roleCardColor(10)).toBe("blue");
expect(roleCardColor(11)).toBe("grape");
});
it("is safe for negative indices", () => {
expect(roleCardColor(-1)).toBe("violet");
expect(roleCardColor(-10)).toBe("blue");
});
});

View File

@@ -0,0 +1,25 @@
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
// these names by index; the colors are applied via theme-aware Mantine CSS vars
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
// Universal assistant uses neutral `gray` separately (not part of this palette).
export const ROLE_CARD_PALETTE = [
"blue",
"grape",
"teal",
"orange",
"pink",
"cyan",
"lime",
"indigo",
"red",
"violet",
] as const;
/**
* Pick a palette color name for a role card by its index. Cycles through the
* palette and is safe for negative indices.
*/
export function roleCardColor(index: number): string {
const len = ROLE_CARD_PALETTE.length;
return ROLE_CARD_PALETTE[((index % len) + len) % len];
}

View File

@@ -126,7 +126,7 @@ function CommentListItem({
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1}> <Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name} {comment.creator.name}
</Text> </Text>
@@ -155,7 +155,7 @@ function CommentListItem({
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
<Text size="xs" fw={500} c="dimmed"> <Text size="xs" fw={500} c="dimmed" lh={1.1}>
{createdAtAgo} {createdAtAgo}
</Text> </Text>
</Group> </Group>

View File

@@ -11,6 +11,7 @@ import {
Badge, Badge,
Text, Text,
ScrollArea, ScrollArea,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item"; import CommentListItem from "@/features/comment/components/comment-list-item";
import { import {
@@ -26,12 +27,16 @@ import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; import { IconArrowUp, IconMessageOff, IconX } from "@tabler/icons-react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() { interface CommentListWithTabsProps {
onClose?: () => void;
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
@@ -194,28 +199,50 @@ function CommentListWithTabs() {
overflow: "hidden", overflow: "hidden",
}} }}
> >
<Tabs.List justify="center"> {/* Header row: full-width centered tab list with the close button overlaid on the right. */}
<Tabs.Tab <div style={{ position: "relative" }}>
value="open" <Tabs.List justify="center">
leftSection={ <Tabs.Tab
<Badge size="sm" variant="light" color="blue"> value="open"
{activeComments.length} leftSection={
</Badge> <Badge size="sm" variant="light" color="blue">
} {activeComments.length}
> </Badge>
{t("Open")} }
</Tabs.Tab> >
<Tabs.Tab {t("Open")}
value="resolved" </Tabs.Tab>
leftSection={ <Tabs.Tab
<Badge size="sm" variant="light" color="green"> value="resolved"
{resolvedComments.length} leftSection={
</Badge> <Badge size="sm" variant="light" color="green">
} {resolvedComments.length}
> </Badge>
{t("Resolved")} }
</Tabs.Tab> >
</Tabs.List> {t("Resolved")}
</Tabs.Tab>
</Tabs.List>
{onClose && (
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("Close")}
style={{
position: "absolute",
right: 0,
top: "50%",
// Nudge the close button slightly up to align with the tab labels.
transform: "translateY(calc(-50% - 4px))",
}}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
)}
</div>
<ScrollArea <ScrollArea
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
@@ -365,7 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
flex: "0 0 auto", flex: "0 0 auto",
borderTop: "1px solid var(--mantine-color-default-border)", borderTop: "1px solid var(--mantine-color-default-border)",
paddingTop: "var(--mantine-spacing-sm)", paddingTop: "var(--mantine-spacing-sm)",
paddingBottom: 25, paddingBottom: 10,
position: "relative", position: "relative",
}} }}
> >
@@ -374,7 +401,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
size="sm" size="sm"
avatarUrl={currentUser?.user?.avatarUrl} avatarUrl={currentUser?.user?.avatarUrl}
name={currentUser?.user?.name} name={currentUser?.user?.name}
style={{ flexShrink: 0, marginTop: 10 }} style={{ flexShrink: 0, marginTop: 2 }}
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor <CommentEditor
@@ -396,7 +423,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
onClick={handleSave} onClick={handleSave}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
loading={isLoading} loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }} style={{ position: "absolute", right: 8, bottom: 15 }}
> >
<IconArrowUp size={16} /> <IconArrowUp size={16} />
</ActionIcon> </ActionIcon>

View File

@@ -3,7 +3,12 @@
} }
.textSelection { .textSelection {
margin-top: 2px; /* Breathing room below the comment header (author + timestamp) so the
quote does not stick to the timestamp when it is the first block. */
margin-top: 8px;
/* Align the quote's left bar with the comment body text left edge
(the comment editor insets its text by 6px). */
margin-left: 6px;
border-left: 2px solid var(--mantine-color-gray-6); border-left: 2px solid var(--mantine-color-gray-6);
padding: 6px; padding: 6px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);

View File

@@ -0,0 +1,24 @@
.recordingWrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Translucent red halo that sits behind the stop button and scales with the
live microphone level (scale set inline from audioLevel). Radius follows the
ActionIcon's own radius so the halo matches the button's rounded-square
outline instead of being a circle. */
.pulse {
position: absolute;
inset: 0;
border-radius: var(--mantine-radius-default);
background-color: var(--mantine-color-red-5);
opacity: 0.35;
transform-origin: center;
transform: scale(1);
transition: transform 90ms linear;
pointer-events: none;
will-change: transform;
z-index: 0;
}

View File

@@ -3,6 +3,8 @@ import { ActionIcon, Loader, Tooltip } from "@mantine/core";
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react"; import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDictation } from "@/features/dictation/hooks/use-dictation"; import { useDictation } from "@/features/dictation/hooks/use-dictation";
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
import classes from "./mic-button.module.css";
interface MicButtonProps { interface MicButtonProps {
onText: (text: string) => void; onText: (text: string) => void;
@@ -11,6 +13,14 @@ interface MicButtonProps {
// Mantine ActionIcon size token; "lg" matches the chat composer, "md" the // Mantine ActionIcon size token; "lg" matches the chat composer, "md" the
// editor toolbar. // editor toolbar.
size?: "md" | "lg"; size?: "md" | "lg";
// Optional Mantine color override for the idle/transcribing states (the
// recording state stays red). Defaults to the theme primary when omitted.
color?: string;
// Optional explicit glyph size override; defaults to the size-token value.
iconSize?: number;
// When true, use the streaming (Silero-VAD) dictation controller, which emits
// text progressively as the user pauses; otherwise use the batch controller.
streaming?: boolean;
} }
/** /**
@@ -24,35 +34,64 @@ export const MicButton: FC<MicButtonProps> = ({
onStart, onStart,
disabled, disabled,
size = "lg", size = "lg",
color,
iconSize,
streaming = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { status, start, stop } = useDictation({ onText, onStart }); // Call BOTH hooks unconditionally to respect the rules of hooks: which one is
const iconSize = size === "lg" ? 18 : 16; // active is a render-time choice, but both must be invoked every render. This
// is safe because both controllers are inert until start() is called — neither
// opens the mic on mount — so the unused one costs nothing.
const batchCtl = useDictation({ onText, onStart });
const streamingCtl = useStreamingDictation({ onText, onStart });
const ctl = streaming ? streamingCtl : batchCtl;
const { status, start, stop, audioLevel } = ctl;
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
if (status === "recording") { if (status === "recording") {
// Live volume-driven halo: the scale follows the current mic level.
const haloScale = 1 + Math.min(1, audioLevel) * 0.9;
return ( return (
<Tooltip label={t("Stop recording")} withArrow> <Tooltip label={t("Stop recording")} withArrow>
<ActionIcon <span className={classes.recordingWrap}>
size={size} <span
color="red" className={classes.pulse}
variant="light" style={{ transform: `scale(${haloScale})` }}
onClick={stop} aria-hidden="true"
aria-label={t("Stop recording")} />
> <ActionIcon
<IconPlayerStopFilled size={iconSize} /> size={size}
</ActionIcon> color="red"
variant="light"
onClick={stop}
aria-label={t("Stop recording")}
style={{ position: "relative", zIndex: 1 }}
>
<IconPlayerStopFilled size={resolvedIconSize} />
</ActionIcon>
</span>
</Tooltip> </Tooltip>
); );
} }
if (status === "transcribing" || status === "error") { if (
status === "loading" ||
status === "transcribing" ||
status === "error"
) {
// "loading" (streaming hook fetching the VAD model on first use) shows the
// same spinner+disabled state so the first click is visibly acknowledged and
// a confusing second click can't fire while the model loads.
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
return ( return (
<Tooltip label={t("Transcribing…")} withArrow> <Tooltip label={label} withArrow>
<ActionIcon <ActionIcon
size={size} size={size}
variant="subtle" variant="subtle"
color={color}
disabled disabled
aria-label={t("Transcribing…")} aria-label={label}
> >
<Loader size="xs" /> <Loader size="xs" />
</ActionIcon> </ActionIcon>
@@ -65,11 +104,12 @@ export const MicButton: FC<MicButtonProps> = ({
<ActionIcon <ActionIcon
size={size} size={size}
variant="subtle" variant="subtle"
color={color}
onClick={() => void start()} onClick={() => void start()}
disabled={disabled} disabled={disabled}
aria-label={t("Start dictation")} aria-label={t("Start dictation")}
> >
<IconMicrophone size={iconSize} /> <IconMicrophone size={resolvedIconSize} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );

View File

@@ -3,7 +3,15 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service"; import { transcribeAudio } from "@/features/dictation/services/dictation-service";
export type DictationStatus = "idle" | "recording" | "transcribing" | "error"; // "loading" is set only by the streaming hook while it lazily loads the VAD
// model on first use; the batch hook never sets it. It exists so the streaming
// hook and the mic button can show immediate feedback during that load.
export type DictationStatus =
| "idle"
| "recording"
| "transcribing"
| "error"
| "loading";
interface UseDictationOptions { interface UseDictationOptions {
onText: (text: string) => void; onText: (text: string) => void;
@@ -16,6 +24,8 @@ interface UseDictationResult {
start: () => Promise<void>; start: () => Promise<void>;
stop: () => void; stop: () => void;
cancel: () => void; cancel: () => void;
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
audioLevel: number;
} }
// Candidate container/codec combinations in preference order. The first one the // Candidate container/codec combinations in preference order. The first one the
@@ -56,6 +66,7 @@ export function useDictation(
): UseDictationResult { ): UseDictationResult {
const { t } = useTranslation(); const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle"); const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Keep the latest callbacks in a ref so the recorder's onstop closure always // Keep the latest callbacks in a ref so the recorder's onstop closure always
// calls the current handlers without re-creating the recorder. // calls the current handlers without re-creating the recorder.
@@ -70,6 +81,15 @@ export function useDictation(
const canceledRef = useRef(false); const canceledRef = useRef(false);
const startingRef = useRef(false); const startingRef = useRef(false);
// Web Audio metering: derives a live input level from the captured stream.
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const rafRef = useRef<number | null>(null);
// Exponentially smoothed level, and the last value pushed to React state.
const smoothedLevelRef = useRef(0);
const emittedLevelRef = useRef(0);
const clearTimer = useCallback(() => { const clearTimer = useCallback(() => {
if (timerRef.current !== null) { if (timerRef.current !== null) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
@@ -82,6 +102,91 @@ export function useDictation(
streamRef.current = null; streamRef.current = null;
}, []); }, []);
// Tear the audio meter down fully. Safe to call multiple times and on any exit
// path; defensive try/catch so cleanup never throws.
const stopMeter = useCallback(() => {
// Cancel the rAF first so getByteTimeDomainData can't run on a closed context.
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
try {
sourceRef.current?.disconnect();
sourceRef.current = null;
analyserRef.current = null;
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
void audioContextRef.current.close();
}
audioContextRef.current = null;
} catch (err) {
// Cleanup must never throw; just log for diagnosis.
console.warn("[dictation] audio meter teardown failed", err);
}
smoothedLevelRef.current = 0;
emittedLevelRef.current = 0;
setAudioLevel(0);
}, []);
// Set up Web Audio metering on the already-captured stream. Reuses the existing
// MediaStream — never requests a second mic. Failure here must not break
// recording: on any error we warn and return, leaving the recorder running.
const startMeter = useCallback((stream: MediaStream) => {
try {
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return;
const audioContext = new Ctor();
// Some browsers start the context suspended; resume so the loop produces
// data. Swallow rejection (e.g. context already closed by a fast
// start/stop race) to avoid an unhandled promise rejection.
audioContext.resume().catch(() => {});
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.5;
// Connect ONLY to the analyser — never to destination, which would echo the
// mic back to the speakers.
source.connect(analyser);
audioContextRef.current = audioContext;
sourceRef.current = source;
analyserRef.current = analyser;
// Allocate the time-domain buffer once and reuse it on every tick.
const data = new Uint8Array(analyser.fftSize);
const tick = () => {
const a = analyserRef.current;
if (!a) return;
a.getByteTimeDomainData(data);
// RMS of the centered waveform (samples are 0..255, midpoint 128).
let sumSquares = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sumSquares += v * v;
}
const rms = Math.sqrt(sumSquares / data.length);
// Boost + clamp so normal speech maps to a visible 0..1 range.
const level = Math.min(1, rms * 3);
// Exponential smoothing to avoid jitter.
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
// Throttle React re-renders: only push when it changed meaningfully.
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
emittedLevelRef.current = smoothedLevelRef.current;
setAudioLevel(smoothedLevelRef.current);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
} catch (err) {
// Web Audio unavailable or threw: recording continues without the meter.
console.warn("[dictation] audio meter unavailable", err);
}
}, []);
const start = useCallback(async (): Promise<void> => { const start = useCallback(async (): Promise<void> => {
// Synchronous live guard: status is stale between renders, so also block on // Synchronous live guard: status is stale between renders, so also block on
// refs to prevent a double-click from opening two MediaStreams (the first // refs to prevent a double-click from opening two MediaStreams (the first
@@ -163,8 +268,9 @@ export function useDictation(
const recordedMime = recorder.mimeType || mimeType || "audio/webm"; const recordedMime = recorder.mimeType || mimeType || "audio/webm";
const wasCanceled = canceledRef.current; const wasCanceled = canceledRef.current;
// Stop the mic tracks regardless of how we got here. // Stop the mic tracks and the audio meter regardless of how we got here.
stopTracks(); stopTracks();
stopMeter();
recorderRef.current = null; recorderRef.current = null;
if (wasCanceled) { if (wasCanceled) {
@@ -237,34 +343,49 @@ export function useDictation(
// Recording has truly begun; release the synchronous start guard. // Recording has truly begun; release the synchronous start guard.
startingRef.current = false; startingRef.current = false;
// Start the live audio meter on the stream we already acquired.
startMeter(stream);
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000; const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
if (recorderRef.current?.state === "recording") { if (recorderRef.current?.state === "recording") {
recorderRef.current.stop(); recorderRef.current.stop();
} }
}, maxDurationMs); }, maxDurationMs);
}, [status, t, clearTimer, stopTracks]); }, [status, t, clearTimer, stopTracks, startMeter, stopMeter]);
const stop = useCallback((): void => { const stop = useCallback((): void => {
clearTimer(); clearTimer();
const recorder = recorderRef.current; const recorder = recorderRef.current;
if (recorder && recorder.state === "recording") { if (recorder && recorder.state === "recording") {
// Normal path: onstop tears down tracks + meter and runs transcription.
recorder.stop(); recorder.stop();
} else {
// No live recorder (e.g. the track ended on its own): tear everything
// down directly so the meter/AudioContext and stream don't leak, and
// recover the UI to idle.
stopTracks();
stopMeter();
recorderRef.current = null;
chunksRef.current = [];
setStatus("idle");
} }
}, [clearTimer]); }, [clearTimer, stopTracks, stopMeter]);
const cancel = useCallback((): void => { const cancel = useCallback((): void => {
clearTimer(); clearTimer();
canceledRef.current = true; canceledRef.current = true;
const recorder = recorderRef.current; const recorder = recorderRef.current;
if (recorder && recorder.state === "recording") { if (recorder && recorder.state === "recording") {
// onstop sees canceledRef and skips transcription; it also stops tracks. // onstop sees canceledRef and skips transcription; it also stops tracks
// and the meter.
recorder.stop(); recorder.stop();
} else { } else {
stopTracks(); stopTracks();
stopMeter();
} }
setStatus("idle"); setStatus("idle");
}, [clearTimer, stopTracks]); }, [clearTimer, stopTracks, stopMeter]);
// Clean up on unmount: stop any live recorder/stream and clear the timers. // Clean up on unmount: stop any live recorder/stream and clear the timers.
useEffect(() => { useEffect(() => {
@@ -280,8 +401,9 @@ export function useDictation(
recorder.stop(); recorder.stop();
} }
stopTracks(); stopTracks();
stopMeter();
}; };
}, [clearTimer, stopTracks]); }, [clearTimer, stopTracks, stopMeter]);
return { status, start, stop, cancel }; return { status, start, stop, cancel, audioLevel };
} }

View File

@@ -0,0 +1,474 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
// only fetched when the user actually begins dictation.
type MicVADInstance = {
start: () => Promise<void>;
pause: () => Promise<void>;
destroy: () => Promise<void>;
};
interface UseStreamingDictationOptions {
onText: (text: string) => void;
onStart?: () => void;
maxDurationMs?: number;
}
interface UseStreamingDictationResult {
status: DictationStatus;
start: () => Promise<void>;
stop: () => void;
cancel: () => void;
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
audioLevel: number;
}
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
const VAD_SAMPLE_RATE = 16000;
// Asset paths for the VAD worklet/Silero model and the onnxruntime-web WASM
// binaries. vad-web 0.0.30's default asset path is "./" (relative to the current
// page URL), NOT a CDN — in this SPA that request hits the client-side catch-all
// route and returns index.html (text/html), so the onnxruntime ESM/wasm backend
// fails to initialize. We instead self-host the four needed files (the vad-web
// worklet + `silero_vad_v5.onnx` model and the onnxruntime-web `*.jsep.mjs`/
// `*.jsep.wasm`) under `apps/client/public/vad/` — populated by
// `scripts/copy-vad-assets.mjs`, which runs before `dev`/`build` — and point both
// paths at the fixed absolute "/vad/".
const VAD_BASE_ASSET_PATH: string | undefined = "/vad/";
const VAD_ONNX_WASM_BASE_PATH: string | undefined = "/vad/";
/**
* Streaming variant of useDictation. Detects speech with a real (Silero) VAD and,
* each time the speaker pauses, cuts that speech segment and POSTs it to the same
* batch transcription endpoint, so text appears progressively as the user speaks.
*
* Returns the SAME shape as useDictation ({ status, start, stop, cancel,
* audioLevel }) so MicButton can use either interchangeably. Refs hold the live
* VAD instance / counters / timer so component re-renders never lose them, and
* every exit path destroys the VAD and stops the MediaStream.
*/
export function useStreamingDictation(
options: UseStreamingDictationOptions,
): UseStreamingDictationResult {
const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
// current handlers without re-creating the VAD.
const optionsRef = useRef(options);
optionsRef.current = options;
const vadRef = useRef<MicVADInstance | null>(null);
// AudioContext we create+resume inside the click gesture and inject into
// MicVAD (see start()). We own it; MicVAD does not close an injected context.
const audioContextRef = useRef<AudioContext | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const canceledRef = useRef(false);
const startingRef = useRef(false);
// True while a recording session is active (VAD listening). Used to ignore late
// VAD callbacks that fire after stop()/cancel().
const activeRef = useRef(false);
// In-order emission: each segment gets a monotonically increasing seq when its
// speech ends; completed transcriptions are buffered by seq and flushed in
// order so out-of-order HTTP responses can't scramble the text.
const nextSeqRef = useRef(0);
const nextEmitSeqRef = useRef(0);
const resultsRef = useRef<Map<number, string>>(new Map());
// Number of transcription requests still in flight.
const inFlightRef = useRef(0);
// Session epoch: bumped when a NEW session starts (start) or everything is
// hard-discarded (cancel). Each in-flight request captures the epoch at send
// time; if the epoch has since changed, the request is stale and its
// then/catch/finally are skipped so old text can't leak into a new session and
// the in-flight counter can't be driven negative across sessions.
const epochRef = useRef(0);
// Exponentially smoothed speech level, and the last value pushed to React state.
const smoothedLevelRef = useRef(0);
const emittedLevelRef = useRef(0);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Reset the level meter back to zero (refs + React state).
const resetLevel = useCallback(() => {
smoothedLevelRef.current = 0;
emittedLevelRef.current = 0;
setAudioLevel(0);
}, []);
// Destroy the live VAD instance (which also releases the mic stream and audio
// context it created). Safe to call multiple times and on any exit path;
// defensive try/catch so teardown never throws.
const destroyVad = useCallback(() => {
const vad = vadRef.current;
vadRef.current = null;
if (vad) {
try {
// destroy() pauses + tears down the worklet/stream/context internally.
// It returns a promise, so attach a .catch too: the surrounding
// try/catch only catches synchronous throws, and a rejected destroy()
// would otherwise surface as an unhandled rejection.
void vad
.destroy()
.catch((err) =>
console.warn("[dictation] VAD teardown failed", err),
);
} catch (err) {
// Cleanup must never throw; just log for diagnosis.
console.warn("[dictation] VAD teardown failed", err);
}
}
}, []);
// Decide the status once recording has ended: stay "transcribing" while
// requests are in flight, otherwise return to "idle".
const settleAfterStop = useCallback(() => {
if (inFlightRef.current > 0) {
setStatus("transcribing");
} else {
setStatus("idle");
}
}, []);
// Drain the in-order result buffer: while the next expected seq is ready, trim
// it, emit it if non-empty, and advance. Called after every resolved request.
const drainResults = useCallback(() => {
const results = resultsRef.current;
while (results.has(nextEmitSeqRef.current)) {
const text = results.get(nextEmitSeqRef.current)!;
results.delete(nextEmitSeqRef.current);
nextEmitSeqRef.current += 1;
const trimmed = text.trim();
// Whisper often returns a leading space; emit the trimmed value.
if (trimmed.length > 0) optionsRef.current.onText(trimmed);
}
}, []);
// Map a transcription error to a user-facing message, mirroring the batch hook.
const transcriptionErrorMessage = useCallback(
(err: unknown): string => {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider 404, bad format,
// STT not configured) — show it verbatim.
return serverMsg;
}
if (resp?.status === 503 || resp?.status === 403) {
return t("Voice dictation is not configured");
}
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
},
[t],
);
// Handle one ended speech segment: encode to WAV and transcribe. Results are
// buffered by seq and flushed in order. A single failed segment does NOT kill
// the session: log + one notification, then advance past that seq so later
// segments still flush.
const handleSegment = useCallback(
(audio: Float32Array) => {
const seq = nextSeqRef.current;
nextSeqRef.current += 1;
inFlightRef.current += 1;
// Capture the epoch for this request synchronously at send time.
const epoch = epochRef.current;
const wavBlob = encodeWavPcm16(audio, VAD_SAMPLE_RATE);
void transcribeAudio(wavBlob, "speech.wav")
.then((text) => {
// Stale request from a previous session: drop it without touching any
// current-session state.
if (epoch !== epochRef.current) return;
// Defend against a non-string server value before drainResults trims.
resultsRef.current.set(seq, typeof text === "string" ? text : "");
drainResults();
})
.catch((err: unknown) => {
if (epoch !== epochRef.current) return;
// Log the full error for diagnosis (status + body + stack).
console.error("[dictation] segment transcription failed", err);
notifications.show({
color: "red",
message: transcriptionErrorMessage(err),
});
// Skip this seq so later segments can still flush in order.
if (nextEmitSeqRef.current === seq) {
nextEmitSeqRef.current += 1;
drainResults();
} else {
resultsRef.current.set(seq, "");
drainResults();
}
})
.finally(() => {
if (epoch !== epochRef.current) return;
inFlightRef.current -= 1;
// If recording already stopped, flip to idle once everything drained.
if (!activeRef.current && inFlightRef.current === 0) {
setStatus("idle");
}
});
},
[drainResults, transcriptionErrorMessage],
);
const start = useCallback(async (): Promise<void> => {
// Synchronous live guard: status is stale between renders, so also block on
// refs to prevent a double-click from creating two VAD instances (the first
// would leak its mic stream).
if (startingRef.current || vadRef.current || activeRef.current) return;
if (status !== "idle") return;
startingRef.current = true;
// Notify the caller right when dictation begins (before any async work) so the
// editor can snapshot the caret position.
optionsRef.current.onStart?.();
// Reset per-session in-order emission state. Bump the epoch so any request
// still in flight from a previous (stopped) session becomes stale and its
// then/catch/finally are skipped — it can neither emit old text into this
// new session nor decrement this session's freshly-zeroed in-flight counter.
epochRef.current += 1;
canceledRef.current = false;
nextSeqRef.current = 0;
nextEmitSeqRef.current = 0;
resultsRef.current = new Map();
inFlightRef.current = 0;
resetLevel();
// Create and resume the AudioContext NOW, inside the click gesture, before
// the (first-time-slow) model load below. A context first touched outside a
// user gesture stays "suspended" and the VAD audio worklet never runs — that
// is exactly why the first click did nothing and only the second (model
// already cached, so MicVAD.new was fast enough to create the context inside
// the gesture) started recording. We own this context and inject it into
// MicVAD (which then will NOT close it); it is reused across start/stop and
// closed only on unmount.
const AudioCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (AudioCtor && !audioContextRef.current) {
audioContextRef.current = new AudioCtor();
}
// Resume within the gesture; swallow rejection (e.g. already running/closed).
void audioContextRef.current?.resume().catch(() => {});
// Show immediate feedback while the model loads (see Part B).
setStatus("loading");
let vad: MicVADInstance;
try {
// Lazy import so the heavy onnx model/worklet are only fetched on first use
// and code-split out of the main bundle.
const { MicVAD } = await import("@ricky0123/vad-web");
vad = await MicVAD.new({
// Silero v5 model (smaller/faster than the legacy model).
model: "v5",
// vad-web 0.0.30 defaults startOnLoad:true, which opens the mic (calls
// getUserMedia) inside new() and leaves the later vad.start() a no-op —
// making its mic-permission error handling dead code. Force it off so the
// mic is opened only by the explicit vad.start() below, where the real
// getUserMedia errors are caught and mapped.
startOnLoad: false,
// Inject the AudioContext we created+resumed inside the click gesture so
// the VAD worklet runs on a "running" context. When provided, the library
// uses it and does NOT take ownership/close it.
...(audioContextRef.current
? { audioContext: audioContextRef.current }
: {}),
// Only pass asset paths when defined; otherwise the library uses its
// bundled CDN defaults.
...(VAD_BASE_ASSET_PATH !== undefined
? { baseAssetPath: VAD_BASE_ASSET_PATH }
: {}),
...(VAD_ONNX_WASM_BASE_PATH !== undefined
? { onnxWASMBasePath: VAD_ONNX_WASM_BASE_PATH }
: {}),
// --- VAD tuning (all tunable) ---
// Probability over which a frame counts as speech.
positiveSpeechThreshold: 0.5,
// Probability under which a frame counts as non-speech (~0.15 below the
// positive threshold, per Silero guidance).
negativeSpeechThreshold: 0.35,
// Silence to wait through before ending a segment (the "don't cut
// immediately" delay). Each ended segment is ONE transcription request, so
// cutting on short gaps over-fragments normal speech into a flood of tiny
// requests (and trips the server's per-user rate limit). Wait ~1.5s — a
// real sentence/thought boundary — so request count tracks actual pauses,
// not every inter-word gap. Higher = fewer requests but more latency
// before text appears. NOTE: vad-web 0.0.30 takes this in ms, not frames
// (one Silero frame is ~32ms at 16k).
redemptionMs: 1500,
// Audio kept before speech start (left padding so the first word isn't
// clipped) — ~0.3s.
preSpeechPadMs: 320,
// Ignore sub-100ms blips like clicks.
minSpeechMs: 96,
onFrameProcessed: (probabilities: { isSpeech: number }) => {
// Drive the level meter from the speech probability. Light exponential
// smoothing + a throttle so React state isn't updated every frame; this
// powers the existing button halo. Reuses the VAD's own frame
// probabilities — no second AudioContext/AnalyserNode.
if (!activeRef.current) return;
const level = Math.min(1, Math.max(0, probabilities.isSpeech));
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
emittedLevelRef.current = smoothedLevelRef.current;
setAudioLevel(smoothedLevelRef.current);
}
},
onSpeechStart: () => {
// No-op: the segment is only handled once it ends.
},
onSpeechEnd: (audio: Float32Array) => {
// A pause was detected — cut this segment and transcribe it. Ignore late
// callbacks that fire after stop()/cancel().
if (!activeRef.current || canceledRef.current) return;
handleSegment(audio);
},
});
} catch (err) {
// With startOnLoad:false, new() loads the model/worklet/wasm but does NOT
// open the mic, so a throw here is an asset/init failure (model fetch,
// worklet, onnxruntime wasm), not a mic-permission error. Map it as a
// generic "could not start" with the underlying detail. (The mic-permission
// name checks are kept in the vad.start() catch below, where getUserMedia
// actually runs.)
console.error("[dictation] VAD init failed", err);
const detail = (err as { message?: string })?.message ?? String(err);
notifications.show({
color: "red",
message: `${t("Could not start recording")}: ${detail}`,
});
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
// don't leak it.
destroyVad();
setStatus("idle");
startingRef.current = false;
return;
}
vadRef.current = vad;
// Accept frames once start() resolves; the VAD callbacks already guard on
// activeRef, so setting it before start() is safe.
activeRef.current = true;
try {
// With startOnLoad:false this is where getUserMedia actually runs, so map
// mic-permission errors here the same way the batch hook does; otherwise
// fall back to a generic "could not start" message.
await vad.start();
} catch (err) {
// Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] VAD.start failed", err);
const name = (err as { name?: string })?.name;
const detail = (err as { message?: string })?.message ?? String(err);
let message: string;
if (name === "NotAllowedError" || name === "SecurityError") {
message = t("Microphone access denied");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
message = t("No microphone found");
} else if (name === "NotReadableError" || name === "AbortError") {
message = t("Microphone is unavailable or already in use");
} else {
message = `${t("Could not start recording")}: ${detail}`;
}
notifications.show({ color: "red", message });
activeRef.current = false;
destroyVad();
setStatus("idle");
startingRef.current = false;
return;
}
setStatus("recording");
// Recording has truly begun; release the synchronous start guard.
startingRef.current = false;
// Optional overall safety cap: auto-stop after maxDurationMs like the batch
// hook does.
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
timerRef.current = setTimeout(() => {
if (activeRef.current) stopRef.current();
}, maxDurationMs);
}, [status, t, resetLevel, destroyVad, handleSegment]);
const stop = useCallback((): void => {
clearTimer();
if (!activeRef.current && !vadRef.current) {
// Nothing is running; make sure the UI is idle.
setStatus("idle");
return;
}
// Mark inactive first so late onSpeechEnd/onFrameProcessed callbacks are
// ignored. Any speech segment that has NOT yet ended (user clicks Stop
// mid-utterance) is dropped — acceptable for v1; users normally pause before
// stopping.
activeRef.current = false;
destroyVad();
resetLevel();
settleAfterStop();
}, [clearTimer, destroyVad, resetLevel, settleAfterStop]);
// Keep stop() reachable from the maxDuration timer closure (which is created
// before stop is defined) without re-creating the VAD.
const stopRef = useRef(stop);
stopRef.current = stop;
const cancel = useCallback((): void => {
clearTimer();
canceledRef.current = true;
activeRef.current = false;
// Hard discard: bump the epoch so any in-flight request becomes stale and is
// ignored the moment it resolves (no emit, no counter touch).
epochRef.current += 1;
// Drop pending results / queue; in-flight requests will resolve into a now-
// empty buffer and be ignored.
resultsRef.current = new Map();
nextSeqRef.current = 0;
nextEmitSeqRef.current = 0;
inFlightRef.current = 0;
destroyVad();
resetLevel();
setStatus("idle");
}, [clearTimer, destroyVad, resetLevel]);
// Clean up on unmount: destroy the VAD, stop the mic stream, clear the timer.
// Defensive try/catch lives inside destroyVad so teardown never throws.
useEffect(() => {
return () => {
clearTimer();
activeRef.current = false;
canceledRef.current = true;
destroyVad();
// Close the AudioContext we own (MicVAD never closes an injected one).
if (
audioContextRef.current &&
audioContextRef.current.state !== "closed"
) {
void audioContextRef.current.close().catch(() => {});
}
audioContextRef.current = null;
};
}, [clearTimer, destroyVad]);
return { status, start, stop, cancel, audioLevel };
}

View File

@@ -0,0 +1,32 @@
// Encode mono Float32 PCM samples into a 16-bit PCM WAV blob (audio/wav).
// The server STT endpoint whitelists audio/wav, so this is sent as-is.
export function encodeWavPcm16(samples: Float32Array, sampleRate = 16000): Blob {
const bytesPerSample = 2;
const blockAlign = bytesPerSample; // mono
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
};
writeStr(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeStr(8, "WAVE");
writeStr(12, "fmt ");
view.setUint32(16, 16, true); // PCM fmt chunk size
view.setUint16(20, 1, true); // audio format = PCM
view.setUint16(22, 1, true); // channels = mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); // bits per sample
writeStr(36, "data");
view.setUint32(40, dataSize, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const clamped = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: "audio/wav" });
}

View File

@@ -13,7 +13,6 @@ import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group"; import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group"; import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group"; import { AskAiGroup } from "./groups/ask-ai-group";
import { DictationGroup } from "./groups/dictation-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css"; import classes from "./fixed-toolbar.module.css";
@@ -31,7 +30,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const state = useToolbarState(editor); const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
if (!editor || !state) return null; if (!editor || !state) return null;
@@ -67,12 +65,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<MoreInsertsGroup editor={editor} templateMode={templateMode} /> <MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} /> <div className={classes.divider} />
<HistoryGroup editor={editor} state={state} /> <HistoryGroup editor={editor} state={state} />
{isDictationEnabled && (
<>
<div className={classes.divider} />
<DictationGroup editor={editor} />
</>
)}
</div> </div>
</div> </div>
<div className={classes.spacer} aria-hidden /> <div className={classes.spacer} aria-hidden />

View File

@@ -4,45 +4,62 @@ import { MicButton } from "@/features/dictation/components/mic-button";
interface Props { interface Props {
editor: Editor; editor: Editor;
color?: string;
iconSize?: number;
} }
export const DictationGroup: FC<Props> = ({ editor }) => { export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
// Caret snapshot taken when dictation starts (where the first segment lands).
const rangeRef = useRef<{ from: number; to: number } | null>(null); const rangeRef = useRef<{ from: number; to: number } | null>(null);
// Running insertion point: after each inserted segment we remember the caret
// end so the NEXT segment appends right after it, contiguously, regardless of
// where the user's caret currently is. Null until the first segment lands.
const insertPosRef = useRef<number | null>(null);
const handleStart = () => { const handleStart = () => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
rangeRef.current = { from, to }; rangeRef.current = { from, to };
// New session: forget any insertion point from a previous dictation so the
// first segment uses the fresh snapshot above.
insertPosRef.current = null;
}; };
const handleText = (text: string) => { const handleText = (text: string) => {
// The editor may be gone by the time async transcription returns; bail out // The editor may be gone by the time async transcription returns; bail out
// instead of operating on a destroyed instance. // instead of operating on a destroyed instance.
if (!editor || editor.isDestroyed) return; if (!editor || editor.isDestroyed) return;
const snapshot = rangeRef.current;
rangeRef.current = null;
// The document may have shrunk during transcription (e.g. a collaborative // The document may have shrunk during transcription (e.g. a collaborative
// edit), so clamp the snapshot into the current bounds before inserting. // edit), so clamp any position into the current bounds before inserting.
const docSize = editor.state.doc.content.size; const docSize = editor.state.doc.content.size;
const clamp = (p: number) => Math.max(0, Math.min(p, docSize)); const clamp = (p: number) => Math.max(0, Math.min(p, docSize));
// First segment lands at the snapshotted caret range; subsequent segments
// land at a zero-length range at the running insertion point so they stay
// contiguous even if the user clicked elsewhere mid-dictation.
const snapshot = rangeRef.current;
const range =
insertPosRef.current !== null
? { from: clamp(insertPosRef.current), to: clamp(insertPosRef.current) }
: snapshot
? { from: clamp(snapshot.from), to: clamp(snapshot.to) }
: null;
try { try {
if (snapshot) { if (range) {
// Insert at the snapshotted caret; a trailing space keeps words // Insert at the resolved range; a trailing space keeps words separated
// separated (the hook already trims the transcribed text). // (the hook already trims the transcribed text).
editor editor.chain().focus().insertContentAt(range, `${text} `).run();
.chain()
.focus()
.insertContentAt(
{ from: clamp(snapshot.from), to: clamp(snapshot.to) },
`${text} `,
)
.run();
} else { } else {
// No snapshot and no running point (shouldn't happen normally) — fall
// back to the current caret.
editor.chain().focus().insertContent(`${text} `).run(); editor.chain().focus().insertContent(`${text} `).run();
} }
// Remember where the inserted text ends so the next segment appends right
// after it, independent of later user caret moves.
insertPosRef.current = editor.state.selection.to;
} catch { } catch {
// The snapshot drifted out of range; fall back to the current caret. // The range drifted out of bounds; fall back to the current caret.
try { try {
editor.chain().focus().insertContent(`${text} `).run(); editor.chain().focus().insertContent(`${text} `).run();
insertPosRef.current = editor.state.selection.to;
} catch { } catch {
// The editor may have been destroyed; ignore so a dead editor can't // The editor may have been destroyed; ignore so a dead editor can't
// surface an uncaught error. // surface an uncaught error.
@@ -53,9 +70,12 @@ export const DictationGroup: FC<Props> = ({ editor }) => {
return ( return (
<MicButton <MicButton
size="md" size="md"
streaming
onStart={handleStart} onStart={handleStart}
onText={handleText} onText={handleText}
disabled={!editor.isEditable} disabled={!editor.isEditable}
color={color}
iconSize={iconSize}
/> />
); );
}; };

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getSuggestionItems } from "./menu-items";
// Coverage for the filter/sort half of `getSuggestionItems` (distinct from the
// HTML-embed gating suite). A slash query is matched against each item three
// ways — fuzzy on the title, substring on the description, and substring on the
// searchTerms — and matched items are sorted so title-substring hits float to
// the top of their group. We also cover `excludeItems`.
//
// `getSuggestionItems` -> `isHtmlEmbedFeatureEnabled` reads the persisted
// `currentUser` localStorage entry, so a working in-memory Storage stub is a
// prerequisite (installed by vitest.setup.ts). We persist a `currentUser` with
// the HTML-embed toggle OFF (the production default) so the gated "HTML embed"
// item never leaks into these non-HTML queries.
const KEY = "currentUser";
function flatTitles(groups: ReturnType<typeof getSuggestionItems>): string[] {
return Object.values(groups)
.flat()
.map((item) => item.title);
}
beforeEach(() => {
// Default workspace state: HTML-embed feature OFF (matches production default).
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
});
afterEach(() => {
localStorage.clear();
});
describe("getSuggestionItems — filter and sort", () => {
it("fuzzy-matches a title (non-contiguous characters)", () => {
// "tdo" is not a substring of "to-do list" but matches fuzzily (t..d..o).
const titles = flatTitles(getSuggestionItems({ query: "tdo" }));
expect(titles).toContain("To-do list");
});
it("matches via the description when the title does not match", () => {
// "numbering" only appears in the description "Create a list with numbering.",
// not in the "Numbered list" title nor its searchTerms.
const titles = flatTitles(getSuggestionItems({ query: "numbering" }));
expect(titles).toContain("Numbered list");
});
it("matches via searchTerms when title and description do not match", () => {
// "blockquote" is only present in the "Quote" item's searchTerms.
const titles = flatTitles(getSuggestionItems({ query: "blockquote" }));
expect(titles).toContain("Quote");
});
it("sorts title-substring matches before non-title (description) matches", () => {
// For "page": several titles contain "page" (e.g. "Page break"), while
// "Synced block" matches only through its description (".. across pages.").
// The sort tie-break must place every title hit ahead of the non-title hit.
const titles = flatTitles(getSuggestionItems({ query: "page" }));
const syncedIndex = titles.indexOf("Synced block");
const pageBreakIndex = titles.indexOf("Page break");
// Sanity: both items survived the filter for this query.
expect(syncedIndex).toBeGreaterThanOrEqual(0);
expect(pageBreakIndex).toBeGreaterThanOrEqual(0);
// The title match ("Page break") sorts before the description-only match.
expect(pageBreakIndex).toBeLessThan(syncedIndex);
});
it("removes a named item via excludeItems", () => {
const withBullet = flatTitles(getSuggestionItems({ query: "list" }));
expect(withBullet).toContain("Bullet list");
const withoutBullet = flatTitles(
getSuggestionItems({
query: "list",
excludeItems: new Set(["Bullet list"]),
}),
);
expect(withoutBullet).not.toContain("Bullet list");
// Other "list" matches remain unaffected by the exclusion.
expect(withoutBullet).toContain("Numbered list");
});
});

View File

@@ -14,8 +14,11 @@ import {
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts"; import { IContributor } from "@/features/page/types/page.types.ts";
@@ -24,7 +27,11 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx"; import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx"; import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts"; import {
currentPageEditModeAtom,
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor); const MemoizedPageEditor = React.memo(PageEditor);
@@ -65,6 +72,8 @@ export function FullEditor({
canComment, canComment,
}: FullEditorProps) { }: FullEditorProps) {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled = const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false; user.settings?.preferences?.editorToolbar ?? false;
@@ -104,6 +113,9 @@ export function FullEditor({
<PageByline <PageByline
creator={creator} creator={creator}
contributors={contributors} contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/> />
<MemoizedPageEditor <MemoizedPageEditor
pageId={pageId} pageId={pageId}
@@ -118,11 +130,24 @@ export function FullEditor({
type PageBylineProps = { type PageBylineProps = {
creator?: PageUser; creator?: PageUser;
contributors?: IContributor[]; contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
}; };
function PageByline({ creator, contributors }: PageBylineProps) { function PageByline({
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
}: PageBylineProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details"); const detailsTriggerProps = useAsideTriggerProps("details");
const editor = useAtomValue(pageEditorAtom);
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter( const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id, (c) => c.id !== creator?.id,
@@ -197,16 +222,23 @@ function PageByline({ creator, contributors }: PageBylineProps) {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
)} )}
<Tooltip label={t("Details")} withArrow openDelay={250}> <Group gap={4} wrap="nowrap">
<ActionIcon <Tooltip label={t("Details")} withArrow openDelay={250}>
variant="subtle" <ActionIcon
color="gray" variant="subtle"
aria-label={t("Details")} color="gray"
{...detailsTriggerProps} aria-label={t("Details")}
> {...detailsTriggerProps}
<IconInfoCircle size={20} stroke={1.5} /> >
</ActionIcon> <IconInfoCircle size={20} stroke={1.5} />
</Tooltip> </ActionIcon>
</Tooltip>
{/* Shown only in edit mode when workspace dictation is enabled, so
dictation stays reachable even when the fixed toolbar is hidden. */}
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
</Group>
</Group> </Group>
); );
} }

View File

@@ -152,7 +152,17 @@ export function TitleEditor({
const debounceUpdate = useDebouncedCallback(saveTitle, 500); const debounceUpdate = useDebouncedCallback(saveTitle, 500);
useEffect(() => { useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) { // Do not overwrite the title while the user is actively editing it. The
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
// carry a title that lags behind what the user has just typed; resetting
// content from it here would drop in-progress characters and jump the
// cursor. Apply external title changes only when the field is not focused.
if (
titleEditor &&
!titleEditor.isDestroyed &&
!titleEditor.isFocused &&
title !== titleEditor.getText()
) {
titleEditor.commands.setContent(title); titleEditor.commands.setContent(title);
} }
}, [pageId, title, titleEditor]); }, [pageId, title, titleEditor]);

View File

@@ -0,0 +1,110 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
// The /spaces list endpoint returns membership.role but NOT membership.permissions
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
// for the current user when their role is ADMIN or WRITER.
function canCreatePage(space: ISpace): boolean {
const role = space.membership?.role;
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
}
// Prominent home-screen action to create a new note (page). Because the home
// 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() {
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.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
// useCreatePageMutation already surfaces a red notification on error.
}
};
// 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} />}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
</Button>
);
}
// Multiple writable spaces → pick the target space from a dropdown.
return (
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
loading={isPending}
>
{t("New note")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Create in space")}</Menu.Label>
{writableSpaces.map((space) => (
<Menu.Item
key={space.id}
disabled={isPending}
leftSection={
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
}
onClick={() => createNote(space)}
>
<Text size="sm" lineClamp={1}>
{space.name}
</Text>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
/**
* Tests for the deterministic label-color hashing. `hashName` is not exported,
* so we exercise it through `getLabelColor`. We assert determinism, that light
* and dark schemes resolve to the SAME palette key (so a label's "blue" stays
* "blue" across themes), that the returned color is always a real palette
* entry, and that a realistic sample of names does not all collapse into one
* bucket (guards the murmur fmix finalizer that de-clusters the % bucket).
*/
// The 8 distinct light-scheme bg colors, used to recover a name's bucket index.
const LIGHT_BGS = [
"#eef1f5", // slate
"#e6f0ff", // blue
"#e3f5ea", // green
"#fbf0d9", // amber
"#fde6e6", // red
"#efe9fb", // purple
"#fce6ee", // pink
"#daf1ee", // teal
];
const DARK_BGS = [
"#2a3140",
"#152a52",
"#143b27",
"#3d2c0e",
"#401a1a",
"#2a1f4d",
"#3c1a2a",
"#103633",
];
describe("getLabelColor — determinism", () => {
it("returns the same color object shape for the same name", () => {
const a = getLabelColor("bug");
const b = getLabelColor("bug");
expect(a).toEqual(b);
expect(a).toMatchObject({
bg: expect.any(String),
fg: expect.any(String),
dot: expect.any(String),
});
});
it("is stable across many repeated calls", () => {
const first = getLabelColor("enhancement");
for (let i = 0; i < 50; i++) {
expect(getLabelColor("enhancement")).toEqual(first);
}
});
});
describe("getLabelColor — scheme parity", () => {
it("light and dark resolve to the SAME palette key for a given name", () => {
const names = ["bug", "enhancement", "wontfix", "duplicate", "p1", "docs"];
for (const name of names) {
const lightIdx = LIGHT_BGS.indexOf(getLabelColor(name, "light").bg);
const darkIdx = DARK_BGS.indexOf(getLabelColor(name, "dark").bg);
expect(lightIdx).toBeGreaterThanOrEqual(0); // it is a real palette entry
expect(darkIdx).toBeGreaterThanOrEqual(0);
expect(darkIdx).toBe(lightIdx); // same bucket across themes
}
});
it("defaults to the light scheme", () => {
expect(getLabelColor("bug")).toEqual(getLabelColor("bug", "light"));
});
});
describe("getLabelColor — index bounds & distribution", () => {
it("always returns a color whose bg is one of the 8 palette entries", () => {
const names = Array.from({ length: 200 }, (_, i) => `label-${i}`);
for (const name of names) {
expect(LIGHT_BGS).toContain(getLabelColor(name).bg);
}
});
it("handles the empty string without crashing and within bounds", () => {
expect(LIGHT_BGS).toContain(getLabelColor("").bg);
});
it("a sample of distinct names does not all collide into one bucket", () => {
const names = Array.from({ length: 64 }, (_, i) => `name-${i}-${i * 7}`);
const buckets = new Set(names.map((n) => getLabelColor(n).bg));
// The fmix finalizer should spread these across multiple buckets, not 1.
expect(buckets.size).toBeGreaterThan(1);
// Realistically a 64-name sample lands in most/all of the 8 buckets.
expect(buckets.size).toBeGreaterThanOrEqual(4);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
/**
* `normalizeLabelName` = trim + collapse ALL whitespace runs to a single hyphen
* + lowercase. Used to canonicalize label names so "Bug Fix" and " bug fix "
* map to the same key.
*/
describe("normalizeLabelName", () => {
it("trims leading and trailing whitespace", () => {
expect(normalizeLabelName(" bug ")).toBe("bug");
});
it("lowercases", () => {
expect(normalizeLabelName("BUG")).toBe("bug");
expect(normalizeLabelName("MixedCase")).toBe("mixedcase");
});
it("collapses internal whitespace runs to a single hyphen", () => {
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
expect(normalizeLabelName("a b c")).toBe("a-b-c");
});
it("combines trim + collapse + lowercase", () => {
expect(normalizeLabelName(" Bug Fix ")).toBe("bug-fix");
});
it("treats tab and newline as whitespace", () => {
expect(normalizeLabelName("bug\tfix")).toBe("bug-fix");
expect(normalizeLabelName("bug\nfix")).toBe("bug-fix");
expect(normalizeLabelName("bug\r\nfix")).toBe("bug-fix");
});
it("treats unicode whitespace (no-break space) as a separator", () => {
// U+00A0 NO-BREAK SPACE is matched by the \s class.
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
});
it("leaves an already-normalized name unchanged", () => {
expect(normalizeLabelName("bug-fix")).toBe("bug-fix");
});
it("returns empty string for whitespace-only input", () => {
expect(normalizeLabelName(" ")).toBe("");
expect(normalizeLabelName("")).toBe("");
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getTimeGroup,
groupNotificationsByTime,
} from "@/features/notification/notification.utils.ts";
import type { INotification } from "@/features/notification/types/notification.types.ts";
/**
* `getTimeGroup` classifies a timestamp into today / yesterday / this_week /
* older using LOCAL-time day boundaries derived from `now`. To stay timezone-
* independent, the boundary anchors are computed exactly the way the SUT does
* (local midnight of today, minus 1 day, minus 7 days) and inputs are offset
* from those anchors by a safe margin. `groupNotificationsByTime` buckets a
* list, drops empty groups, and preserves input order within each group, in the
* fixed order today -> yesterday -> this_week -> older.
*/
const FIXED_NOW = new Date("2026-06-21T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// Local midnight of "today" relative to the frozen clock.
function startOfTodayLocal(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
// An ISO string `offsetMs` away from local midnight of today.
function fromTodayStart(offsetMs: number): string {
return new Date(startOfTodayLocal().getTime() + offsetMs).toISOString();
}
function notif(id: string, createdAt: string): INotification {
return {
id,
createdAt,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
const HOUR = 3_600_000;
const DAY = 86_400_000;
describe("getTimeGroup — boundary classification", () => {
it("classifies a time after today's midnight as 'today'", () => {
expect(getTimeGroup(fromTodayStart(HOUR))).toBe("today");
});
it("classifies exactly today's midnight as 'today' (inclusive lower bound)", () => {
expect(getTimeGroup(fromTodayStart(0))).toBe("today");
});
it("classifies the slice between yesterday-midnight and today-midnight as 'yesterday'", () => {
expect(getTimeGroup(fromTodayStart(-HOUR))).toBe("yesterday");
expect(getTimeGroup(fromTodayStart(-DAY))).toBe("yesterday"); // start of yesterday, inclusive
});
it("classifies 2..7 days before today as 'this_week'", () => {
expect(getTimeGroup(fromTodayStart(-DAY - HOUR))).toBe("this_week");
expect(getTimeGroup(fromTodayStart(-7 * DAY))).toBe("this_week"); // start of week, inclusive
});
it("classifies anything before the 7-day window as 'older'", () => {
expect(getTimeGroup(fromTodayStart(-7 * DAY - HOUR))).toBe("older");
expect(getTimeGroup(fromTodayStart(-30 * DAY))).toBe("older");
});
});
describe("groupNotificationsByTime", () => {
const labels = {
today: "Today",
yesterday: "Yesterday",
this_week: "This week",
older: "Older",
};
it("returns groups in the order today -> yesterday -> this_week -> older", () => {
// Provide rows out of order to prove ordering comes from the group order,
// not input order.
const result = groupNotificationsByTime(
[
notif("old", fromTodayStart(-30 * DAY)),
notif("today", fromTodayStart(HOUR)),
notif("week", fromTodayStart(-3 * DAY)),
notif("yest", fromTodayStart(-HOUR)),
],
labels,
);
expect(result.map((g) => g.key)).toEqual([
"today",
"yesterday",
"this_week",
"older",
]);
expect(result.map((g) => g.label)).toEqual([
"Today",
"Yesterday",
"This week",
"Older",
]);
});
it("preserves input order within a single group", () => {
const result = groupNotificationsByTime(
[
notif("t1", fromTodayStart(HOUR)),
notif("t2", fromTodayStart(2 * HOUR)),
notif("t3", fromTodayStart(3 * HOUR)),
],
labels,
);
expect(result).toHaveLength(1);
expect(result[0].key).toBe("today");
expect(result[0].notifications.map((n) => n.id)).toEqual(["t1", "t2", "t3"]);
});
it("drops empty groups", () => {
const result = groupNotificationsByTime(
[notif("only-today", fromTodayStart(HOUR))],
labels,
);
expect(result.map((g) => g.key)).toEqual(["today"]);
});
it("returns an empty array for no notifications", () => {
expect(groupNotificationsByTime([], labels)).toEqual([]);
});
});

View File

@@ -30,7 +30,6 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
@@ -143,7 +142,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: page, isLoading } = usePageQuery({ const { data: page, isLoading } = usePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
}); });
const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(page?.spaceId ?? ""); const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
@@ -189,7 +187,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
}; };
const handleDeletePage = () => { const handleDeletePage = () => {
openDeleteModal({ onConfirm: () => handleDelete(page.id) }); handleDelete(page.id);
}; };
const handleToggleFavorite = () => { const handleToggleFavorite = () => {

View File

@@ -0,0 +1,27 @@
import { Button, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
type MoveToTrashNotificationProps = {
message: string;
undoLabel: string;
onUndo: () => void;
};
// Builds the body of the "page moved to trash" toast: the status text plus an
// inline Undo action that restores the page from trash. Returned as a ReactNode
// so it can be passed as the `message` of a Mantine notification from a
// non-TSX module (page-query.ts).
export function moveToTrashNotificationMessage({
message,
undoLabel,
onUndo,
}: MoveToTrashNotificationProps): ReactNode {
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm">{message}</Text>
<Button variant="subtle" size="compact-sm" onClick={onUndo}>
{undoLabel}
</Button>
</Group>
);
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { buildPageUrl, buildSharedPageUrl } from "@/features/page/page.utils.ts";
/**
* URL builders. A page URL is `${titleSlug}-${slugId}` where the title is
* slugified (lowercase, dashed) after truncating to the first 70 chars, and an
* empty title becomes "untitled". `buildPageUrl` prefixes `/p/` when no space
* name is given and `/s/{space}/p/` otherwise. `buildSharedPageUrl` prefixes
* `/share/p/` when no shareId and `/share/{shareId}/p/` otherwise. An anchorId
* is appended as `#...`.
*/
describe("buildPageUrl", () => {
it("uses /p/{slug} when spaceName is undefined", () => {
expect(buildPageUrl(undefined as unknown as string, "abc123", "Hello World")).toBe(
"/p/hello-world-abc123",
);
});
it("uses /s/{space}/p/{slug} when spaceName is provided", () => {
expect(buildPageUrl("eng", "abc123", "Hello World")).toBe(
"/s/eng/p/hello-world-abc123",
);
});
it("slugifies (lowercases + dashes) the title", () => {
expect(buildPageUrl("eng", "id1", "My Cool PAGE!")).toBe(
"/s/eng/p/my-cool-page-id1",
);
});
it("uses 'untitled' for an empty title", () => {
expect(buildPageUrl("eng", "id1", "")).toBe("/s/eng/p/untitled-id1");
});
it("uses 'untitled' when no title is passed at all", () => {
expect(buildPageUrl("eng", "id1")).toBe("/s/eng/p/untitled-id1");
});
it("truncates the title to the first 70 chars before slugifying", () => {
// 80 'a' then a space then "tail". Only the first 70 chars feed slugify, so
// the slug is 70 a's (the space and "tail" past char 70 are dropped).
const longTitle = "a".repeat(80) + " tail";
const url = buildPageUrl("eng", "id1", longTitle);
expect(url).toBe(`/s/eng/p/${"a".repeat(70)}-id1`);
expect(url).not.toContain("tail");
});
it("appends the anchorId as a #fragment", () => {
expect(buildPageUrl("eng", "id1", "Page", "section-2")).toBe(
"/s/eng/p/page-id1#section-2",
);
});
it("omits the fragment when no anchorId is given", () => {
expect(buildPageUrl("eng", "id1", "Page")).not.toContain("#");
});
});
describe("buildSharedPageUrl", () => {
it("uses /share/p/{slug} when shareId is absent", () => {
expect(
buildSharedPageUrl({ shareId: "", pageSlugId: "id1", pageTitle: "Doc" }),
).toBe("/share/p/doc-id1");
});
it("uses /share/{shareId}/p/{slug} when shareId is present", () => {
expect(
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "Doc" }),
).toBe("/share/s9/p/doc-id1");
});
it("falls back to 'untitled' for an empty title", () => {
expect(
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "" }),
).toBe("/share/s9/p/untitled-id1");
});
it("appends the anchorId as a #fragment", () => {
expect(
buildSharedPageUrl({
shareId: "s9",
pageSlugId: "id1",
pageTitle: "Doc",
anchorId: "h1",
}),
).toBe("/share/s9/p/doc-id1#h1");
});
it("truncates the title to the first 70 chars before slugifying", () => {
const longTitle = "b".repeat(80) + " tail";
const url = buildSharedPageUrl({
shareId: "s9",
pageSlugId: "id1",
pageTitle: longTitle,
});
expect(url).toBe(`/share/s9/p/${"b".repeat(70)}-id1`);
expect(url).not.toContain("tail");
});
});

View File

@@ -35,11 +35,12 @@ import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtom } from "jotai"; import { useSetAtom, useStore } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types"; import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
export function usePageQuery( export function usePageQuery(
pageInput: Partial<IPageInput>, pageInput: Partial<IPageInput>,
@@ -118,10 +119,29 @@ export function useUpdatePageMutation() {
export function useRemovePageMutation() { export function useRemovePageMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
// Reuse the existing restore flow for the toast's Undo action. Its side
// effects (tree re-insert, cache updates, websocket emit, success toast) live
// in its useMutation-level onSuccess, so they still run after the originating
// tree node / page header has unmounted by the time Undo is clicked.
const restorePageMutation = useRestorePageMutation();
return useMutation({ return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false), mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => { onSuccess: (_, pageId) => {
notifications.show({ message: t("Page moved to trash") }); // Replace the former pre-delete confirmation dialog with an Undo action
// surfaced directly in the "moved to trash" toast.
const notificationId = `page-moved-to-trash-${pageId}`;
notifications.show({
id: notificationId,
autoClose: 8000,
message: moveToTrashNotificationMessage({
message: t("Page moved to trash"),
undoLabel: t("Undo"),
onUndo: () => {
notifications.hide(notificationId);
restorePageMutation.mutate(pageId);
},
}),
});
// Stamp deletedAt so a re-visit shows the trash banner, not stale state. // Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(["pages", pageId]); const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
@@ -173,7 +193,8 @@ export function useMovePageMutation() {
export function useRestorePageMutation() { export function useRestorePageMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const setTreeData = useSetAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit(); const emit = useQueryEmit();
return useMutation({ return useMutation({
@@ -181,8 +202,13 @@ export function useRestorePageMutation() {
onSuccess: async (restoredPage) => { onSuccess: async (restoredPage) => {
notifications.show({ message: t("Page restored successfully") }); notifications.show({ message: t("Page restored successfully") });
// Undo can fire from the trash toast after the originating tree node /
// page header has unmounted, so a render-time `treeData` closure would be
// stale. Read the live tree imperatively from the store at execution time.
const currentTree = store.get(treeDataAtom);
// Check if the page already exists in the tree (it shouldn't) // Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(treeData, restoredPage.id)) { if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend // Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = { const nodeData: SpaceTreeNode = {
id: restoredPage.id, id: restoredPage.id,
@@ -201,17 +227,22 @@ export function useRestorePageMutation() {
let index = 0; let index = 0;
if (parentId) { if (parentId) {
const parentNode = treeModel.find(treeData, parentId); const parentNode = treeModel.find(currentTree, parentId);
if (parentNode) { if (parentNode) {
index = parentNode.children?.length || 0; index = parentNode.children?.length || 0;
} }
} else { } else {
// Root level page // Root level page
index = treeData.length; index = currentTree.length;
} }
// Add the node to the tree // Add the node to the tree via a functional updater, re-checking
setTreeData(treeModel.insert(treeData, parentId, nodeData, index)); // existence against the freshest state for idempotency.
setTreeData((prev) =>
treeModel.find(prev, restoredPage.id)
? prev
: treeModel.insert(prev, parentId, nodeData, index),
);
// Emit websocket event to sync with other users // Emit websocket event to sync with other users
setTimeout(() => { setTimeout(() => {

View File

@@ -19,7 +19,6 @@ import {
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx"; import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { duplicatePage } from "@/features/page/services/page-service.ts"; import { duplicatePage } from "@/features/page/services/page-service.ts";
import { useClipboard } from "@/hooks/use-clipboard"; import { useClipboard } from "@/hooks/use-clipboard";
@@ -47,7 +46,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 }); const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(node.spaceId); const { handleDelete } = useTreeMutation(node.spaceId);
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
@@ -257,9 +255,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
openDeleteModal({ handleDelete(node.id);
onConfirm: () => handleDelete(node.id),
});
}} }}
> >
{t("Move to trash")} {t("Move to trash")}

View File

@@ -88,6 +88,10 @@ export default function ShareAiWidget({
const isStreaming = status === "submitted" || status === "streaming"; const isStreaming = status === "submitted" || status === "streaming";
// Same classified-error banner as the internal chat: name the cause instead of a
// generic heading.
const errorView = error ? describeChatError(error.message ?? "", t) : null;
const handleSend = () => { const handleSend = () => {
const text = input.trim(); const text = input.trim();
if (!text || isStreaming) return; if (!text || isStreaming) return;
@@ -173,18 +177,18 @@ export default function ShareAiWidget({
/> />
</Box> </Box>
{error && ( {errorView && (
<Alert <Alert
variant="light" variant="light"
color="red" color="red"
icon={<IconAlertTriangle size={16} />} icon={<IconAlertTriangle size={16} />}
mx="sm" mx="sm"
mb="xs" mb="xs"
title={t("Something went wrong")} title={errorView.title}
> >
{/* Surface the real cause (provider/gating message) instead of a {/* Surface the real cause (provider/gating category) instead of a
generic line — same helper the internal chat uses. */} generic line — same helper the internal chat uses. */}
{describeChatError(error.message ?? "", t)} {errorView.detail}
</Alert> </Alert>
)} )}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
buildSharedPageTree,
isPageInTree,
type SharedPageTreeNode,
} from "@/features/share/utils.ts";
import type { IPage } from "@/features/page/types/page.types.ts";
/**
* `buildSharedPageTree` nests pages by `parentPageId` (keyed on `page.id`),
* promotes orphans (parent absent) to top level, marks `hasChildren`, and sorts
* siblings recursively by `position`. `isPageInTree` walks the tree matching on
* `slugId`. We build minimal page records (only the fields the builder reads).
*/
function page(p: Partial<IPage> & { id: string }): IPage {
return {
id: p.id,
slugId: p.slugId ?? `slug-${p.id}`,
title: p.title ?? p.id,
icon: p.icon ?? "",
position: p.position ?? "a0",
spaceId: p.spaceId ?? "space-1",
parentPageId: p.parentPageId ?? (null as unknown as string),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
describe("buildSharedPageTree — nesting & sorting", () => {
it("nests children under their parent and sorts siblings by position", () => {
const tree = buildSharedPageTree([
page({ id: "root", slugId: "root-s", position: "a0" }),
page({ id: "c2", slugId: "c2-s", parentPageId: "root", position: "a2" }),
page({ id: "c1", slugId: "c1-s", parentPageId: "root", position: "a1" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
expect(tree).toHaveLength(1);
const root = tree[0];
expect(root.slugId).toBe("root-s");
expect(root.hasChildren).toBe(true);
expect(root.children.map((c) => c.slugId)).toEqual(["c1-s", "c2-s"]);
});
it("sorts top-level siblings by position", () => {
// Positions: a-s=a1, c-s=a2, b-s=a3 -> sorted order is a1, a2, a3.
const tree = buildSharedPageTree([
page({ id: "b", slugId: "b-s", position: "a3" }),
page({ id: "a", slugId: "a-s", position: "a1" }),
page({ id: "c", slugId: "c-s", position: "a2" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
expect(tree.map((n) => n.slugId)).toEqual(["a-s", "c-s", "b-s"]);
});
it("sorts recursively at depth", () => {
const tree = buildSharedPageTree([
page({ id: "root", slugId: "root-s", position: "a0" }),
page({ id: "mid", slugId: "mid-s", parentPageId: "root", position: "a0" }),
page({ id: "g2", slugId: "g2-s", parentPageId: "mid", position: "a5" }),
page({ id: "g1", slugId: "g1-s", parentPageId: "mid", position: "a1" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const mid = tree[0].children[0];
expect(mid.slugId).toBe("mid-s");
expect(mid.hasChildren).toBe(true);
expect(mid.children.map((c) => c.slugId)).toEqual(["g1-s", "g2-s"]);
});
});
describe("buildSharedPageTree — orphans & flags", () => {
it("promotes a page whose parent is absent to a top-level node (no crash)", () => {
const tree = buildSharedPageTree([
page({ id: "x", slugId: "x-s", parentPageId: "missing-parent" }),
page({ id: "y", slugId: "y-s" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const slugs = tree.map((n) => n.slugId).sort();
expect(slugs).toEqual(["x-s", "y-s"]);
});
it("leaves hasChildren false for leaf nodes", () => {
const tree = buildSharedPageTree([
page({ id: "leaf", slugId: "leaf-s" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
expect(tree[0].hasChildren).toBe(false);
expect(tree[0].children).toEqual([]);
});
it("uses 'untitled' as the label for an empty title", () => {
const tree = buildSharedPageTree([
page({ id: "z", slugId: "z-s", title: "" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
expect(tree[0].label).toBe("untitled");
});
});
describe("isPageInTree", () => {
const tree: SharedPageTreeNode[] = buildSharedPageTree([
page({ id: "root", slugId: "root-s", position: "a0" }),
page({ id: "child", slugId: "child-s", parentPageId: "root", position: "a1" }),
page({ id: "grand", slugId: "grand-s", parentPageId: "child", position: "a1" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
it("returns true for a top-level slugId", () => {
expect(isPageInTree(tree, "root-s")).toBe(true);
});
it("returns true for a deeply nested slugId", () => {
expect(isPageInTree(tree, "grand-s")).toBe(true);
});
it("returns false for an unknown slugId", () => {
expect(isPageInTree(tree, "does-not-exist")).toBe(false);
});
it("returns false for an empty tree", () => {
expect(isPageInTree([], "root-s")).toBe(false);
});
});

View File

@@ -87,7 +87,6 @@ export function SpaceSidebar() {
spaceName={space?.name} spaceName={space?.name}
spaceSlug={space?.slug} spaceSlug={space?.slug}
spaceIcon={space?.logo} spaceIcon={space?.logo}
onSettings={openSettings}
/> />
</div> </div>

View File

@@ -2,16 +2,6 @@
width: 100%; width: 100%;
} }
.header {
padding: rem(4px) var(--mantine-spacing-sm);
}
.spaceName {
flex: 1;
min-width: 0;
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-0));
}
.grid { .grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1,8 +1,7 @@
import classes from "./switch-space.module.css"; import classes from "./switch-space.module.css";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getSpaceUrl } from "@/lib/config"; import { getSpaceUrl } from "@/lib/config";
import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { Text, UnstyledButton } from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { import {
@@ -10,7 +9,6 @@ import {
useGetSpacesQuery, useGetSpacesQuery,
} from "@/features/space/queries/space-query.ts"; } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types"; import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
@@ -19,7 +17,6 @@ interface SwitchSpaceProps {
spaceName: string; spaceName: string;
spaceSlug: string; spaceSlug: string;
spaceIcon?: string; spaceIcon?: string;
onSettings: () => void;
} }
export function SwitchSpace({ export function SwitchSpace({
@@ -27,9 +24,7 @@ export function SwitchSpace({
spaceName, spaceName,
spaceSlug, spaceSlug,
spaceIcon, spaceIcon,
onSettings,
}: SwitchSpaceProps) { }: SwitchSpaceProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
// Load every space the user belongs to (API caps limit at 100) and render // Load every space the user belongs to (API caps limit at 100) and render
// them as an always-visible grid instead of the previous searchable popover. // them as an always-visible grid instead of the previous searchable popover.
@@ -59,31 +54,6 @@ export function SwitchSpace({
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<Group gap={6} wrap="nowrap" className={classes.header}>
<CustomAvatar
name={spaceName}
avatarUrl={spaceIcon}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<Text className={classes.spaceName} size="md" fw={600} lineClamp={1}>
{spaceName}
</Text>
<Tooltip label={t("Space settings")} withArrow position="top">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onSettings}
aria-label={t("Space settings")}
>
<IconSettings size={18} stroke={2} />
</ActionIcon>
</Tooltip>
</Group>
<div className={classes.grid}> <div className={classes.grid}>
{spaces.map((space: ISpace) => ( {spaces.map((space: ISpace) => (
<UnstyledButton <UnstyledButton

View File

@@ -26,7 +26,6 @@ import {
IAiMcpServer, IAiMcpServer,
IAiMcpServerCreate, IAiMcpServerCreate,
IAiMcpServerUpdate, IAiMcpServerUpdate,
McpTransport,
} from "@/features/workspace/services/ai-mcp-server-service.ts"; } from "@/features/workspace/services/ai-mcp-server-service.ts";
const formSchema = z.object({ const formSchema = z.object({
@@ -62,13 +61,6 @@ function buildInitialValues(server?: IAiMcpServer): FormValues {
}; };
} }
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
const TAVILY_PRESET = {
name: "Tavily",
transport: "http" as McpTransport,
url: "https://mcp.tavily.com/mcp/",
};
export default function AiMcpServerForm({ export default function AiMcpServerForm({
server, server,
onClose, onClose,
@@ -155,28 +147,11 @@ export default function AiMcpServerForm({
form.setFieldValue("authHeader", ""); form.setFieldValue("authHeader", "");
} }
function applyTavilyPreset() {
form.setFieldValue("name", TAVILY_PRESET.name);
form.setFieldValue("transport", TAVILY_PRESET.transport);
form.setFieldValue("url", TAVILY_PRESET.url);
// Prefill the Bearer prefix; the admin pastes their Tavily key after it.
form.setFieldValue("authHeader", "Bearer ");
setHeadersCleared(false);
}
const testResult = testMutation.data; const testResult = testMutation.data;
const isSaving = createMutation.isPending || updateMutation.isPending; const isSaving = createMutation.isPending || updateMutation.isPending;
return ( return (
<Stack> <Stack>
{!isEdit && (
<Group justify="flex-start">
<Button variant="default" size="compact-sm" onClick={applyTavilyPreset}>
{t("Use Tavily preset")}
</Button>
</Group>
)}
<TextInput <TextInput
label={t("Server name")} label={t("Server name")}
{...form.getInputProps("name")} {...form.getInputProps("name")}
@@ -193,6 +168,11 @@ export default function AiMcpServerForm({
<PasswordInput <PasswordInput
label={t("Authorization header")} label={t("Authorization header")}
// Clarify that the value is sent verbatim as the Authorization header,
// so the user supplies the full scheme (no implicit Bearer prefix).
description={t(
"Sent verbatim as the value of the Authorization header (e.g. \"Bearer <token>\" or \"Basic <base64>\").",
)}
// Placeholder hints whether headers are stored; the value is never shown. // Placeholder hints whether headers are stored; the value is never shown.
placeholder={hasHeaders ? t("•••• set") : ""} placeholder={hasHeaders ? t("•••• set") : ""}
autoComplete="off" autoComplete="off"

View File

@@ -42,6 +42,40 @@ import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiMcpServers from "./ai-mcp-servers.tsx"; import AiMcpServers from "./ai-mcp-servers.tsx";
// Curated ISO-639-1 dictation languages for the STT card. The empty-value
// "Auto-detect" entry is prepended in render (it needs translation). Values
// are sent verbatim to the transcription model as the language hint.
const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
{ value: "en", label: "English" },
{ value: "ru", label: "Russian — Русский" },
{ value: "uk", label: "Ukrainian — Українська" },
{ value: "de", label: "German — Deutsch" },
{ value: "fr", label: "French — Français" },
{ value: "es", label: "Spanish — Español" },
{ value: "it", label: "Italian — Italiano" },
{ value: "pt", label: "Portuguese — Português" },
{ value: "nl", label: "Dutch — Nederlands" },
{ value: "pl", label: "Polish — Polski" },
{ value: "tr", label: "Turkish — Türkçe" },
{ value: "cs", label: "Czech — Čeština" },
{ value: "sv", label: "Swedish — Svenska" },
{ value: "fi", label: "Finnish — Suomi" },
{ value: "da", label: "Danish — Dansk" },
{ value: "no", label: "Norwegian — Norsk" },
{ value: "ro", label: "Romanian — Română" },
{ value: "hu", label: "Hungarian — Magyar" },
{ value: "el", label: "Greek — Ελληνικά" },
{ value: "he", label: "Hebrew — עברית" },
{ value: "ar", label: "Arabic — العربية" },
{ value: "hi", label: "Hindi — हिन्दी" },
{ value: "id", label: "Indonesian — Bahasa Indonesia" },
{ value: "vi", label: "Vietnamese — Tiếng Việt" },
{ value: "th", label: "Thai — ไทย" },
{ value: "ja", label: "Japanese — 日本語" },
{ value: "ko", label: "Korean — 한국어" },
{ value: "zh", label: "Chinese — 中文" },
];
// No driver field: every endpoint is OpenAI-compatible, so the form carries only // No driver field: every endpoint is OpenAI-compatible, so the form carries only
// the user-editable fields. `apiKey` / `embeddingApiKey` are write-only buffers // the user-editable fields. `apiKey` / `embeddingApiKey` are write-only buffers
// (empty means "leave unchanged" unless explicitly cleared). // (empty means "leave unchanged" unless explicitly cleared).
@@ -63,6 +97,8 @@ const formSchema = z.object({
sttModel: z.string(), sttModel: z.string(),
sttBaseUrl: z.string(), sttBaseUrl: z.string(),
sttApiStyle: z.enum(["multipart", "json"]), sttApiStyle: z.enum(["multipart", "json"]),
// ISO-639-1 dictation language; empty = auto-detect.
sttLanguage: z.string(),
sttApiKey: z.string(), sttApiKey: z.string(),
}); });
@@ -233,6 +269,7 @@ export default function AiProviderSettings() {
sttModel: "", sttModel: "",
sttBaseUrl: "", sttBaseUrl: "",
sttApiStyle: "multipart" as SttApiStyle, sttApiStyle: "multipart" as SttApiStyle,
sttLanguage: "",
sttApiKey: "", sttApiKey: "",
}, },
}); });
@@ -254,6 +291,7 @@ export default function AiProviderSettings() {
sttModel: settings.sttModel ?? "", sttModel: settings.sttModel ?? "",
sttBaseUrl: settings.sttBaseUrl ?? "", sttBaseUrl: settings.sttBaseUrl ?? "",
sttApiStyle: settings.sttApiStyle ?? "multipart", sttApiStyle: settings.sttApiStyle ?? "multipart",
sttLanguage: settings.sttLanguage ?? "",
sttApiKey: "", sttApiKey: "",
}); });
form.resetDirty(); form.resetDirty();
@@ -288,6 +326,7 @@ export default function AiProviderSettings() {
sttModel: values.sttModel, sttModel: values.sttModel,
sttBaseUrl: values.sttBaseUrl, sttBaseUrl: values.sttBaseUrl,
sttApiStyle: values.sttApiStyle, sttApiStyle: values.sttApiStyle,
sttLanguage: values.sttLanguage,
}; };
// Key semantics (never send the stored key back) — see resolveKeyField: // Key semantics (never send the stored key back) — see resolveKeyField:
@@ -923,6 +962,22 @@ export default function AiProviderSettings() {
{...form.getInputProps("sttApiStyle")} {...form.getInputProps("sttApiStyle")}
/> />
<Select
mt="sm"
label={t("Dictation language")}
description={t(
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
)}
data={[
{ value: "", label: t("Auto-detect") },
...STT_LANGUAGE_OPTIONS,
]}
searchable
allowDeselect={false}
disabled={isLoading}
{...form.getInputProps("sttLanguage")}
/>
<TextInput <TextInput
mt="sm" mt="sm"
label={t("Base URL")} label={t("Base URL")}

View File

@@ -33,6 +33,8 @@ export interface IAiSettings {
sttModel?: string; sttModel?: string;
sttBaseUrl?: string; sttBaseUrl?: string;
sttApiStyle?: SttApiStyle; sttApiStyle?: SttApiStyle;
// ISO-639-1 dictation language; empty = auto-detect.
sttLanguage?: string;
hasSttApiKey: boolean; hasSttApiKey: boolean;
// RAG indexing coverage (pages indexed for semantic search). // RAG indexing coverage (pages indexed for semantic search).
indexedPages: number; indexedPages: number;
@@ -60,6 +62,8 @@ export interface IAiSettingsUpdate {
sttModel?: string; sttModel?: string;
sttBaseUrl?: string; sttBaseUrl?: string;
sttApiStyle?: SttApiStyle; sttApiStyle?: SttApiStyle;
// ISO-639-1 dictation language; empty = auto-detect.
sttLanguage?: string;
// Write-only STT key (same semantics as `apiKey` / `embeddingApiKey`). // Write-only STT key (same semantics as `apiKey` / `embeddingApiKey`).
sttApiKey?: string; sttApiKey?: string;
} }

View File

@@ -12,6 +12,15 @@ i18n
// init i18next // init i18next
// for all options read: https://www.i18next.com/overview/configuration-options // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
// i18n maintenance policy:
// - en-US is the source of truth for all UI strings (keys are the English text).
// - en-US and ru-RU are the fully-maintained locales; in particular, the
// AI-chat string set is kept complete in both so the UI never renders
// mixed-language (no per-key en-US fallback within a single widget).
// - The other 10 locales (fr-FR, de-DE, es-ES, nl-NL, ja-JP, zh-CN, ko-KR,
// pt-BR, it-IT, uk-UA) are partial and intentionally rely on the
// `fallbackLng: "en-US"` fallback below until translations are
// contributed (e.g. via Crowdin).
fallbackLng: "en-US", fallbackLng: "en-US",
debug: false, debug: false,
showSupportNotice: false, showSupportNotice: false,

Binary file not shown.

View File

@@ -0,0 +1,13 @@
.container {
/* Default top padding for tablet/desktop (replaces the former pt="xl") */
padding-top: var(--mantine-spacing-xl);
}
@media (max-width: $mantine-breakpoint-sm) {
.container {
/* On phones drop the extra side padding (AppShell already provides the
outer gap) and shrink the top gap below the header. */
padding-inline: 0;
padding-top: var(--mantine-spacing-xs);
}
}

View File

@@ -1,9 +1,11 @@
import { Container, Space } from "@mantine/core"; import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs"; import HomeTabs from "@/features/home/components/home-tabs";
import NewNoteButton from "@/features/home/components/new-note-button";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx"; import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts"; import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "./home.module.css";
export default function Home() { export default function Home() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,7 +17,11 @@ export default function Home() {
{t("Home")} - {getAppName()} {t("Home")} - {getAppName()}
</title> </title>
</Helmet> </Helmet>
<Container size={"900"} pt="xl"> <Container size={"900"} className={classes.container}>
<NewNoteButton />
<Space h="xl" />
<SpaceCarousel /> <SpaceCarousel />
<Space h="xl" /> <Space h="xl" />

View File

@@ -12,6 +12,6 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: [], setupFiles: ['./vitest.setup.ts'],
}, },
}); });

View File

@@ -0,0 +1,51 @@
// Vitest global setup (test-infra only — no production app source).
//
// Under Node 25 / jsdom 25 / vitest 4 the jsdom `localStorage` exposed on the
// global is not a usable Storage: its methods (`setItem`/`getItem`/...) are not
// callable, so any code touching `localStorage` throws `... is not a function`.
// Production code such as `isHtmlEmbedFeatureEnabled()` reads
// `localStorage.getItem("currentUser")`, which made dependent tests fail.
//
// We install a correct in-memory Storage stub on the global BEFORE tests run so
// the Web Storage contract holds: string coercion of keys/values, `null` for
// missing keys, working `length`/`key(index)`, and `clear()`.
import { vi } from "vitest";
// Minimal, spec-faithful in-memory implementation of the Web Storage API.
function createStorage(): Storage {
let store = new Map<string, string>();
const storage: Storage = {
get length(): number {
return store.size;
},
clear(): void {
store = new Map<string, string>();
},
getItem(key: string): string | null {
// Missing keys must return `null`, not `undefined`.
const value = store.get(String(key));
return value === undefined ? null : value;
},
setItem(key: string, value: string): void {
// Web Storage coerces both key and value to strings.
store.set(String(key), String(value));
},
removeItem(key: string): void {
store.delete(String(key));
},
key(index: number): string | null {
// Insertion order matches Map iteration order; out-of-range => null.
const keys = Array.from(store.keys());
return index >= 0 && index < keys.length ? keys[index] : null;
},
};
return storage;
}
// Install on the jsdom global. `vi.stubGlobal` also reflects onto `window`
// (jsdom shares `globalThis` and `window`), so both `localStorage` and
// `window.localStorage` resolve to the same working stub.
vi.stubGlobal("localStorage", createStorage());
vi.stubGlobal("sessionStorage", createStorage());

View File

@@ -25,6 +25,7 @@
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build", "pretest": "pnpm --filter @docmost/editor-ext build",
"test": "jest", "test": "jest",
"test:int": "jest --config test/jest-integration.json",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",

View File

@@ -0,0 +1,243 @@
import * as Y from 'yjs';
import {
getPageId,
isEmptyParagraphDoc,
jsonToNode,
prosemirrorNodeToYElement,
} from './collaboration.util';
import { Node } from '@tiptap/pm/model';
// Collect every node type name in a ProseMirror Node, in document order.
const collectTypes = (node: Node): string[] => {
const types: string[] = [];
node.descendants((n) => {
types.push(n.type.name);
});
return types;
};
// Yjs types throw "Invalid access" until attached to a document, so every
// produced Y element must be inserted into a fragment before it is inspected.
const attach = (json: any): any => {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const element = prosemirrorNodeToYElement(json);
fragment.insert(0, [element as any]);
return element;
};
describe('getPageId', () => {
it('extracts the uuid from a "page.<uuid>" document name', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
expect(getPageId(`page.${uuid}`)).toBe(uuid);
});
it('returns undefined when the name has no separator', () => {
// Auth keying depends on this: a malformed name must not yield a stray id.
expect(getPageId('justaname')).toBeUndefined();
});
it('returns the second segment only, ignoring extra dotted parts', () => {
expect(getPageId('page.abc.def')).toBe('abc');
});
it('returns an empty string for a trailing dot', () => {
expect(getPageId('page.')).toBe('');
});
});
describe('isEmptyParagraphDoc', () => {
it('returns true for a doc with a single empty paragraph', () => {
expect(
isEmptyParagraphDoc({ type: 'doc', content: [{ type: 'paragraph' }] }),
).toBe(true);
});
it('returns true for a single paragraph with an empty content array', () => {
expect(
isEmptyParagraphDoc({
type: 'doc',
content: [{ type: 'paragraph', content: [] }],
}),
).toBe(true);
});
it('returns false for a paragraph containing text', () => {
expect(
isEmptyParagraphDoc({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
],
}),
).toBe(false);
});
it('returns false for a doc with more than one child', () => {
expect(
isEmptyParagraphDoc({
type: 'doc',
content: [{ type: 'paragraph' }, { type: 'paragraph' }],
}),
).toBe(false);
});
it('returns false when the single child is not a paragraph', () => {
expect(
isEmptyParagraphDoc({
type: 'doc',
content: [{ type: 'heading', attrs: { level: 1 } }],
}),
).toBe(false);
});
it('returns false when the root is not a "doc"', () => {
expect(
isEmptyParagraphDoc({ type: 'paragraph', content: [] } as any),
).toBe(false);
});
it('returns false for null / undefined input', () => {
expect(isEmptyParagraphDoc(null as any)).toBe(false);
expect(isEmptyParagraphDoc(undefined as any)).toBe(false);
});
});
describe('stripUnknownNodes (via jsonToNode fallback)', () => {
it('drops an unknown leaf node while keeping known siblings', () => {
const json = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
{ type: 'totallyUnknownLeaf', attrs: {} },
],
};
const node = jsonToNode(json);
// Only the paragraph + its text remain; the unknown leaf is gone.
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
expect(node.textContent).toBe('keep');
});
it('unwraps an unknown WRAPPER, flattening its children (no content loss)', () => {
const json = {
type: 'doc',
content: [
{
type: 'unknownWrapper',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
],
},
],
};
const node = jsonToNode(json);
// The wrapper disappears but its paragraph child is lifted up, not deleted.
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
expect(node.textContent).toBe('inside');
});
it('leaves an entirely known document untouched', () => {
const json = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'b' }],
},
],
};
const node = jsonToNode(json);
expect(collectTypes(node)).toEqual([
'paragraph',
'text',
'heading',
'text',
]);
expect(node.textContent).toBe('ab');
});
it('drops an unknown inline nested inside a known node', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'a' },
{ type: 'weirdInline' },
{ type: 'text', text: 'b' },
],
},
],
};
const node = jsonToNode(json);
// The unknown inline is silently removed; surrounding text survives.
expect(node.textContent).toBe('ab');
expect(collectTypes(node)).toEqual(['paragraph', 'text', 'text']);
});
});
describe('prosemirrorNodeToYElement', () => {
it('produces a Y.XmlText carrying mark attrs as format on a marked text node', () => {
const ytext = attach({
type: 'text',
text: 'hi',
marks: [{ type: 'bold', attrs: { level: 2 } }, { type: 'italic' }],
});
const delta = ytext.toDelta();
expect(delta).toHaveLength(1);
expect(delta[0].insert).toBe('hi');
// mark.attrs is used when present, otherwise `true` (the `|| true` path).
expect(delta[0].attributes).toEqual({
bold: { level: 2 },
italic: true,
});
expect(ytext.length).toBe(2);
});
it('produces a plain Y.XmlText with no format for an unmarked text node', () => {
const ytext = attach({ type: 'text', text: 'plain' });
const delta = ytext.toDelta();
expect(delta).toEqual([{ insert: 'plain' }]);
expect(ytext.length).toBe(5);
});
it('sets element attributes, skipping null and undefined values', () => {
const element = attach({
type: 'paragraph',
attrs: { textAlign: 'left', indent: 0, anchorId: null, ghost: undefined },
content: [{ type: 'text', text: 'abc' }],
});
expect(element.nodeName).toBe('paragraph');
expect(element.getAttribute('textAlign')).toBe('left');
// indent is 0 (falsy but defined) -> must still be set.
expect(element.getAttribute('indent')).toBe(0);
// null / undefined attrs are skipped, never set.
expect(element.getAttribute('anchorId')).toBeUndefined();
expect(element.getAttribute('ghost')).toBeUndefined();
expect(element.getAttributes()).toEqual({ textAlign: 'left', indent: 0 });
});
it('creates an element with no attributes when attrs is absent', () => {
const element = attach({ type: 'horizontalRule' });
expect(element.nodeName).toBe('horizontalRule');
expect(element.getAttributes()).toEqual({});
expect(element.length).toBe(0);
});
it('recurses into nested content preserving order', () => {
const element = attach({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
],
});
// Two child paragraphs, in original order.
expect(element.length).toBe(2);
expect(element.get(0).get(0).toString()).toBe('one');
expect(element.get(1).get(0).toString()).toBe('two');
});
});

View File

@@ -0,0 +1,211 @@
import {
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticationExtension } from './authentication.extension';
import { SpaceRole } from '../../common/helpers/types/permission';
import { JwtType } from '../../core/auth/dto/jwt-payload';
/**
* Unit tests for the collab read-only downgrade matrix in
* `AuthenticationExtension.onAuthenticate`. This is a security boundary: a wrong
* branch here is either a collab-auth bypass (writer on a page they may only
* read) or a denial. We mock every repo and inspect both the thrown exception
* type and the `connectionConfig.readOnly` flag the extension mutates.
*/
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const USER_ID = 'user-1';
const WORKSPACE_ID = 'ws-1';
const SPACE_ID = 'space-1';
const buildUser = (overrides: Partial<any> = {}) => ({
id: USER_ID,
workspaceId: WORKSPACE_ID,
deactivatedAt: null,
deletedAt: null,
name: 'Alice',
avatarUrl: null,
...overrides,
});
const buildPage = (overrides: Partial<any> = {}) => ({
id: PAGE_ID,
spaceId: SPACE_ID,
workspaceId: WORKSPACE_ID,
deletedAt: null,
...overrides,
});
// Default jwt payload: a plain human collab token (no agent provenance claims).
const buildJwt = (overrides: Partial<any> = {}) => ({
sub: USER_ID,
workspaceId: WORKSPACE_ID,
type: JwtType.COLLAB,
...overrides,
});
describe('AuthenticationExtension.onAuthenticate', () => {
let ext: AuthenticationExtension;
let tokenService: { verifyJwt: jest.Mock };
let userRepo: { findById: jest.Mock };
let pageRepo: { findById: jest.Mock };
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
let pagePermissionRepo: { canUserEditPage: jest.Mock };
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
// starts false; the extension flips it to true on a read-only downgrade.
const buildData = (token = 'tok') => ({
documentName: `page.${PAGE_ID}`,
token,
connectionConfig: { readOnly: false },
});
beforeEach(() => {
tokenService = { verifyJwt: jest.fn().mockResolvedValue(buildJwt()) };
userRepo = { findById: jest.fn().mockResolvedValue(buildUser()) };
pageRepo = { findById: jest.fn().mockResolvedValue(buildPage()) };
spaceMemberRepo = {
getUserSpaceRoles: jest
.fn()
.mockResolvedValue([{ userId: USER_ID, role: SpaceRole.WRITER }]),
};
pagePermissionRepo = {
// No page-level restriction by default → defer to space role.
canUserEditPage: jest.fn().mockResolvedValue({
hasAnyRestriction: false,
canAccess: true,
canEdit: true,
}),
};
ext = new AuthenticationExtension(
tokenService as any,
userRepo as any,
pageRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
);
// Silence the extension's logger (it warns/debugs on denial branches).
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
});
it('invalid token → UnauthorizedException (no repo lookups happen)', async () => {
tokenService.verifyJwt.mockRejectedValue(new Error('bad sig'));
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
expect(userRepo.findById).not.toHaveBeenCalled();
});
it('user not found → Unauthorized', async () => {
userRepo.findById.mockResolvedValue(null);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('user disabled (deactivatedAt set) → Unauthorized', async () => {
userRepo.findById.mockResolvedValue(
buildUser({ deactivatedAt: new Date() }),
);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('page not found → NotFoundException', async () => {
pageRepo.findById.mockResolvedValue(null);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
NotFoundException,
);
});
it('no space role → Unauthorized', async () => {
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([]);
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('page-level restriction canAccess=false → Unauthorized (restricted-page denial)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: false,
canEdit: false,
});
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
UnauthorizedException,
);
});
it('restriction canAccess=true & canEdit=false → readOnly (no restricted-page write)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: true,
canEdit: false,
});
const data = buildData();
const ctx = await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
expect(ctx.actor).toBe('user');
});
it('restriction canAccess=true & canEdit=true → writable (readOnly stays false)', async () => {
pagePermissionRepo.canUserEditPage.mockResolvedValue({
hasAnyRestriction: true,
canAccess: true,
canEdit: true,
});
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(false);
});
it('no restriction + space READER → readOnly', async () => {
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([
{ userId: USER_ID, role: SpaceRole.READER },
]);
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
});
it('no restriction + space WRITER → writable', async () => {
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(false);
});
it('soft-deleted page (deletedAt set) → readOnly even for a WRITER', async () => {
// A writer must NOT be able to mutate a page in the trash via collab.
pageRepo.findById.mockResolvedValue(buildPage({ deletedAt: new Date() }));
const data = buildData();
await ext.onAuthenticate(data as any);
expect(data.connectionConfig.readOnly).toBe(true);
});
it('agent JWT (actor=agent + aiChatId) propagates into the connection context', async () => {
tokenService.verifyJwt.mockResolvedValue(
buildJwt({ actor: 'agent', aiChatId: 'chat-7' }),
);
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('agent');
expect(ctx.aiChatId).toBe('chat-7');
expect(ctx.user.id).toBe(USER_ID);
});
it('human JWT (no provenance claims) → actor=user, aiChatId=null', async () => {
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('user');
expect(ctx.aiChatId).toBeNull();
});
});

View File

@@ -0,0 +1,105 @@
import {
computeHistoryJob,
resolveSource,
} from './persistence.extension';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
// A fixed clock + fixed createdAt make pageAge deterministic.
const NOW = 1_700_000_000_000;
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
const pageAged = (ageMs: number) => ({
id: PAGE_ID,
createdAt: new Date(NOW - ageMs),
});
describe('computeHistoryJob', () => {
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
// The worker re-reads the page row at run time, so any non-zero delay risks
// snapshotting content a later human edit has already overwritten. This is
// the load-bearing assertion of this spec — do not relax it.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
// Even when the page is far older than the fast threshold, the agent path
// must short-circuit to 0 — age-based debounce is a human-only concern.
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
'agent',
NOW,
);
expect(delay).toBe(0);
expect(jobId).toBe(`${PAGE_ID}-agent`);
});
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD - 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('human edit on an OLD page (age > threshold) → standard interval', () => {
const { jobId, delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD + 1),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
const { delay } = computeHistoryJob(
pageAged(HISTORY_FAST_THRESHOLD),
'user',
NOW,
);
expect(delay).toBe(HISTORY_INTERVAL);
});
it('treats any non-"agent" source string as human', () => {
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
// the agent branch keys strictly on === 'agent'.
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
expect(delay).toBe(HISTORY_FAST_INTERVAL);
expect(jobId).toBe(PAGE_ID);
});
});
describe('resolveSource (truth table)', () => {
// (sticky, actor) → expected. Marker is OR of the sticky flag and actor==='agent'.
it('sticky=false, actor=user → user', () => {
expect(resolveSource(false, 'user')).toBe('user');
});
it('sticky=true, actor=user → agent (sticky wins)', () => {
expect(resolveSource(true, 'user')).toBe('agent');
});
it('sticky=false, actor=agent → agent (current writer is the agent)', () => {
expect(resolveSource(false, 'agent')).toBe('agent');
});
it('sticky=true, actor=agent → agent', () => {
expect(resolveSource(true, 'agent')).toBe('agent');
});
it('sticky=false, actor=undefined → user (human collab path omits the claim)', () => {
expect(resolveSource(false, undefined)).toBe('user');
});
});

View File

@@ -0,0 +1,185 @@
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { tiptapExtensions } from '../collaboration.util';
/**
* Integration test for `onStoreDocument`'s Approach-A boundary snapshot.
*
* The data-loss risk: when an AGENT store lands over a page whose persisted
* state was authored by a HUMAN, the agent overwrites that human content. If we
* do not pin the human revision as its own history version BEFORE the agent's
* updatePage, the last human edit is lost. This test pins the ordering
* (saveHistory(oldHumanPage) strictly before updatePage) and the idempotency
* skip when content is unchanged.
*
* We pass a REAL Y.Doc as the `document` arg (so TiptapTransformer.fromYdoc
* yields real content) and stub repos/queues + an executeTx-compatible db whose
* transaction().execute() invokes the callback with a trx stub.
*/
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const USER_ID = 'human-1';
// Build a real Y.Doc carrying the given tiptap JSON in the 'default' fragment.
// hocuspocus augments the live document with broadcastStateless(); the bare
// Y.Doc lacks it, so stub it for the post-store broadcast.
const ydocFor = (json: any) => {
const ydoc = TiptapTransformer.toYdoc(json, 'default', tiptapExtensions);
(ydoc as any).broadcastStateless = jest.fn();
return ydoc;
};
const doc = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot', () => {
let ext: PersistenceExtension;
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
let pageHistoryRepo: {
saveHistory: jest.Mock;
findPageLastHistory: jest.Mock;
};
let aiQueue: { add: jest.Mock };
let historyQueue: { add: jest.Mock };
let notificationQueue: { add: jest.Mock };
let collabHistory: { addContributors: jest.Mock };
let transclusionService: {
syncPageTransclusions: jest.Mock;
syncPageReferences: jest.Mock;
syncPageTemplateReferences: jest.Mock;
};
let callOrder: string[];
// db whose transaction().execute(fn) runs fn with a trx stub — this lets the
// real executeTx() helper drive the callback without a database.
const trxStub = { __trx: true };
const db = {
transaction: () => ({
execute: (fn: (trx: any) => Promise<any>) => fn(trxStub),
}),
};
// The persisted page row the transaction reads (OLD, human-authored state).
const persistedHumanPage = (newAgentText: string) => ({
id: PAGE_ID,
slugId: 'slug-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
creatorId: 'creator-1',
contributorIds: ['creator-1'],
createdAt: new Date('2020-01-01T00:00:00Z'),
lastUpdatedSource: 'user', // prior revision was human
// content differs from the new agent doc so the update branch runs.
content: doc('OLD HUMAN'),
_newAgentText: newAgentText,
});
const buildData = (document: any, actor: 'user' | 'agent') => ({
documentName: `page.${PAGE_ID}`,
document,
context: { user: { id: USER_ID, name: 'Alice' }, actor },
});
beforeEach(() => {
callOrder = [];
pageRepo = {
findById: jest.fn(),
updatePage: jest.fn().mockImplementation(async () => {
callOrder.push('updatePage');
}),
};
pageHistoryRepo = {
saveHistory: jest.fn().mockImplementation(async () => {
callOrder.push('saveHistory');
}),
findPageLastHistory: jest.fn().mockResolvedValue(null),
};
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
};
ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
});
it('agent store over a human page pins saveHistory(oldHumanPage) BEFORE updatePage', async () => {
const document = ydocFor(doc('NEW AGENT CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
// No human baseline snapshot exists yet → boundary snapshot must run.
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await ext.onStoreDocument(buildData(document, 'agent') as any);
// Boundary snapshot fired, and strictly before the agent overwrite.
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
const saved = pageHistoryRepo.saveHistory.mock.calls[0][0];
expect(saved.content).toEqual(doc('OLD HUMAN')); // the OLD human revision
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
// The agent's new content is tagged 'agent' on the update.
const update = pageRepo.updatePage.mock.calls[0][0];
expect(update.lastUpdatedSource).toBe('agent');
});
it('skips the boundary snapshot when the human baseline is already pinned', async () => {
const document = ydocFor(doc('NEW AGENT CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
// Latest history already equals the current human state → no duplicate.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: doc('OLD HUMAN'),
});
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
it('human store does NOT trigger the boundary snapshot (no source transition)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
});
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
// The Y.Doc content equals the persisted content deeply → early skip.
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
// the persisted content from fromYdoc to make the deep-equal skip genuine.
const document = ydocFor(doc('SAME CONTENT'));
const normalized = TiptapTransformer.fromYdoc(document, 'default');
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('SAME CONTENT'),
content: normalized,
});
await ext.onStoreDocument(buildData(document, 'agent') as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
});

View File

@@ -40,6 +40,52 @@ import {
} from '../constants'; } from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service'; import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
/**
* Resolve the provenance source for a coalesced snapshot.
*
* The snapshot is tagged 'agent' if any agent edit landed in the coalescing
* window (sticky marker) OR if the current writer is the agent; otherwise
* 'user'. Pure so the §15 H2 marker logic is unit-testable in isolation.
*/
export function resolveSource(
stickyTouched: boolean,
contextActor?: string,
): 'agent' | 'user' {
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
}
/**
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
* (caller passes `Date.now()`) for determinism.
*
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
* delay MUST stay 0 — the worker re-reads the page row at run time, so any
* delay risks reading content a later human edit has already overwritten
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
* the job from coalescing with the bare-page.id human job.
* - Human edits: age-based debounce so rapid human edits coalesce into one
* snapshot; job id is the bare `page.id`.
*
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
*/
export function computeHistoryJob(
page: Pick<Page, 'id' | 'createdAt'>,
source: string,
now: number,
): { jobId: string; delay: number } {
const isAgent = source === 'agent';
const pageAge = now - new Date(page.createdAt).getTime();
const delay = isAgent
? 0
: pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
const jobId = isAgent ? `${page.id}-agent` : page.id;
return { jobId, delay };
}
@Injectable() @Injectable()
export class PersistenceExtension implements Extension { export class PersistenceExtension implements Extension {
private readonly logger = new Logger(PersistenceExtension.name); private readonly logger = new Logger(PersistenceExtension.name);
@@ -129,9 +175,10 @@ export class PersistenceExtension implements Extension {
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR // Sticky agent marker: 'agent' if any agent edit landed in this window, OR
// if the current writer is the agent (covers a store with no prior onChange // if the current writer is the agent (covers a store with no prior onChange
// agent event in the same window). §15 H2. // agent event in the same window). §15 H2.
const agentTouched = const lastUpdatedSource = resolveSource(
this.consumeAgentTouched(documentName) || context?.actor === 'agent'; this.consumeAgentTouched(documentName),
const lastUpdatedSource = agentTouched ? 'agent' : 'user'; context?.actor,
);
try { try {
await executeTx(this.db, async (trx) => { await executeTx(this.db, async (trx) => {
@@ -311,24 +358,13 @@ export class PersistenceExtension implements Extension {
page: Page, page: Page,
lastUpdatedSource: string, lastUpdatedSource: string,
): Promise<void> { ): Promise<void> {
// Agent edits get an immediate, source-keyed history job: they snapshot // Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
// deterministically as 'agent' and a later human edit (jobId = page.id) // doc comment for the agent-delay-0 / age-based-debounce invariants).
// cannot coalesce/retag them. Human edits keep the age-based debounce so const { jobId, delay } = computeHistoryJob(
// rapid human edits still coalesce into one snapshot. page,
// NOTE: the agent delay MUST stay 0 — the worker re-reads the page row at lastUpdatedSource,
// run time, so any delay would risk reading content a later human edit has Date.now(),
// already overwritten (mis-tagged snapshot). 0 minimizes that window. );
const isAgent = lastUpdatedSource === 'agent';
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay = isAgent
? 0
: pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
// BullMQ forbids ':' in custom job IDs (it is the Redis key separator), so
// use '-' here. page.id is a UUID, so `${page.id}-agent` cannot collide with
// any human job whose id is a bare page.id.
const jobId = isAgent ? `${page.id}-agent` : page.id;
await this.historyQueue.add( await this.historyQueue.add(
QueueJob.PAGE_HISTORY, QueueJob.PAGE_HISTORY,

View File

@@ -0,0 +1,200 @@
import { Job } from 'bullmq';
import { HistoryProcessor } from './history.processor';
import { QueueJob } from '../../integrations/queue/constants';
/**
* Unit tests for `HistoryProcessor.process`. This worker is the last line of
* defense for the page-history snapshot, so we pin the data-loss-sensitive
* paths: duplicate/empty history skipping (isDeepStrictEqual), and — critically
* — that a saveHistory failure RESTORES the contributors it popped (otherwise
* the contributor set is silently lost) before rethrowing.
*/
const PAGE_ID = 'page-1';
const SPACE_ID = 'space-1';
const WORKSPACE_ID = 'ws-1';
// A non-empty content doc (distinct from the empty-paragraph doc).
const filledContent = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
};
const emptyContent = { type: 'doc', content: [{ type: 'paragraph' }] };
const buildPage = (overrides: Partial<any> = {}) => ({
id: PAGE_ID,
spaceId: SPACE_ID,
workspaceId: WORKSPACE_ID,
content: filledContent,
...overrides,
});
const buildJob = (overrides: Partial<any> = {}) =>
({
name: QueueJob.PAGE_HISTORY,
data: { pageId: PAGE_ID },
...overrides,
}) as unknown as Job<any, void>;
describe('HistoryProcessor.process', () => {
let proc: HistoryProcessor;
let pageHistoryRepo: { findPageLastHistory: jest.Mock; saveHistory: jest.Mock };
let pageRepo: { findById: jest.Mock };
let collabHistory: {
clearContributors: jest.Mock;
popContributors: jest.Mock;
addContributors: jest.Mock;
};
let watcherService: { addPageWatchers: jest.Mock };
let notificationQueue: { add: jest.Mock };
let generalQueue: { add: jest.Mock };
beforeEach(() => {
pageHistoryRepo = {
findPageLastHistory: jest.fn().mockResolvedValue(null),
saveHistory: jest.fn().mockResolvedValue(undefined),
};
pageRepo = { findById: jest.fn().mockResolvedValue(buildPage()) };
collabHistory = {
clearContributors: jest.fn().mockResolvedValue(undefined),
popContributors: jest.fn().mockResolvedValue(['u1', 'u2']),
addContributors: jest.fn().mockResolvedValue(undefined),
};
watcherService = {
addPageWatchers: jest.fn().mockResolvedValue(undefined),
};
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
// WorkerHost's constructor reads `this.worker`; passing repos positionally
// matches the constructor and avoids the Nest DI container.
proc = new HistoryProcessor(
pageHistoryRepo as any,
pageRepo as any,
collabHistory as any,
watcherService as any,
notificationQueue as any,
generalQueue as any,
);
jest.spyOn(proc['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(proc['logger'], 'warn').mockImplementation(() => undefined);
jest.spyOn(proc['logger'], 'error').mockImplementation(() => undefined);
});
it('ignores jobs whose name is not PAGE_HISTORY (no page lookup)', async () => {
await proc.process(buildJob({ name: 'some.other.job' }));
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('page not found → clearContributors and return (no save)', async () => {
pageRepo.findById.mockResolvedValue(null);
await proc.process(buildJob());
expect(collabHistory.clearContributors).toHaveBeenCalledWith(PAGE_ID);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(collabHistory.popContributors).not.toHaveBeenCalled();
});
it('first history + empty content → skip and clear contributors (no save)', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
pageRepo.findById.mockResolvedValue(buildPage({ content: emptyContent }));
await proc.process(buildJob());
expect(collabHistory.clearContributors).toHaveBeenCalledWith(PAGE_ID);
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
});
it('content unchanged vs last history → no save (isDeepStrictEqual skip)', async () => {
// Last history holds a deep-equal-but-distinct copy of current content.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: JSON.parse(JSON.stringify(filledContent)),
});
await proc.process(buildJob());
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(collabHistory.popContributors).not.toHaveBeenCalled();
});
it('content changed → addPageWatchers + saveHistory + backlinks queue', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
await proc.process(buildJob());
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
['u1', 'u2'],
PAGE_ID,
SPACE_ID,
WORKSPACE_ID,
);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: PAGE_ID }),
{ contributorIds: ['u1', 'u2'] },
);
expect(generalQueue.add).toHaveBeenCalledWith(
QueueJob.PAGE_BACKLINKS,
expect.objectContaining({ pageId: PAGE_ID, workspaceId: WORKSPACE_ID }),
);
});
it('first history (lastHistory null) with non-empty content → saves, no PAGE_UPDATED notification', async () => {
// popContributors yields users, but lastHistory?.content is falsy so the
// notification branch (needs a prior version) must be skipped.
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
await proc.process(buildJob());
expect(pageHistoryRepo.saveHistory).toHaveBeenCalled();
expect(notificationQueue.add).not.toHaveBeenCalled();
});
it('changed content WITH prior history + contributors → queues PAGE_UPDATED notification', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
await proc.process(buildJob());
expect(notificationQueue.add).toHaveBeenCalledWith(
QueueJob.PAGE_UPDATED,
expect.objectContaining({
pageId: PAGE_ID,
actorIds: ['u1', 'u2'],
}),
);
});
it('saveHistory throws → contributors RESTORED (addContributors) AND error rethrown', async () => {
// The data-loss guard: if the snapshot save fails after popContributors,
// the popped ids MUST be returned to the pending set, then the error
// propagates so BullMQ retries. Assert BOTH halves.
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
const boom = new Error('db down');
pageHistoryRepo.saveHistory.mockRejectedValue(boom);
await expect(proc.process(buildJob())).rejects.toThrow('db down');
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
'u1',
'u2',
]);
});
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
content: { type: 'doc', content: [] },
});
generalQueue.add.mockRejectedValue(new Error('redis backlinks down'));
notificationQueue.add.mockRejectedValue(new Error('redis notif down'));
// The downstream queue failures are caught internally; process resolves.
await expect(proc.process(buildJob())).resolves.toBeUndefined();
expect(pageHistoryRepo.saveHistory).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,309 @@
import {
extractUserMentionIdsFromJson,
getAttachmentIds,
extractMentions,
extractUserMentions,
extractPageMentions,
removeMarkTypeFromDoc,
} from './utils';
import { jsonToNode } from '../../../collaboration/collaboration.util';
// Real UUIDs (uuid.validate must accept these).
const UUID_A = '550e8400-e29b-41d4-a716-446655440000';
const UUID_B = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const UUID_C = '00000000-0000-4000-8000-000000000000';
// Helper builders that mirror the real ProseMirror JSON shapes.
const doc = (...content: any[]) => ({ type: 'doc', content });
const paragraph = (...content: any[]) => ({ type: 'paragraph', content });
const mention = (attrs: Record<string, any>) => ({ type: 'mention', attrs });
describe('extractUserMentionIdsFromJson', () => {
it('collects entityIds for user mentions only', () => {
const json = doc(
paragraph(
mention({ entityType: 'user', entityId: UUID_A }),
mention({ entityType: 'user', entityId: UUID_B }),
),
);
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A, UUID_B]);
});
it('dedups the same entityId', () => {
const json = doc(
paragraph(
mention({ entityType: 'user', entityId: UUID_A }),
mention({ entityType: 'user', entityId: UUID_A }),
),
);
// Mutation guard: a non-dedup impl would return [UUID_A, UUID_A].
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
expect(extractUserMentionIdsFromJson(json)).toHaveLength(1);
});
it('filters OUT non-user entityTypes (page mentions ignored)', () => {
const json = doc(
paragraph(
mention({ entityType: 'page', entityId: UUID_A }),
mention({ entityType: 'user', entityId: UUID_B }),
),
);
// Cross-contamination guard: page mention must not leak in.
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_B]);
});
it('skips a user mention with no entityId', () => {
const json = doc(
paragraph(
mention({ entityType: 'user' }),
mention({ entityType: 'user', entityId: UUID_A }),
),
);
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
});
it('returns [] for null / undefined node', () => {
expect(extractUserMentionIdsFromJson(null)).toEqual([]);
expect(extractUserMentionIdsFromJson(undefined)).toEqual([]);
});
it('handles a mention node with missing attrs without throwing', () => {
const json = doc(paragraph({ type: 'mention' }));
expect(() => extractUserMentionIdsFromJson(json)).not.toThrow();
expect(extractUserMentionIdsFromJson(json)).toEqual([]);
});
it('walks deeply nested content', () => {
const json = doc(
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
paragraph(mention({ entityType: 'user', entityId: UUID_A })),
],
},
],
},
);
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
});
});
describe('getAttachmentIds', () => {
it('collects attachmentIds from image, video and attachment nodes', () => {
const json = doc(
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
{ type: 'video', attrs: { src: 'b', attachmentId: UUID_B } },
{
type: 'attachment',
attrs: {
url: 'c',
name: 'file',
mimeType: 'application/pdf',
size: 1,
attachmentId: UUID_C,
},
},
);
expect(getAttachmentIds(json).sort()).toEqual(
[UUID_A, UUID_B, UUID_C].sort(),
);
});
it('skips an invalid (non-UUID) attachmentId', () => {
const json = doc(
{ type: 'image', attrs: { src: 'a', attachmentId: 'not-a-uuid' } },
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
);
// Guard: a non-UUID must never leak into downstream queries.
expect(getAttachmentIds(json)).toEqual([UUID_A]);
});
it('dedups the same attachmentId across nodes', () => {
const json = doc(
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
);
expect(getAttachmentIds(json)).toEqual([UUID_A]);
});
it('ignores non-attachment node types', () => {
const json = doc(
paragraph({ type: 'text', text: 'hi' }),
// A paragraph carrying an attachmentId-like attr must NOT be collected.
{ ...paragraph(), attrs: { attachmentId: UUID_A } },
);
expect(getAttachmentIds(json)).toEqual([]);
});
it('returns [] for an empty doc with no attachments', () => {
expect(getAttachmentIds(doc(paragraph()))).toEqual([]);
});
});
describe('extractMentions / extractUserMentions / extractPageMentions', () => {
it('extractMentions dedups by id (NOT by entityId)', () => {
const json = doc(
paragraph(
mention({
id: 'mention-1',
label: 'Alice',
entityType: 'user',
entityId: UUID_A,
creatorId: UUID_C,
}),
// Same id, different label -> must be dropped as a duplicate.
mention({
id: 'mention-1',
label: 'Alice again',
entityType: 'user',
entityId: UUID_A,
creatorId: UUID_C,
}),
// Different id but SAME entityId -> must be KEPT (dedup key is id).
mention({
id: 'mention-2',
label: 'Alice elsewhere',
entityType: 'user',
entityId: UUID_A,
creatorId: UUID_C,
}),
),
);
const result = extractMentions(json);
// Dedup key footgun: if it deduped by entityId we'd only get 1.
expect(result.map((m) => m.id)).toEqual(['mention-1', 'mention-2']);
});
it('extractMentions skips a mention missing id', () => {
const json = doc(
paragraph(
mention({ label: 'no id', entityType: 'user', entityId: UUID_A }),
mention({
id: 'mention-1',
label: 'has id',
entityType: 'user',
entityId: UUID_A,
}),
),
);
const result = extractMentions(json);
expect(result.map((m) => m.id)).toEqual(['mention-1']);
});
it('extractMentions preserves the full mention shape', () => {
const json = doc(
paragraph(
mention({
id: 'mention-1',
label: 'Bob',
entityType: 'user',
entityId: UUID_B,
creatorId: UUID_C,
}),
),
);
const [m] = extractMentions(json);
expect(m).toMatchObject({
id: 'mention-1',
label: 'Bob',
entityType: 'user',
entityId: UUID_B,
creatorId: UUID_C,
});
});
it('extractUserMentions keeps only entityType === user', () => {
const list = [
{ id: '1', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
{ id: '2', label: 'p', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
] as any;
const users = extractUserMentions(list);
expect(users.map((m) => m.id)).toEqual(['1']);
expect(users.every((m) => m.entityType === 'user')).toBe(true);
});
it('extractPageMentions dedups by entityId and filters to page', () => {
const list = [
{ id: 'a', label: 'p', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
// Same entityId, different id -> must be dropped (dedup key is entityId).
{ id: 'b', label: 'p2', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
// A user mention that happens to share the entityId -> filtered out.
{ id: 'c', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
{ id: 'd', label: 'p3', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
] as any;
const pages = extractPageMentions(list);
// Dedup key footgun: dedup is by entityId here, not by id.
expect(pages.map((m) => m.entityId)).toEqual([UUID_A, UUID_B]);
expect(pages.map((m) => m.id)).toEqual(['a', 'd']);
expect(pages.every((m) => m.entityType === 'page')).toBe(true);
});
it('extractUserMentions / extractPageMentions return [] for an empty list', () => {
expect(extractUserMentions([])).toEqual([]);
expect(extractPageMentions([])).toEqual([]);
});
});
describe('removeMarkTypeFromDoc', () => {
it('removes the named mark across the whole doc', () => {
const node = jsonToNode(
doc(
paragraph({ type: 'text', text: 'first', marks: [{ type: 'bold' }] }),
paragraph({ type: 'text', text: 'second', marks: [{ type: 'bold' }] }),
),
);
const result = removeMarkTypeFromDoc(node, 'bold');
// No text node anywhere should still carry marks after removal.
const json = result.toJSON();
const marksLeft: any[] = [];
result.descendants((n) => {
if (n.marks.length > 0) marksLeft.push(n.marks);
});
expect(marksLeft).toEqual([]);
expect(JSON.stringify(json)).not.toContain('"type":"bold"');
// Text content survives, only the mark is gone.
expect(result.textContent).toBe('firstsecond');
});
it('leaves other marks intact when removing one mark type', () => {
const node = jsonToNode(
doc(
paragraph({
type: 'text',
text: 'styled',
marks: [{ type: 'bold' }, { type: 'italic' }],
}),
),
);
const result = removeMarkTypeFromDoc(node, 'bold');
const serialized = JSON.stringify(result.toJSON());
expect(serialized).not.toContain('"bold"');
expect(serialized).toContain('"italic"');
});
it('returns the doc unchanged (no throw) for an unknown mark name', () => {
const node = jsonToNode(
doc(paragraph({ type: 'text', text: 'x', marks: [{ type: 'bold' }] })),
);
let result!: ReturnType<typeof removeMarkTypeFromDoc>;
// Guard: the `!markType` branch must short-circuit, never throw.
expect(() => {
result = removeMarkTypeFromDoc(node, 'noSuchMarkAnywhere');
}).not.toThrow();
// Returns the SAME node reference (no transform applied).
expect(result).toBe(node);
expect(JSON.stringify(result.toJSON())).toContain('"bold"');
});
it('is a no-op on a doc that has no marks', () => {
const node = jsonToNode(
doc(paragraph({ type: 'text', text: 'plain' })),
);
const result = removeMarkTypeFromDoc(node, 'bold');
expect(result.textContent).toBe('plain');
expect(JSON.stringify(result.toJSON())).not.toContain('marks');
});
});

View File

@@ -16,11 +16,12 @@ import { getHTMLFromFragment } from './getHTMLFromFragment';
* ``` * ```
*/ */
export function generateHTML(doc: JSONContent, extensions: Extensions): string { export function generateHTML(doc: JSONContent, extensions: Extensions): string {
if (typeof window !== 'undefined') { // No global-`window` guard here: this helper is server-only and self-contained
throw new Error( // (it serializes via `getHTMLFromFragment`, which creates its own happy-dom
'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', // `Window` and never reads the global `window`). A guard on `typeof window`
); // would be a false positive whenever a global `window` is injected into the
} // Node process (e.g. by the in-process MCP module, which sets `global.window`
// via jsdom).
const schema = getSchema(extensions); const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc); const contentNode = Node.fromJSON(schema, doc);

View File

@@ -21,11 +21,11 @@ export function generateJSON(
extensions: Extensions, extensions: Extensions,
options?: ParseOptions, options?: ParseOptions,
): Record<string, any> { ): Record<string, any> {
if (typeof window !== 'undefined') { // No global-`window` guard here: this helper is server-only and self-contained
throw new Error( // (it creates its own happy-dom `Window` below and never reads the global
'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', // `window`). A guard on `typeof window` would be a false positive whenever a
); // global `window` is injected into the Node process (e.g. by the in-process
} // MCP module, which sets `global.window` via jsdom).
const localWindow = new Window(); const localWindow = new Window();
const localDOMParser = new localWindow.DOMParser(); const localDOMParser = new localWindow.DOMParser();

View File

@@ -0,0 +1,52 @@
import { resolveFrameHeader } from './security-headers';
describe('resolveFrameHeader', () => {
describe('iframe embedding disabled (clickjacking protection)', () => {
it('returns X-Frame-Options SAMEORIGIN and ignores origins', () => {
expect(resolveFrameHeader(false, [])).toEqual({
name: 'X-Frame-Options',
value: 'SAMEORIGIN',
});
});
it('still returns X-Frame-Options even when origins are configured', () => {
// A wrong branch could leak a permissive CSP here; origins must be ignored
// when embedding is disabled so clickjacking protection stays intact.
const result = resolveFrameHeader(false, [
'https://a.com',
'https://b.com',
]);
expect(result).toEqual({
name: 'X-Frame-Options',
value: 'SAMEORIGIN',
});
expect(result?.name).not.toBe('Content-Security-Policy');
});
});
describe('iframe embedding allowed', () => {
it('returns null when there are no allowed origins', () => {
expect(resolveFrameHeader(true, [])).toBeNull();
});
it('builds a frame-ancestors CSP for a single origin', () => {
expect(resolveFrameHeader(true, ['https://a.com'])).toEqual({
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.com",
});
});
it('space-joins multiple origins after self', () => {
expect(
resolveFrameHeader(true, [
'https://a.com',
'https://b.com',
'https://c.com',
]),
).toEqual({
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.com https://b.com https://c.com",
});
});
});
});

View File

@@ -0,0 +1,245 @@
import { FastifyRequest } from 'fastify';
import {
redactSensitiveUrl,
extractBearerTokenFromHeader,
parseRedisUrl,
normalizePostgresUrl,
diffAuditTrackedFields,
isUserDisabled,
} from './utils';
/**
* Build a minimal FastifyRequest-shaped object carrying just the authorization
* header, which is all extractBearerTokenFromHeader reads.
*/
function reqWithAuth(authorization?: string): FastifyRequest {
return { headers: { authorization } } as unknown as FastifyRequest;
}
describe('redactSensitiveUrl', () => {
it('strips the query string from a sensitive (SSO) URL', () => {
expect(
redactSensitiveUrl('/api/sso/google/callback?code=secret&state=pii'),
).toBe('/api/sso/google/callback');
});
it('returns a sensitive URL unchanged when it has no query string', () => {
expect(redactSensitiveUrl('/api/sso/google/callback')).toBe(
'/api/sso/google/callback',
);
});
it('does NOT strip the query string from a non-sensitive URL', () => {
// A mutation that redacts everything would break legitimate logging here.
expect(redactSensitiveUrl('/api/pages/list?page=2&token=abc')).toBe(
'/api/pages/list?page=2&token=abc',
);
});
it('handles empty string without throwing and returns it unchanged', () => {
expect(redactSensitiveUrl('')).toBe('');
});
it('handles undefined input without throwing', () => {
expect(
redactSensitiveUrl(undefined as unknown as string),
).toBeUndefined();
});
});
describe('extractBearerTokenFromHeader', () => {
it('extracts the token from a Bearer scheme', () => {
expect(extractBearerTokenFromHeader(reqWithAuth('Bearer xyz'))).toBe('xyz');
});
it('is case-insensitive on the scheme', () => {
// Impl lowercases the scheme before comparing, so lowercase "bearer" works.
expect(extractBearerTokenFromHeader(reqWithAuth('bearer xyz'))).toBe('xyz');
expect(extractBearerTokenFromHeader(reqWithAuth('BEARER xyz'))).toBe('xyz');
});
it('rejects a non-Bearer scheme (auth bypass guard)', () => {
expect(
extractBearerTokenFromHeader(reqWithAuth('Basic xyz')),
).toBeUndefined();
});
it('returns undefined when the header is missing', () => {
expect(extractBearerTokenFromHeader(reqWithAuth(undefined))).toBeUndefined();
});
it('returns undefined for an empty header', () => {
expect(extractBearerTokenFromHeader(reqWithAuth(''))).toBeUndefined();
});
it('returns undefined when the scheme has no token', () => {
expect(
extractBearerTokenFromHeader(reqWithAuth('Bearer')),
).toBeUndefined();
});
});
describe('parseRedisUrl', () => {
it('parses a full URL into host/port/password/db/family', () => {
expect(parseRedisUrl('redis://user:pass@host:6379/3?family=6')).toEqual({
host: 'host',
port: 6379,
password: 'pass',
db: 3,
family: 6,
});
});
it('defaults db to 0 when there is no /db path segment', () => {
const cfg = parseRedisUrl('redis://localhost:6379');
expect(cfg.db).toBe(0);
expect(cfg.host).toBe('localhost');
expect(cfg.port).toBe(6379);
// No family query → undefined (not parsed).
expect(cfg.family).toBeUndefined();
});
it('falls back to db 0 for a non-numeric db segment', () => {
expect(parseRedisUrl('redis://localhost:6379/abc').db).toBe(0);
});
it('returns an empty-string password when the URL has no credentials', () => {
// Quirk: WHATWG URL exposes a missing password as '' (empty string),
// not undefined, so the helper propagates ''.
const cfg = parseRedisUrl('redis://localhost:6379/1');
expect(cfg.password).toBe('');
expect(cfg.db).toBe(1);
});
});
describe('normalizePostgresUrl', () => {
it('removes sslmode=no-verify but keeps other sslmode values', () => {
expect(
normalizePostgresUrl(
'postgres://u:p@host:5432/db?sslmode=no-verify',
),
).toBe('postgres://u:p@host:5432/db');
expect(
normalizePostgresUrl('postgres://u:p@host:5432/db?sslmode=require'),
).toBe('postgres://u:p@host:5432/db?sslmode=require');
});
it('removes the schema param while preserving unrelated params', () => {
expect(
normalizePostgresUrl(
'postgres://u:p@host:5432/db?schema=public&application_name=app',
),
).toBe('postgres://u:p@host:5432/db?application_name=app');
});
it('returns a URL with no query string untouched', () => {
expect(normalizePostgresUrl('postgres://u:p@host:5432/db')).toBe(
'postgres://u:p@host:5432/db',
);
});
});
describe('diffAuditTrackedFields', () => {
const fields = ['name', 'email', 'settings'] as const;
it('returns a before/after entry for a changed tracked field', () => {
expect(
diffAuditTrackedFields(
fields,
{ name: 'new' },
{ name: 'old' },
{ name: 'new' },
),
).toEqual({ before: { name: 'old' }, after: { name: 'new' } });
});
it('skips a field whose value is unchanged', () => {
expect(
diffAuditTrackedFields(
fields,
{ name: 'same' },
{ name: 'same' },
{ name: 'same' },
),
).toBeNull();
});
it('skips a field that is absent from the dto (undefined guard)', () => {
// before/after differ, but the dto does not carry this field → not tracked.
expect(
diffAuditTrackedFields(
fields,
{},
{ name: 'old' },
{ name: 'new' },
),
).toBeNull();
});
it('returns null when nothing changed across all fields', () => {
expect(
diffAuditTrackedFields(
fields,
{ name: 'a', email: 'b@x' },
{ name: 'a', email: 'b@x' },
{ name: 'a', email: 'b@x' },
),
).toBeNull();
});
it('treats null and undefined as equal (no false diff)', () => {
// before has explicit null, after omits the key (undefined) → both ?? null.
expect(
diffAuditTrackedFields(
fields,
{ email: 'present' },
{ email: null },
{},
),
).toBeNull();
});
it('compares object-valued fields structurally via JSON.stringify', () => {
// Distinct object references with equal contents must NOT register a diff.
expect(
diffAuditTrackedFields(
fields,
{ settings: { theme: 'dark' } },
{ settings: { theme: 'dark' } },
{ settings: { theme: 'dark' } },
),
).toBeNull();
expect(
diffAuditTrackedFields(
fields,
{ settings: { theme: 'dark' } },
{ settings: { theme: 'light' } },
{ settings: { theme: 'dark' } },
),
).toEqual({
before: { settings: { theme: 'light' } },
after: { settings: { theme: 'dark' } },
});
});
});
describe('isUserDisabled', () => {
it('returns false for an active user', () => {
expect(isUserDisabled({ deactivatedAt: null, deletedAt: null })).toBe(false);
expect(isUserDisabled({})).toBe(false);
});
it('returns true for a deactivated user', () => {
expect(
isUserDisabled({ deactivatedAt: new Date('2026-01-01'), deletedAt: null }),
).toBe(true);
});
it('returns true for a deleted user', () => {
expect(
isUserDisabled({ deactivatedAt: null, deletedAt: new Date('2026-01-01') }),
).toBe(true);
});
});

View File

@@ -161,7 +161,16 @@ export class AiChatController {
// cannot simply remove it once `stream()` returns). // cannot simply remove it once `stream()` returns).
const controller = new AbortController(); const controller = new AbortController();
const onClose = (): void => { const onClose = (): void => {
if (!res.raw.writableEnded) controller.abort(); // A genuine disconnect leaves the response unfinished (unlike a normal
// completion, which also fires `close`). Such a drop — e.g. a reverse
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
// so log it here before aborting the agent loop.
if (!res.raw.writableEnded) {
this.logger.warn(
'AI chat stream: client disconnected before completion; aborting turn',
);
controller.abort();
}
}; };
req.raw.once('close', onClose); req.raw.once('close', onClose);
res.raw.once('finish', () => req.raw.off('close', onClose)); res.raw.once('finish', () => req.raw.off('close', onClose));
@@ -228,25 +237,14 @@ export class AiChatController {
} }
if (!file) throw new BadRequestException('No audio uploaded'); if (!file) throw new BadRequestException('No audio uploaded');
// Whitelist audio container types produced by browser MediaRecorder // Resolve + whitelist the upload's container type (MediaRecorder mimetypes
// (Chrome/FF: webm/opus, Safari: mp4) plus common STT-accepted formats. // carry parameters, e.g. "audio/webm;codecs=opus"). A non-whitelisted type
const allowedMime = new Set([ // is rejected; an allowed one yields the STT container-format hint.
'audio/webm', const resolved = resolveAudioFormat(file.mimetype);
'audio/ogg', if (!resolved.ok) {
'audio/mp4',
'audio/mpeg',
'audio/wav',
'audio/x-wav',
'audio/wave',
'audio/m4a',
'audio/x-m4a',
]);
// MediaRecorder mimetypes carry parameters (e.g. "audio/webm;codecs=opus");
// compare only the base type.
const baseMime = file.mimetype.split(';')[0].trim().toLowerCase();
if (!allowedMime.has(baseMime)) {
throw new BadRequestException('Unsupported audio format'); throw new BadRequestException('Unsupported audio format');
} }
const { format } = resolved;
let buf: Buffer; let buf: Buffer;
try { try {
@@ -259,20 +257,6 @@ export class AiChatController {
} }
throw err; throw err;
} }
// Container hint for JSON-style STT providers (e.g. OpenRouter); multipart
// endpoints ignore it.
const formatMap: Record<string, string> = {
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/mp4': 'mp4',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/x-wav': 'wav',
'audio/wave': 'wav',
'audio/m4a': 'm4a',
'audio/x-m4a': 'm4a',
};
const format = formatMap[baseMime] ?? 'webm';
let text: string; let text: string;
try { try {
text = await this.aiTranscription.transcribe(workspace.id, buf, format); text = await this.aiTranscription.transcribe(workspace.id, buf, format);
@@ -302,3 +286,39 @@ export class AiChatController {
} }
} }
} }
/**
* Whitelist audio container types produced by browser MediaRecorder (Chrome/FF:
* webm/opus, Safari: mp4) plus common STT-accepted formats. The value maps each
* allowed base mime to the container-format hint passed to JSON-style STT
* providers (e.g. OpenRouter); multipart endpoints ignore the hint.
*/
const AUDIO_FORMAT_MAP: Record<string, string> = {
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/mp4': 'mp4',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/x-wav': 'wav',
'audio/wave': 'wav',
'audio/m4a': 'm4a',
'audio/x-m4a': 'm4a',
};
/**
* Resolve and whitelist an uploaded clip's mimetype. MediaRecorder mimetypes
* carry parameters (e.g. "audio/webm;codecs=opus"), so the base type is split
* out (lowercased, trimmed) before the whitelist check. Returns ok=false for a
* non-whitelisted container; otherwise the base mime and its STT format hint.
* Pure — the caller throws BadRequestException on !ok.
*/
export function resolveAudioFormat(
mimetype: string,
): { ok: true; baseMime: string; format: string } | { ok: false } {
const baseMime = mimetype.split(';')[0].trim().toLowerCase();
const format = AUDIO_FORMAT_MAP[baseMime];
if (format === undefined) {
return { ok: false };
}
return { ok: true, baseMime, format };
}

View File

@@ -82,3 +82,82 @@ describe('buildSystemPrompt role layering', () => {
expect(prompt).toContain(SAFETY_MARKER); expect(prompt).toContain(SAFETY_MARKER);
}); });
}); });
/**
* Unit tests for the "current page" context injected by buildSystemPrompt. When
* the client supplies an openedPage with a non-blank id, a CONTEXT line names
* the page (title or "Untitled") and its pageId so the agent can resolve "this
* page". When no usable id is present, nothing is added. The line always sits
* inside the safety sandwich, before the trailing SAFETY copy.
*/
describe('buildSystemPrompt current-page context', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const SAFETY_MARKER = 'Operating rules (always in effect)';
it('includes the page title and pageId when both are present', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Audio Tract' },
});
expect(prompt).toContain('currently viewing the page');
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Audio Tract"');
});
it('falls back to "Untitled" when the title is missing', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123' },
});
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Untitled"');
});
it('falls back to "Untitled" when the title is only whitespace', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: ' ' },
});
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Untitled"');
});
it('adds no page context when openedPage is null', () => {
const prompt = buildSystemPrompt({ workspace, openedPage: null });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when openedPage is omitted', () => {
const prompt = buildSystemPrompt({ workspace });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when openedPage has no id', () => {
const prompt = buildSystemPrompt({ workspace, openedPage: { title: 'x' } });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when the id is only whitespace', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: ' ' },
});
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('places the page context inside the safety sandwich (before the closing SAFETY)', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Audio Tract' },
});
const pageIdx = prompt.indexOf('currently viewing the page');
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
expect(pageIdx).toBeGreaterThan(firstSafety);
expect(pageIdx).toBeLessThan(lastSafety);
});
});

View File

@@ -50,6 +50,8 @@ describe('AiChatService.resolveRoleForRequest', () => {
{} as never, // tools {} as never, // tools
{} as never, // mcpClients {} as never, // mcpClients
aiAgentRoleRepo as never, aiAgentRoleRepo as never,
{} as never, // pageRepo
{} as never, // pageAccess
); );
return { service, aiChatRepo, aiAgentRoleRepo }; return { service, aiChatRepo, aiAgentRoleRepo };
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { import {
streamText, streamText,
@@ -14,6 +14,8 @@ import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo'; import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo'; import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { import {
User, User,
Workspace, Workspace,
@@ -126,6 +128,8 @@ export class AiChatService {
private readonly tools: AiChatToolsService, private readonly tools: AiChatToolsService,
private readonly mcpClients: McpClientsService, private readonly mcpClients: McpClientsService,
private readonly aiAgentRoleRepo: AiAgentRoleRepo, private readonly aiAgentRoleRepo: AiAgentRoleRepo,
private readonly pageRepo: PageRepo,
private readonly pageAccess: PageAccessService,
) {} ) {}
/** /**
@@ -195,12 +199,44 @@ export class AiChatService {
} }
} }
if (!chatId) { if (!chatId) {
// Resolve the origin document for the history list. body.openPage.id is
// attacker-controllable, so validate it before persisting: it must be a
// real page in THIS workspace that the user is allowed to read. Anything
// else (foreign workspace, inaccessible/restricted, or non-existent) is
// dropped to null — persisting it would leak the page's title via the
// chat-list join, or violate the page_id FK on insert (this runs after
// res.hijack(), so a DB error would break the stream).
let originPageId: string | null = null;
const candidatePageId = body.openPage?.id;
if (candidatePageId) {
const page = await this.pageRepo.findById(candidatePageId);
if (page && page.workspaceId === workspace.id) {
try {
await this.pageAccess.validateCanView(page, user);
originPageId = page.id;
} catch (e) {
// Fail-closed: no provenance on any failure. A ForbiddenException is
// the expected "user cannot read this page" case; log anything else
// (e.g. a DB error) so a real fault is not masked as "no access".
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`origin page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
originPageId = null;
}
}
}
const chat = await this.aiChatRepo.insert({ const chat = await this.aiChatRepo.insert({
creatorId: user.id, creatorId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
// Bind the chat to the resolved role (if any) at creation time. The role // Bind the chat to the resolved role (if any) at creation time. The role
// is immutable afterwards (later turns read it from this column). // is immutable afterwards (later turns read it from this column).
roleId: role?.id ?? null, roleId: role?.id ?? null,
// Validated above: a real, readable page in this workspace, else null.
pageId: originPageId,
}); });
chatId = chat.id; chatId = chat.id;
isNewChat = true; isNewChat = true;
@@ -394,6 +430,14 @@ export class AiChatService {
// Client disconnected / request aborted: persist the partial answer, // Client disconnected / request aborted: persist the partial answer,
// including any completed tool steps so the turn replays faithfully. // including any completed tool steps so the turn replays faithfully.
const text = steps.map((s) => s.text ?? '').join(''); const text = steps.map((s) => s.text ?? '').join('');
// Unlike onError/onFinish, this terminal path otherwise writes nothing,
// so an aborted turn (client disconnect / proxy drop / stop()) would be
// invisible in the logs. Log it (warn) so the abort is traceable, with
// the step count and how much partial text was produced before the cut.
this.logger.warn(
`AI chat stream aborted (chat ${chatId}) after ${steps.length} ` +
`step(s), ${text.length} chars partial text; persisting partial turn.`,
);
await persistAssistant({ await persistAssistant({
text, text,
toolCalls: serializeSteps(steps), toolCalls: serializeSteps(steps),

View File

@@ -0,0 +1,80 @@
import { EmbeddingIndexerService } from './embedding-indexer.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AiService } from '../../../integrations/ai/ai.service';
/**
* Unit tests for EmbeddingIndexerService.reindexWorkspace's batch control flow.
*
* The constructor body only stores its deps, so the service can be unit-built
* with lightweight mocks — no Nest module graph. We stub only the methods that
* reindexWorkspace actually touches:
* - aiService.getEmbeddingModel -> a model string so the up-front configured
* check passes,
* - pageRepo.getIdsByWorkspace -> three page ids,
* - service.reindexPage -> spied per test to drive the per-page outcome.
*
* The point under test is the catch block: a FATAL provider error (auth/billing)
* must abort the whole batch (re-throw, stop iterating), while a non-fatal error
* keeps per-page isolation (failed++, continue to the next page).
*/
describe('EmbeddingIndexerService.reindexWorkspace fail-fast', () => {
const WORKSPACE_ID = 'ws-1';
function makeService() {
const pageRepo = {
getIdsByWorkspace: jest.fn().mockResolvedValue(['p1', 'p2', 'p3']),
};
const pageEmbeddingRepo = {};
const aiService = {
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
};
const db = {};
const service = new EmbeddingIndexerService(
pageRepo as unknown as PageRepo,
pageEmbeddingRepo as unknown as PageEmbeddingRepo,
aiService as unknown as AiService,
db as unknown as KyselyDB,
);
return { service, pageRepo, aiService };
}
it('aborts after the first page on a FATAL (401) provider error', async () => {
const { service } = makeService();
// A 401 "User not found" recurs identically on every page -> must abort.
const reindexPage = jest
.spyOn(service, 'reindexPage')
.mockRejectedValue({ statusCode: 401, message: 'User not found' });
await expect(service.reindexWorkspace(WORKSPACE_ID)).rejects.toMatchObject({
statusCode: 401,
});
// Aborted on the first page: pages 2 and 3 were never attempted.
expect(reindexPage).toHaveBeenCalledTimes(1);
});
it('keeps per-page isolation on a non-fatal error (plain Error, no statusCode)', async () => {
const { service } = makeService();
// No statusCode -> non-fatal -> isolate per page and continue.
const reindexPage = jest
.spyOn(service, 'reindexPage')
.mockRejectedValue(new Error('boom'));
// Resolves (does not throw) even though every page failed.
await expect(service.reindexWorkspace(WORKSPACE_ID)).resolves.toBeUndefined();
// All three pages were attempted despite the failures.
expect(reindexPage).toHaveBeenCalledTimes(3);
});
it('processes every page on the all-success path', async () => {
const { service } = makeService();
const reindexPage = jest
.spyOn(service, 'reindexPage')
.mockResolvedValue(undefined);
await expect(service.reindexWorkspace(WORKSPACE_ID)).resolves.toBeUndefined();
expect(reindexPage).toHaveBeenCalledTimes(3);
});
});

View File

@@ -10,7 +10,10 @@ import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { AiService } from '../../../integrations/ai/ai.service'; import { AiService } from '../../../integrations/ai/ai.service';
import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception'; import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception';
import { describeProviderError } from '../../../integrations/ai/ai-error.util'; import {
describeProviderError,
isFatalProviderError,
} from '../../../integrations/ai/ai-error.util';
import { jsonToText } from '../../../collaboration/collaboration.util'; import { jsonToText } from '../../../collaboration/collaboration.util';
// NOTE: the `page_embeddings.embedding` column is now dimension-agnostic // NOTE: the `page_embeddings.embedding` column is now dimension-agnostic
@@ -229,8 +232,19 @@ export class EmbeddingIndexerService {
); );
} }
} catch (err) { } catch (err) {
// Per-page isolation: one failure (incl. an embedding timeout) must not // A fatal provider error (invalid/missing key, no credits) recurs
// abort the whole batch. // identically on EVERY remaining page. Abort the whole batch instead of
// issuing hundreds of doomed requests against the provider.
if (isFatalProviderError(err)) {
this.logger.error(
`reindexWorkspace: aborting at [${position}/${total}] for workspace ` +
`${workspaceId} — fatal provider error, remaining pages would fail ` +
`identically: ${describeProviderError(err)}`,
);
throw err;
}
// Per-page isolation: one non-fatal failure (incl. an embedding timeout)
// must not abort the whole batch.
failed++; failed++;
this.logger.error( this.logger.error(
`reindexWorkspace: [${position}/${total}] failed to reindex page ${pageId} ` + `reindexWorkspace: [${position}/${total}] failed to reindex page ${pageId} ` +

View File

@@ -367,6 +367,28 @@ export class McpClientsService {
} }
} }
/**
* Apply the SSRF connect-time rule to a set of DNS-resolved addresses: block if
* ANY resolved address is disallowed by `isIpAllowed`, and block an EMPTY set
* (nothing safe to connect to). Only an all-public, non-empty set is allowed.
*
* This is the connect-time half of the DNS-rebinding defense: the dispatcher's
* lookup hands net/tls.connect ONLY a set that passed this check, so the kernel
* can never connect to an address that did not pass the guard. Pure — no I/O.
*/
export function validateResolvedAddresses(
addrs: readonly LookupAddress[],
): { ok: boolean; blockedHost?: string } {
if (addrs.length === 0) {
return { ok: false };
}
const blocked = addrs.find((a) => !isIpAllowed(a.address).ok);
if (blocked) {
return { ok: false, blockedHost: blocked.address };
}
return { ok: true };
}
/** /**
* Build the SSRF-pinned undici dispatcher. Its custom connect.lookup resolves * Build the SSRF-pinned undici dispatcher. Its custom connect.lookup resolves
* the host, validates EVERY resolved address with the same ssrf-guard, and * the host, validates EVERY resolved address with the same ssrf-guard, and
@@ -388,22 +410,15 @@ function buildPinnedDispatcher(): Agent {
return; return;
} }
const addrs = addresses as LookupAddress[]; const addrs = addresses as LookupAddress[];
if (addrs.length === 0) { const verdict = validateResolvedAddresses(addrs);
callback( if (!verdict.ok) {
new Error(`No address resolved for ${hostname}`),
'',
0,
);
return;
}
const blocked = addrs.find((a) => !isIpAllowed(a.address).ok);
if (blocked) {
// Refuse the connection: net/tls.connect never sees this address. // Refuse the connection: net/tls.connect never sees this address.
callback( // An empty set is treated as blocked (nothing safe to connect to).
new Error(`Blocked address for ${hostname}`), const reason =
'', addrs.length === 0
0, ? `No address resolved for ${hostname}`
); : `Blocked address for ${hostname}`;
callback(new Error(reason), '', 0);
return; return;
} }
// undici/net invoke this lookup with `all: true`, so the callback // undici/net invoke this lookup with `all: true`, so the callback

View File

@@ -0,0 +1,136 @@
import { type Tool } from 'ai';
import { McpClientsService } from './mcp-clients.service';
/**
* Tool-name namespacing / collision tests.
*
* REACHABILITY NOTE: the helpers `namespace` / `sanitizeName` / `capName` /
* `disambiguate` are module-private (not exported) and `mergeNamespaced` is a
* PRIVATE method. The smallest reachable public path that exercises all of them
* is `toolsFor()` -> getOrBuildEntry -> buildEntry -> connect/tools() ->
* mergeNamespaced. We drive that path: stub the repo's `listEnabled` to return
* fake servers and spy on the private `connect` to return fake MCP clients whose
* `tools()` we control. We then inspect the merged tool KEYS on the returned
* toolset — the observable result of namespacing.
*
* What we assert (all SECURITY/correctness-relevant):
* - two servers each exposing a tool `search` -> BOTH survive under distinct
* namespaced keys (no silent overwrite);
* - a tool name with spaces/unicode -> sanitized to ^[a-zA-Z0-9_-]+;
* - an over-long name -> capped to the provider limit (<= 64);
* - duplicate names WITHIN one server (collide after sanitize/truncate) ->
* disambiguated, so the second is not overwritten.
*/
const MAX_TOOL_NAME_LENGTH = 64;
function fakeTool(): Tool {
return { description: 'x', inputSchema: undefined } as unknown as Tool;
}
interface FakeServer {
id: string;
name: string;
transport: string;
url: string;
headersEnc: string | null;
toolAllowlist: string[] | null;
}
function server(over: Partial<FakeServer> & { id: string; name: string }): FakeServer {
return {
transport: 'http',
url: 'https://example.com/mcp',
headersEnc: null,
toolAllowlist: null,
...over,
};
}
/**
* Build a service whose repo returns `servers` and whose `connect` returns a
* fake client exposing `toolsByServerId[server.id]` from tools(). Returns the
* merged keys produced by toolsFor.
*/
async function mergedKeysFor(
servers: FakeServer[],
toolsByServerId: Record<string, Record<string, Tool>>,
): Promise<string[]> {
const repoStub = {
listEnabled: jest.fn().mockResolvedValue(servers),
};
const service = new McpClientsService(repoStub as never, {} as never);
// Map each connect() call (by server identity) to a fake client. connect is
// private; spy on it via a typed any-cast.
jest
.spyOn(service as unknown as { connect: (s: FakeServer) => unknown }, 'connect')
.mockImplementation((s: FakeServer) =>
Promise.resolve({
tools: () => Promise.resolve(toolsByServerId[s.id] ?? {}),
close: () => Promise.resolve(),
}),
);
const toolset = await service.toolsFor('ws-1');
// Release the lease so the service does not hold the fake clients open.
await Promise.all(toolset.clients.map((c) => c.close()));
return Object.keys(toolset.tools);
}
describe('external MCP tool-name namespacing (via toolsFor)', () => {
afterEach(() => jest.restoreAllMocks());
it('keeps tools from two servers that both expose `search` (no overwrite)', async () => {
const keys = await mergedKeysFor(
[
server({ id: 'id-alpha', name: 'alpha' }),
server({ id: 'id-beta', name: 'beta' }),
],
{
'id-alpha': { search: fakeTool() },
'id-beta': { search: fakeTool() },
},
);
// Two distinct keys survive -> no silent overwrite.
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(2);
// The server name is prefixed onto each tool.
expect(keys).toContain('alpha_search');
expect(keys.some((k) => k !== 'alpha_search')).toBe(true);
});
it('sanitizes spaces/unicode in names to the allowed charset', async () => {
const keys = await mergedKeysFor(
[server({ id: 'id-1', name: 'My Server!' })],
{ 'id-1': { 'search the wiki ✨': fakeTool() } },
);
expect(keys).toHaveLength(1);
// Only ^[a-zA-Z0-9_-]+ characters remain (no spaces, no unicode).
expect(keys[0]).toMatch(/^[a-zA-Z0-9_-]+$/);
});
it('caps an over-long name to the provider length limit', async () => {
const longName = 'a'.repeat(200);
const keys = await mergedKeysFor(
[server({ id: 'id-1', name: 'svr' })],
{ 'id-1': { [longName]: fakeTool() } },
);
expect(keys).toHaveLength(1);
expect(keys[0].length).toBeLessThanOrEqual(MAX_TOOL_NAME_LENGTH);
});
it('disambiguates two names that collide after sanitize/truncate within one server', async () => {
// Both names sanitize to the same value ("a_b") -> the second must be
// suffix-disambiguated, not overwritten.
const keys = await mergedKeysFor(
[server({ id: 'id-1', name: 'svr' })],
{ 'id-1': { 'a b': fakeTool(), 'a@b': fakeTool() } },
);
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(2);
});
});

View File

@@ -0,0 +1,85 @@
import { McpServersService } from './mcp-servers.service';
import { AiMcpServer } from '@docmost/db/types/entity.types';
/**
* Encrypted-header leak guard for the admin-facing view (§8.10): `toView` is
* private, so we drive it through the public `list()` (which maps every row
* with toView). The contract: a row with `headersEnc` set surfaces ONLY
* `hasHeaders:true` and NEVER the `headersEnc` blob; a row without headers
* surfaces `hasHeaders:false`. The blob must never reach an admin response.
*/
function row(overrides: Partial<AiMcpServer>): AiMcpServer {
return {
id: 'srv-1',
name: 'Tavily',
transport: 'http',
url: 'https://example.com/mcp',
enabled: true,
toolAllowlist: null,
headersEnc: null,
...overrides,
} as unknown as AiMcpServer;
}
describe('McpServersService.toView (via list) — encrypted-header leak guard', () => {
function buildService(rows: AiMcpServer[]): McpServersService {
const repoStub = {
listByWorkspace: jest.fn().mockResolvedValue(rows),
};
// secretBox + clients are unused by the list/toView path; pass stubs to
// satisfy the constructor.
return new McpServersService(
repoStub as never,
{} as never,
{} as never,
);
}
it('exposes hasHeaders:true and NO headersEnc when auth headers are set', async () => {
const service = buildService([
row({ headersEnc: 'ENCRYPTED-SECRET-BLOB' }),
]);
const [view] = await service.list('ws-1');
expect(view.hasHeaders).toBe(true);
// The encrypted blob must NEVER appear in the view, under any key.
expect('headersEnc' in view).toBe(false);
expect(Object.values(view)).not.toContain('ENCRYPTED-SECRET-BLOB');
});
it('exposes hasHeaders:false when no auth headers are set', async () => {
const service = buildService([row({ headersEnc: null })]);
const [view] = await service.list('ws-1');
expect(view.hasHeaders).toBe(false);
expect('headersEnc' in view).toBe(false);
});
it('projects only the public fields', async () => {
const service = buildService([
row({
id: 'srv-9',
name: 'My MCP',
transport: 'sse',
url: 'https://mcp.example.com/',
enabled: false,
toolAllowlist: ['search'],
headersEnc: 'BLOB',
}),
]);
const [view] = await service.list('ws-1');
expect(view).toEqual({
id: 'srv-9',
name: 'My MCP',
transport: 'sse',
url: 'https://mcp.example.com/',
enabled: false,
toolAllowlist: ['search'],
hasHeaders: true,
});
});
});

View File

@@ -0,0 +1,67 @@
import { type LookupAddress } from 'node:dns';
import { validateResolvedAddresses } from './mcp-clients.service';
/**
* Unit tests for validateResolvedAddresses — the connect-time half of the SSRF
* DNS-rebinding defense. It applies the REAL `isIpAllowed` rule (imported
* transitively via the service) and must block if ANY resolved address is
* disallowed, treat an EMPTY set as blocked, and unwrap IPv4-mapped IPv6.
*
* These tests intentionally use real public/private literals (no DNS, no mock)
* so they exercise the actual ssrf-guard classification.
*/
function addr(address: string, family = 4): LookupAddress {
return { address, family };
}
describe('validateResolvedAddresses', () => {
it('allows an all-public set', () => {
const res = validateResolvedAddresses([
addr('8.8.8.8'),
addr('1.1.1.1'),
addr('2001:4860:4860::8888', 6),
]);
expect(res.ok).toBe(true);
});
it('blocks when ONE address among many is private (any-private-blocks)', () => {
const res = validateResolvedAddresses([
addr('8.8.8.8'),
addr('1.1.1.1'),
addr('10.0.0.5'), // private 10/8 hidden among public addresses
addr('1.0.0.1'),
]);
expect(res.ok).toBe(false);
expect(res.blockedHost).toBe('10.0.0.5');
});
it('blocks an empty set (nothing safe to connect to)', () => {
expect(validateResolvedAddresses([]).ok).toBe(false);
});
it('blocks an IPv4-mapped IPv6 private address', () => {
const res = validateResolvedAddresses([addr('::ffff:10.0.0.1', 6)]);
expect(res.ok).toBe(false);
});
it('blocks the cloud metadata link-local address', () => {
const res = validateResolvedAddresses([
addr('8.8.8.8'),
addr('169.254.169.254'),
]);
expect(res.ok).toBe(false);
});
/**
* Regression sentinel: if the "any private blocks" rule were weakened to
* "all private blocks" / "first address wins", this mixed set (public first,
* private second) would wrongly pass. The assertion below FAILS in that case.
*/
it('FAILS if the any-private rule is weakened (sentinel)', () => {
const res = validateResolvedAddresses([
addr('8.8.8.8'), // public first
addr('192.168.1.1'), // private second — must still block the whole set
]);
expect(res.ok).toBe(false);
});
});

View File

@@ -386,7 +386,7 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
it('falls back to the default for an unparseable / NaN value', () => { it('falls back to the default for an unparseable / NaN value', () => {
process.env[ENV] = 'not-a-number'; process.env[ENV] = 'not-a-number';
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW); expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300); expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(100);
}); });
it('falls back to the default when unset', () => { it('falls back to the default when unset', () => {

View File

@@ -42,7 +42,7 @@ import type { Redis } from 'ioredis';
*/ */
/** Default cap: anonymous share-AI calls allowed per workspace per window. */ /** Default cap: anonymous share-AI calls allowed per workspace per window. */
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 300; export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 100;
/** Default window length: one rolling hour. */ /** Default window length: one rolling hour. */
export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000; export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000;

View File

@@ -0,0 +1,53 @@
import { resolveAudioFormat } from './ai-chat.controller';
/**
* Unit tests for resolveAudioFormat — the transcribe-endpoint mime whitelist.
* It splits the base mime off any MediaRecorder parameters, lowercases/trims it,
* checks it against the whitelist, and maps it to the STT container-format hint.
* A non-whitelisted container yields { ok: false } (the controller then throws
* BadRequestException).
*/
describe('resolveAudioFormat', () => {
it('strips MediaRecorder parameters to the base mime (audio/webm;codecs=opus)', () => {
const res = resolveAudioFormat('audio/webm;codecs=opus');
expect(res).toEqual({ ok: true, baseMime: 'audio/webm', format: 'webm' });
});
it('normalizes uppercase / surrounding whitespace', () => {
const res = resolveAudioFormat(' AUDIO/MP4 ; codecs=mp4a ');
expect(res).toEqual({ ok: true, baseMime: 'audio/mp4', format: 'mp4' });
});
it('handles the Safari/iOS audio/x-m4a container', () => {
expect(resolveAudioFormat('audio/x-m4a')).toEqual({
ok: true,
baseMime: 'audio/x-m4a',
format: 'm4a',
});
});
it('rejects a disallowed container (audio/aiff)', () => {
expect(resolveAudioFormat('audio/aiff')).toEqual({ ok: false });
});
it('maps every whitelisted container to its STT format hint', () => {
const cases: Array<[string, string]> = [
['audio/webm', 'webm'],
['audio/ogg', 'ogg'],
['audio/mp4', 'mp4'],
['audio/mpeg', 'mp3'],
['audio/wav', 'wav'],
['audio/x-wav', 'wav'],
['audio/wave', 'wav'],
['audio/m4a', 'm4a'],
['audio/x-m4a', 'm4a'],
];
for (const [mime, format] of cases) {
expect(resolveAudioFormat(mime)).toEqual({
ok: true,
baseMime: mime,
format,
});
}
});
});

View File

@@ -1,6 +1,19 @@
import { AiChatToolsService } from './ai-chat-tools.service'; import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader'; import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader'; import type { DocmostClientLike } from './docmost-client.loader';
// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so
// importing the TS source directly keeps these mocks honest: the service builds
// the shared tools from exactly the specs the package ships, not a hand-stub.
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
// loadDocmostMcp now resolves to { DocmostClient, sharedToolSpecs }. Every mock
// below must supply sharedToolSpecs or the service throws while building the
// shared tools. Factor the resolved-value shape so the three mock sites stay in
// sync.
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
});
/** /**
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a * Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
@@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
beforeEach(() => { beforeEach(() => {
deletePageCalls.length = 0; deletePageCalls.length = 0;
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake. // Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
DocmostClient: function () { mockLoaded(function () {
return fakeClient as DocmostClientLike; return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor, } as unknown as loader.DocmostClientCtor),
}); );
// The new semanticSearch deps (aiService + repos) are not exercised by the // The new semanticSearch deps (aiService + repos) are not exercised by the
// deletePage guardrail tests; pass stubs to satisfy the constructor arity. // deletePage guardrail tests; pass stubs to satisfy the constructor arity.
service = new AiChatToolsService( service = new AiChatToolsService(
@@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
let service: AiChatToolsService; let service: AiChatToolsService;
beforeEach(() => { beforeEach(() => {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
DocmostClient: function () { mockLoaded(function () {
return fakeClient as DocmostClientLike; return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor, } as unknown as loader.DocmostClientCtor),
}); );
service = new AiChatToolsService( service = new AiChatToolsService(
tokenServiceStub as never, tokenServiceStub as never,
{} as never, {} as never,
@@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0; patchNodeCalls.length = 0;
insertNodeCalls.length = 0; insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0; updatePageJsonCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
DocmostClient: function () { mockLoaded(function () {
return fakeClient as DocmostClientLike; return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor, } as unknown as loader.DocmostClientCtor),
}); );
service = new AiChatToolsService( service = new AiChatToolsService(
tokenServiceStub as never, tokenServiceStub as never,
{} as never, {} as never,

View File

@@ -11,7 +11,10 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
import { import {
loadDocmostMcp, loadDocmostMcp,
type DocmostClientLike, type DocmostClientLike,
type SharedToolSpec,
} from './docmost-client.loader'; } from './docmost-client.loader';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
/** /**
* Per-user, per-request adapter that exposes Docmost READ operations to the * Per-user, per-request adapter that exposes Docmost READ operations to the
@@ -82,13 +85,29 @@ export class AiChatToolsService {
aiChatId, aiChatId,
}); });
const { DocmostClient } = await loadDocmostMcp(); const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp();
const client: DocmostClientLike = new DocmostClient({ const client: DocmostClientLike = new DocmostClient({
apiUrl, apiUrl,
getToken, getToken,
getCollabToken, getCollabToken,
}); });
// Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the
// canonical description + (optional) schema builder, which is invoked with
// THIS layer's zod (v4); only the execute body is supplied per call. No-arg
// specs (no buildShape) get an empty object schema.
const sharedTool = (
spec: SharedToolSpec,
execute: Tool['execute'],
): Tool =>
tool({
description: spec.description,
inputSchema: spec.buildShape
? z.object(spec.buildShape(z) as z.ZodRawShape)
: z.object({}),
execute,
});
return { return {
searchPages: tool({ searchPages: tool({
description: description:
@@ -197,21 +216,8 @@ export class AiChatToolsService {
const accessibleSet = new Set(accessibleIds); const accessibleSet = new Set(accessibleIds);
// Keep the best (first — hits are ordered by fused score desc) chunk // Keep the best (first — hits are ordered by fused score desc) chunk
// per page, capped to `cap`. // per page, dropping any page the user cannot access, capped to `cap`.
const seen = new Set<string>(); return selectAccessibleHits(hits, accessibleSet, cap);
const results: { id: string; title: string; snippet: string }[] = [];
for (const hit of hits) {
if (!accessibleSet.has(hit.pageId)) continue;
if (seen.has(hit.pageId)) continue;
seen.add(hit.pageId);
results.push({
id: hit.pageId,
title: hit.title ?? '',
snippet: snippet(hit.content),
});
if (results.length >= cap) break;
}
return results;
}, },
}), }),
@@ -222,14 +228,7 @@ export class AiChatToolsService {
'or null if the user is not currently on a page. Call this first whenever ' + 'or null if the user is not currently on a page. Call this first whenever ' +
'the user refers to the current page without giving an explicit id.', 'the user refers to the current page without giving an explicit id.',
inputSchema: z.object({}), inputSchema: z.object({}),
execute: async () => { execute: async () => resolveCurrentPageResult(openedPage),
if (!openedPage?.id) {
return { page: null };
}
return {
page: { id: openedPage.id, title: openedPage.title ?? '' },
};
},
}), }),
getPage: tool({ getPage: tool({
@@ -371,12 +370,29 @@ export class AiChatToolsService {
createComment: tool({ createComment: tool({
description: description:
'Add a comment to a page, or reply to an existing top-level comment ' + 'Add an INLINE comment to a page, or reply to an existing top-level ' +
'(one level only — the backend rejects replies to replies). ' + 'comment (one level only — the backend rejects replies to replies). ' +
'Reversible via the comment UI.', 'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' +
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. Reversible via the ' +
'comment UI.',
inputSchema: z.object({ inputSchema: z.object({
pageId: z.string().describe('The id of the page to comment on.'), pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'), content: z.string().describe('The comment body as Markdown.'),
selection: z
.string()
.min(1)
.max(250)
.optional()
.describe(
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
'formatting boundaries). Required for a new top-level comment; ' +
'omit only when replying via parentCommentId.',
),
parentCommentId: z parentCommentId: z
.string() .string()
.optional() .optional()
@@ -385,14 +401,22 @@ export class AiChatToolsService {
'of replies only).', 'of replies only).',
), ),
}), }),
execute: async ({ pageId, content, parentCommentId }) => { execute: async ({ pageId, content, selection, parentCommentId }) => {
// createComment(pageId, content, type, selection?, parentCommentId?). // createComment(pageId, content, type, selection?, parentCommentId?).
// Page-type comment (no inline selection); replies inherit the anchor. // Top-level comments are inline and must carry a selection to anchor
// on; replies inherit the parent's anchor (no selection). Throwing
// here surfaces a tool error to the model (Vercel `ai` SDK) so the
// agent retries with a better selection — do not catch/suppress it.
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
);
}
const result = await client.createComment( const result = await client.createComment(
pageId, pageId,
content, content,
'page', 'inline',
undefined, selection,
parentCommentId, parentCommentId,
); );
const data = (result?.data ?? {}) as { id?: string }; const data = (result?.data ?? {}) as { id?: string };
@@ -421,20 +445,15 @@ export class AiChatToolsService {
// --- READ tools (added) --- // --- READ tools (added) ---
getWorkspace: tool({ getWorkspace: sharedTool(
description: sharedToolSpecs.getWorkspace,
'Fetch metadata about the current workspace (name, settings).', async () => await client.getWorkspace(),
inputSchema: z.object({}), ),
execute: async () => await client.getWorkspace(),
}),
listSpaces: tool({ listSpaces: sharedTool(
description: sharedToolSpecs.listSpaces,
'List the spaces the current user can access. Returns the array ' + async () => await client.getSpaces(),
'of spaces (id, name, slug, ...).', ),
inputSchema: z.object({}),
execute: async () => await client.getSpaces(),
}),
listPages: tool({ listPages: tool({
description: description:
@@ -482,43 +501,20 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId), await client.listSidebarPages(spaceId, pageId),
}), }),
getOutline: tool({ getOutline: sharedTool(
description: sharedToolSpecs.getOutline,
"Compact outline of a page's top-level blocks, with block ids. Use " + async ({ pageId }) => await client.getOutline(pageId),
'it to locate sections/tables and grab block ids before drilling in ' + ),
'with getNode / patchNode / insertNode.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.getOutline(pageId),
}),
getPageJson: tool({ getPageJson: sharedTool(
description: sharedToolSpecs.getPageJson,
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' + async ({ pageId }) => await client.getPageJson(pageId),
'marks). Use this when you need exact structure for node-level edits.', ),
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.getPageJson(pageId),
}),
getNode: tool({ getNode: sharedTool(
description: sharedToolSpecs.getNode,
"Fetch a single block's full ProseMirror subtree (lossless) by " + async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
'reference.', ),
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
.describe(
'A block id from getOutline, or "#<index>" to select a ' +
'top-level block by its outline index (e.g. a table).',
),
}),
execute: async ({ pageId, nodeId }) =>
await client.getNode(pageId, nodeId),
}),
getTable: tool({ getTable: tool({
description: description:
@@ -575,27 +571,16 @@ export class AiChatToolsService {
await client.checkNewComments(spaceId, since, parentPageId), await client.checkNewComments(spaceId, since, parentPageId),
}), }),
listShares: tool({ listShares: sharedTool(
description: sharedToolSpecs.listShares,
'List all public shares in the workspace, each with its public URL.', async () => await client.listShares(),
inputSchema: z.object({}), ),
execute: async () => await client.listShares(),
}),
listPageHistory: tool({ listPageHistory: sharedTool(
description: sharedToolSpecs.listPageHistory,
'List the saved versions (history snapshots) of a page, newest ' + async ({ pageId, cursor }) =>
'first. Returns one cursor-paginated page of results.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
cursor: z
.string()
.optional()
.describe('Optional pagination cursor from a previous call.'),
}),
execute: async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor), await client.listPageHistory(pageId, cursor),
}), ),
getPageHistory: tool({ getPageHistory: tool({
description: description:
@@ -608,24 +593,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId), await client.getPageHistory(historyId),
}), }),
diffPageVersions: tool({ diffPageVersions: sharedTool(
description: sharedToolSpecs.diffPageVersions,
'Diff two versions of a page and return the change set. from/to ' + async ({ pageId, from, to }) =>
"each accept a historyId or 'current' (or omit for current).",
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
from: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
to: z
.string()
.optional()
.describe("A historyId, or 'current'/omit for current content."),
}),
execute: async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to), await client.diffPageVersions(pageId, from, to),
}), ),
exportPageMarkdown: tool({ exportPageMarkdown: tool({
description: description:
@@ -643,46 +615,10 @@ export class AiChatToolsService {
// --- WRITE tools (added; reversible via page history/trash) --- // --- WRITE tools (added; reversible via page history/trash) ---
editPageText: tool({ editPageText: sharedTool(
description: sharedToolSpecs.editPageText,
'Surgical find/replace inside a page\'s text, preserving all block ' + async ({ pageId, edits }) => await client.editPageText(pageId, edits),
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' + ),
'replacement inherits marks from the unchanged common prefix/suffix ' +
'(so editing plain text next to a bold word keeps it bold, and ' +
'editing inside a bold word keeps the new text bold). Each find must ' +
'match exactly once unless replaceAll is set. The batch applies what ' +
'it can and returns applied[] + failed[] plus a verify change-report ' +
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
'your edit landed; do not assume success); a fully-unmatched batch ' +
'writes nothing and errors. find and replace are LITERAL text, not ' +
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
'formatting marks: a formatting change — find/replace that differ only ' +
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
'failed[]. To change bold/italic/strike/code/link, read the block with ' +
'getPageJson and use patchNode (or updatePageJson) to set its marks. ' +
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to edit.'),
edits: z
.array(
z.object({
find: z.string().describe('Exact text to find.'),
replace: z.string().describe('Replacement text.'),
replaceAll: z
.boolean()
.optional()
.describe('Replace every occurrence (default: one match).'),
}),
)
.min(1)
.describe('One or more find/replace edits.'),
}),
execute: async ({ pageId, edits }) =>
await client.editPageText(pageId, edits),
}),
patchNode: tool({ patchNode: tool({
description: description:
@@ -711,14 +647,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts patch_node): the // Parity with the standalone MCP server (index.ts patch_node): the
// model sometimes serializes the node as a JSON string. Parse it // model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it. // before the client's typeof-object guard rejects it.
let parsedNode = node; const parsedNode = parseNodeArg(node);
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
return await client.patchNode(pageId, nodeId, parsedNode); return await client.patchNode(pageId, nodeId, parsedNode);
}, },
}), }),
@@ -770,14 +699,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts insert_node): the // Parity with the standalone MCP server (index.ts insert_node): the
// model sometimes serializes the node as a JSON string. Parse it // model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it. // before the client's typeof-object guard rejects it.
let parsedNode = node; const parsedNode = parseNodeArg(node);
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
return await client.insertNode(pageId, parsedNode, { return await client.insertNode(pageId, parsedNode, {
position, position,
anchorNodeId, anchorNodeId,
@@ -786,17 +708,10 @@ export class AiChatToolsService {
}, },
}), }),
deleteNode: tool({ deleteNode: sharedTool(
description: sharedToolSpecs.deleteNode,
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' + async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
'previous version is kept in page history.', ),
inputSchema: z.object({
pageId: z.string().describe('The id of the page.'),
nodeId: z.string().describe('The block id to remove.'),
}),
execute: async ({ pageId, nodeId }) =>
await client.deleteNode(pageId, nodeId),
}),
updatePageJson: tool({ updatePageJson: tool({
description: description:
@@ -826,14 +741,9 @@ export class AiChatToolsService {
let doc; let doc;
if (content === undefined || content === null) { if (content === undefined || content === null) {
doc = undefined; doc = undefined;
} else if (typeof content === 'string') {
try {
doc = JSON.parse(content);
} catch {
throw new Error('content was a string but not valid JSON');
}
} else { } else {
doc = content; // String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, 'content was a string but not valid JSON');
} }
return await client.updatePageJson(pageId, doc, title); return await client.updatePageJson(pageId, doc, title);
}, },
@@ -890,35 +800,17 @@ export class AiChatToolsService {
await client.tableUpdateCell(pageId, tableRef, row, col, text), await client.tableUpdateCell(pageId, tableRef, row, col, text),
}), }),
copyPageContent: tool({ copyPageContent: sharedTool(
description: sharedToolSpecs.copyPageContent,
"Replace the target page's BODY with the source page's body " + async ({ sourcePageId, targetPageId }) =>
'(title/slug are kept). Runs server-side — no document passes ' +
'through the model. Reversible: the target keeps page history.',
inputSchema: z.object({
sourcePageId: z.string().describe('The id of the source page.'),
targetPageId: z
.string()
.describe('The id of the target page to overwrite.'),
}),
execute: async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId), await client.copyPageContent(sourcePageId, targetPageId),
}), ),
importPageMarkdown: tool({ importPageMarkdown: sharedTool(
description: sharedToolSpecs.importPageMarkdown,
"Replace a page's body from Docmost-flavoured Markdown (as produced " + async ({ pageId, markdown }) =>
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
'page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to overwrite.'),
markdown: z
.string()
.describe('Docmost-flavoured Markdown for the page body.'),
}),
execute: async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown), await client.importPageMarkdown(pageId, markdown),
}), ),
sharePage: tool({ sharePage: tool({
description: description:
@@ -936,27 +828,15 @@ export class AiChatToolsService {
await client.sharePage(pageId, searchIndexing), await client.sharePage(pageId, searchIndexing),
}), }),
unsharePage: tool({ unsharePage: sharedTool(
description: sharedToolSpecs.unsharePage,
'Remove the public share of a page (reverses sharePage).', async ({ pageId }) => await client.unsharePage(pageId),
inputSchema: z.object({ ),
pageId: z.string().describe('The id of the page to unshare.'),
}),
execute: async ({ pageId }) => await client.unsharePage(pageId),
}),
restorePageVersion: tool({ restorePageVersion: sharedTool(
description: sharedToolSpecs.restorePageVersion,
'Restore a past version by writing its content back as the current ' + async ({ historyId }) => await client.restorePageVersion(historyId),
'page content. Itself reversible: it creates a new history snapshot.', ),
inputSchema: z.object({
historyId: z
.string()
.describe('The id of the history version to restore.'),
}),
execute: async ({ historyId }) =>
await client.restorePageVersion(historyId),
}),
transformPage: tool({ transformPage: tool({
description: description:
@@ -984,6 +864,44 @@ export class AiChatToolsService {
} }
} }
/** A single hybrid-search hit: the minimal shape selectAccessibleHits needs. */
export interface SearchHitLike {
pageId: string;
title: string | null;
content: string;
}
/**
* Post-filter hybrid-search hits into the agent-facing result list. This is the
* CASL leak guard for the in-process hybrid search: the hits come from a direct
* pgvector + full-text query that does NOT get CASL for free, so an accessible
* SPACE does not imply every page in it is accessible (restricted pages).
*
* Given `hits` (ordered by fused score desc), the `accessibleSet` of page ids
* the user may read, and `cap`, it keeps the BEST (first) chunk per page, drops
* any page not in `accessibleSet`, and caps the output at `cap`. Pure — no I/O.
*/
export function selectAccessibleHits(
hits: readonly SearchHitLike[],
accessibleSet: Set<string>,
cap: number,
): { id: string; title: string; snippet: string }[] {
const seen = new Set<string>();
const results: { id: string; title: string; snippet: string }[] = [];
for (const hit of hits) {
if (!accessibleSet.has(hit.pageId)) continue;
if (seen.has(hit.pageId)) continue;
seen.add(hit.pageId);
results.push({
id: hit.pageId,
title: hit.title ?? '',
snippet: snippet(hit.content),
});
if (results.length >= cap) break;
}
return results;
}
/** /**
* Trim a search highlight/snippet to a token-efficient length. The highlight * Trim a search highlight/snippet to a token-efficient length. The highlight
* may contain `<b>` markers from the search backend; they are harmless to the * may contain `<b>` markers from the search backend; they are harmless to the

View File

@@ -0,0 +1,43 @@
import { resolveCurrentPageResult } from './current-page.util';
/**
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
* otherwise { page: { id, title } } with title defaulting to ''.
*/
describe('resolveCurrentPageResult', () => {
it('returns { page: null } when openedPage is undefined', () => {
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
});
it('returns { page: null } when openedPage is null', () => {
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
});
it('returns { page: null } when openedPage has no id', () => {
expect(resolveCurrentPageResult({})).toEqual({ page: null });
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
});
it('returns { page: null } when id is an empty string', () => {
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
});
it('returns the page id and title when both are present', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
page: { id: 'p1', title: 'Hello' },
});
});
it('defaults title to "" when it is missing', () => {
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
page: { id: 'p1', title: '' },
});
});
it('keeps an explicit empty-string title as ""', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
page: { id: 'p1', title: '' },
});
});
});

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