Compare commits

...

77 Commits

Author SHA1 Message Date
claude code agent 227
21a9c4d89d fix(ai-chat): tick the live token counter between agent steps (#163)
The header token badge (and the "Thinking… · N tokens" line) froze between
agent steps and jumped in chunks instead of ticking smoothly. liveTurnTokens
returned the authoritative server `usage` VERBATIM as soon as it appeared, but
the server only attaches usage at a step boundary and it is cumulative over
COMPLETED steps — so during the next (in-flight) step the figure stayed frozen
at the previous boundary and the running text estimate was ignored.

Combine both sources per component via max: always compute the running estimate
(chars/≈4 over the message's reasoning/text parts, which includes the in-flight
step) and take max(authoritativeBase, estimate). Between boundaries the estimate
ticks the number up; at a boundary the authoritative figure snaps it exact; and
because the server usage is cumulative and we only ever take the max, the counter
is monotonic (never drops). Reasoning/output stay split; the #151 reasoning-only
authoritative count is preserved.

Backward compatible: in every existing test the estimate is <= the authoritative
figure, so max returns the same value. +4 tests for the in-flight-step-exceeds-
base case (output + reasoning), the authoritative-wins case, and monotonicity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:12:21 +03:00
claude_code
6566d2153c Merge pull request 'fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)' (#154) from fix/mcp-comment-cursor-jump into develop 2026-06-24 14:49:46 +03:00
claude code agent 227
aca075108c refactor(mcp): accurate encode-failure labels + diff edge-case tests (#154 review)
Addresses the approve-with-comments review on PR #154:

- applyDocToFragment: hydrate PMNode.fromJSON in its OWN try so a hydration
  failure (e.g. an unknown node type) is labelled "fromJSON" — the stage that
  actually threw — instead of the misleading "updateYFragment". The diagnostic
  comment on unstorableYjsError ("label names the stage that failed") is now
  truthful.
- assertYjsEncodable: also rehearse PMNode.fromJSON(docmostSchema, …) so a doc
  that would only fail in apply's hydration step is rejected at preview time too,
  narrowing the preview/apply gap (review suggestion B). Still cheap — no live
  fragment, no updateYFragment.
- Tests: relabel the diagnostic test to (fromJSON); add structural-diff edge
  cases — neighbour deletion keeps the unchanged node's cursor anchor, doc->empty
  clears the fragment without throwing, top-level node-type change diffs in
  place — plus a preview-gate test for the new fromJSON rehearsal. 297/297 green.

build/ rebuilt for the changed lib module only (build/client.js left untouched
to avoid pulling in pre-existing unrelated src/build drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:48:59 +03:00
claude_code
c9b012894b Merge pull request 'feat(editor): recursive tree mode for the subpages node (#150)' (#155) from feat/subpages-recursive-tree into develop 2026-06-24 14:35:17 +03:00
claude code agent 227
623c89554a refactor(subpages): address PR #155 review
- Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into
  subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts)
  covering nesting, position order, missing/unreachable parent, self-parent
  guard, empty input, countNodes and mapSharedNodes remap.
- Replace the manual useState + editor.on("transaction") subscription in
  subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus
  use), so the mode icon/tooltip track the live recursive attribute without
  re-rendering on every keystroke.
- i18n: add the 6 menu/tree strings and a pluralized
  "Showing {{count}} subpages" key to en-US and ru-RU.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:33:25 +03:00
claude code agent 227
f7b99f9fb3 feat(editor): recursive tree mode for the subpages node (#150)
The `subpages` node showed only one level of direct children. Add a `recursive`
attribute that renders the FULL descendant tree of the current page — fully
expanded, unlimited depth. Default `false`, so every previously-inserted node
stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the
`getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]`
(recursive CTE, permission-filtered); the nested tree is built on the client by
`parentPageId`.

- editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`),
  shared by client + server so the collab ProseMirror schema keeps the attribute.
- `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts
  either); new `useGetPageTreeQuery(pageId)` react-query hook.
- `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and
  `RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent
  guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode`
  renders it (16px indent per depth, soft "showing N" note past 300 — data never
  capped). Shared/public context reads the already-nested shared tree, no
  `/pages/tree` request.
- toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree".

Review follow-ups folded in: invalidate `["page-tree"]` from the create / update /
move / delete cache helpers so an open recursive tree refreshes (no stale data);
mode icon made reactive on editor transactions; `t` threaded into `TreeNode`
(no per-node useTranslation); shared-subtree hook deduped to a thin alias.

editor-ext build + client `tsc --noEmit` both clean. Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:33:07 +03:00
e97024343a Merge pull request 'feat(editor): float image with text wrap (#145)' (#157) from feat/float-image into develop
Reviewed-on: #157
2026-06-24 14:04:03 +03:00
claude code agent 227
43cf1913e0 style(editor): scope the float responsive :global to .container (#145 review)
Per review: the file's other :global is locally scoped (.container:global(...)),
but the new float-reset media rule was fully global in a *.module.css. Scope it to
.container — the image node-view container carries BOTH the .container class and
the data-image-align attribute (same element), so behavior is unchanged while the
selector no longer leaks globally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:08:44 +03:00
9225eeeeed Merge pull request 'feat(ai-chat): realtime token counter + reasoning tokens (#151)' (#158) from feat/ai-chat-realtime-tokens into develop
Reviewed-on: #158
2026-06-24 13:07:51 +03:00
claude code agent 227
044e3f7e6a fix(ai-chat): plural token strings + cover reasoning UI + cleanups (#151 review)
Review of #158 (Request changes) — core logic verified correct; addressed the
test-coverage + localization items:

1. i18n pluralization: the token-count keys were called with {count} but had one
   form, so ru-RU always rendered the genitive ("1 токенов"). Added _one/_other
   (en) and _one/_few/_many (ru: токен/токена/токенов) for both "Thinking… ·
   {{count}} tokens" and "Thinking · {{count}} tokens"; de-duped the PR-added
   duplicate "Thinking" key. Call sites unchanged.
2. ReasoningBlock: new reasoning-block.test.tsx (4 branches: authoritative count
   wins / estimate fallback / header-only when count-but-no-text / body render).
3. Reasoning-token attribution: extracted the #151 anti-double-count rule into a
   pure `reasoningTokensForPart(message)` (single reasoning part -> authoritative
   turn total; multiple/none -> undefined so each estimates). message-item uses
   it; removed the now-dead lastReasoningIndex reduce (review #5). Unit-tested.
6. adopt-chat-id.ts: refreshed 3 stale `chatStreamStartMetadata` ->
   `chatStreamMetadata` comment references.
7. chat-markdown.test.ts: assert the export footer's `reasoning: N` line appears
   when reasoningTokens>0 and is absent at 0/undefined.

Skipped optional #4 (mantine useThrottledCallback): the manual throttle has two
distinct exit paths (turn-end revert-to-null + the captured-total trailing emit)
with no guarding test; remapping risks the streaming behavior — non-blocking.

Client tsc clean; ai-chat suite green (171 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:05:07 +03:00
claude code agent 227
c7c0c28e38 refactor(mcp): single docmostSchema + shared encode-error helper + catch test (#152 review)
Review of #154 (Request changes) — all clean follow-ups, no defect in the fix:

1. Single source of the ProseMirror schema: export `docmostSchema` from
   docmost-schema.ts (next to docmostExtensions); diff.ts and collaboration.ts
   import it instead of each calling getSchema(docmostExtensions) — the schema
   can no longer drift between call sites. Removed both local builds + the now
   unused getSchema imports.
2. Doc fix: assertYjsEncodable's docstring and the client.ts comment no longer
   claim "the same encoder as apply" — apply uses updateYFragment, the dry-run
   uses toYdoc; both reject the same unstorable attrs but are NOT byte-identical.
   Reworded to "independent encodability gate".
3+4+5. Extracted `unstorableYjsError(safe, label, e)` — buildYDoc and
   applyDocToFragment now share one message template (label kept for diagnostics:
   toYdoc vs updateYFragment), so the wording can't drift between dry-run/apply.
6. Test for applyDocToFragment's catch branch: an unknown node type makes the
   schema-validated PMNode.fromJSON throw, and the function must re-throw it
   wrapped with the (updateYFragment) diagnostic.

build/ rebuilt for the three changed lib modules; 293 package tests green.
(Left build/client.js untouched: rebuilding it would pull in a pre-existing,
unrelated src/build drift — a listSidebarPages slugId fix never rebuilt on
develop — and my client.ts change there is comment-only.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:56:23 +03:00
claude code agent 227
99359fa0fa fix(editor): load the float responsive rule + test applyAlignment (#145 review)
Review of #157 (Request changes) caught two blockers:

1. DEAD responsive CSS: the `@media (max-width:600px)` float-reset was added to
   `image-resize.module.css`, which is imported NOWHERE — the image container's
   classes come from `common/node-resize.module.css` (via buildResizeClasses).
   So on mobile a floated image kept its px width + float and crushed the text,
   exactly the failure the rule promised to prevent. Moved the rule to
   `common/node-resize.module.css` (the module actually imported by the resize
   node views); its `:global([data-image-align=...])` selectors are data-attr
   based, so they work unchanged. Reverted the dead addition from the (pre-existing,
   orphaned) image-resize.module.css.

2. `applyAlignment` was untested. Exported it and added `image.spec.ts` (vitest/
   jsdom) covering all five align values, the data-image-align mirror, and the
   floatLeft -> left reset-then-apply (the guard against a leaked float).
   Switched the float writes to the canonical CSSOM `cssFloat` property (portable:
   browsers + jsdom; behavior identical to the `.float` alias).

editor-ext build + client tsc clean; 6 image.spec tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:49:33 +03:00
claude_code
7325eeac19 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-24 12:47:36 +03:00
b321bbafc4 Merge pull request 'feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149)' (#156) from feat/ai-role-autostart into develop
Reviewed-on: #156
2026-06-24 12:43:42 +03:00
claude code agent 227
5519f4b23b test(ai-chat): cover role-pick autoStart logic + the rolePickedNoSend reset (#149 review)
Review of #156 (Request changes) flagged the new CLIENT logic as untested. Extract
the decision logic from chat-thread.tsx into pure, unit-testable helpers and cover
both branches the reviewer called out:

- `roleLaunchMessage(role, default)` — the three-way handleRolePick behavior:
  autoStart=false -> null (send nothing); autoStart=true + custom -> trimmed
  message; autoStart=true + empty/null/whitespace -> default fallback.
- `shouldResetRolePicked(chatId, roleId, flag)` — the #149 render-phase reset; the
  regression test asserts the stuck-flag case (New chat after an autoStart=false
  pick -> cards return) that the pre-fix code never handled, and that a still-bound
  role keeps the cards hidden.

chat-thread.tsx now calls these helpers (behavior unchanged). 9 new pure tests.

Also folded the review's cosmetic suggestion: `x ? x : null` -> `x || null` in
ai-agent-roles.repo.ts (identical for string|null|undefined).

Client tsc clean; role-launch + role-cards green; repo spec green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:42:22 +03:00
claude code agent 227
0ebb1adce8 feat(ai-chat): realtime token counter + reasoning tokens, Claude-Code style (#151)
Tokens were only counted post-hoc (onFinish) and the header badge updated only on
chat open/switch; reasoning wasn't requested or shown. Now a counter ticks LIVE
during generation and surfaces reasoning ("thinking") tokens separately, like
Claude Code's `Thinking… · N tokens`.

Architecture (AI SDK v6): no provider gives exact per-token usage mid-stream, so
the live number is a cheap client estimate (chars/≈4) reconciled to AUTHORITATIVE
provider usage at step boundaries and turn end. The useChat per-delta re-render is
the existing realtime engine.

- server: `chatStreamMetadata` now also forwards usage on `finish-step` + `finish`;
  `sendReasoning: true`; persisted `metadata.usage` carries `reasoningTokens`
  (normalized from `outputTokenDetails` or the deprecated field).
- client: pure `count-stream-tokens` (estimateTokens / liveTurnTokens, prefers
  authoritative usage else estimate); `Thinking… · N tokens` in the typing
  indicator; collapsible "Thinking" reasoning block; throttled (~8 Hz) live
  turn-token header badge; `reasoningTokens` in types + Markdown export.

Review fixes folded in:
- v6 `finish-step.usage` is PER-STEP, not cumulative — the server now ACCUMULATES
  a running sum (new pure `accumulateStepUsage`) and sends the cumulative, which
  converges to `finish.totalUsage`, so the live counter never jumps DOWN on a
  multi-step agent turn.
- reasoning double-count: the authoritative turn-total is attributed to a block
  ONLY for a single-reasoning-part (one-step) turn; multi-step blocks each show
  their own estimate (the authoritative total stays in the header).
- no "0" badge flash at turn start (require live > 0, else show context size).
- comment refreshed (finish-step trigger).

Tests: server `accumulateStepUsage` + updated `chatStreamMetadata` (34 in the
suite); client pure-fn tests. Both tsc clean; 162 client ai-chat + the ai-chat
server suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:56:14 +03:00
claude code agent 227
8ef66ba712 feat(editor): float image with text wrap (#145, port from Forkmost)
Adds floatLeft / floatRight image alignment so text wraps beside the image,
beyond the existing block left/center/right. Ported from Forkmost PR #7 /
upstream Docmost PR #1132 (fuscodev), adapted to gitmost's imperative image
node-view (the upstream uses a React styled component; ours styles the node-view
container directly via applyAlignment).

- editor-ext image.ts: `setImageAlign` accepts `floatLeft`/`floatRight`;
  `applyAlignment` resets float/padding then, for a float mode, sets
  `float:left|right` + side padding on the (shrink-to-fit) container so text
  flows beside it (the inner <img> already has max-width:100%). The resolved
  align is mirrored onto the container as `data-image-align` for the responsive
  rule. `data-align` already round-trips the value through parse/renderHTML, so
  float survives serialization / collab / history with no schema change.
- image-menu.tsx: Float-left / Float-right bubble-menu buttons (IconFloatLeft/
  Right) with active state.
- image-resize.module.css: on narrow screens (<=600px) a floated image collapses
  to full width and drops the float (`!important`, keyed on data-image-align) —
  the upstream "100% width on small screen" follow-up.
- i18n: en-US + ru-RU strings.

editor-ext build + client tsc --noEmit clean. Visual wrap behavior is best
confirmed in-browser (logic/serialization verified by build + types).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:35:34 +03:00
claude code agent 227
0ec0af405a feat(ai-chat): per-role autoStart toggle + custom launchMessage (#149)
Agent role cards always auto-sent a hardcoded "Take a look at the current
document" on pick. Make it configurable per role:
- autoStart (bool, default true): whether picking the role auto-sends a message.
- launchMessage (nullable text): the text sent on auto-start; empty -> the
  built-in default. autoStart=false -> bind the role and send nothing (the user
  types the first message, which still carries the roleId).
Existing roles default to autoStart=true / launchMessage=null => identical old
behavior.

Full-stack:
- migration 20260624T120000 adds `auto_start boolean NOT NULL DEFAULT true` +
  `launch_message text` (additive; down drops both); db.d.ts updated by hand.
- DTO: autoStart (@IsBoolean) + launchMessage (trim @Transform, @MaxLength 2000).
- repo/service: thread + normalize (undefined=unchanged, ""=>null, autoStart??true).
  Both fields exposed in the picker-view for ordinary members (they decide
  whether/what to auto-send); instructions/modelConfig stay ADMIN-ONLY.
- client: IAiRole types, role form (Switch + Textarea, re-hydrated on edit),
  handleRolePick branches on autoStart; i18n en-US + ru-RU.

Review follow-ups folded in: reset the `rolePickedNoSend` flag when the thread
returns to an empty role-less state (the "New chat after autoStart=false pick"
stuck-UI bug — render-phase one-shot reset); made create/update launchMessage
normalization symmetric (raw value, server normalizes ""→null).

Server: 68 role tests pass, tsc clean. Client: tsc clean, role tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:28:41 +03:00
claude code agent 227
f86b8b69a0 fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)
mutatePageContent wrote agent edits back by DELETING the whole Yjs fragment and
re-applying a fresh Y.Doc. Yjs is a CRDT — the editor anchors its selection to
node ids — so wiping every id made an open editor's cursor lose its anchor and
snap to the end of the document on every agent write. It was most visible on
comment anchoring (issue #152): a comment changes no text, yet the cursor jumped.
(Before commit 4201f0a3 the anchoring silently no-op'd, so the destructive write
never ran for comments — hence the regression.)

Fix: write via `updateYFragment` (y-prosemirror) — the same routine the editor
uses to sync its own edits into Yjs. It structurally diffs the new doc against
the live fragment and touches only changed nodes, preserving the ids of unchanged
ones, so the cursor stays put. This improves ALL agent write tools (text edits,
node ops, comments, replace) — minimal diff instead of full replace: less collab
noise, stable block-ids, other users' cursors no longer disrupted.

- collaboration.ts: new `applyDocToFragment` (sanitize -> PMNode.fromJSON against
  a memoized docmost schema -> updateYFragment in one transact), keeping the
  `findUnstorableAttr` encode diagnostic; swap the destructive write-back for it.
- package.json: `y-prosemirror` promoted to a direct dependency (was transitive).
- test: comment-cursor-stability.test.mjs — a Yjs RelativePosition (the cursor
  anchor) survives both a sibling edit and a comment-mark anchoring (the old
  full-replace tombstoned it -> null). 292 package tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:56:32 +03:00
claude_code
3662d21c99 docs(agents): add Gitea tea CLI usage for creating issues
Add a new “Creating issues (Gitea `tea` CLI)” section to AGENTS.md that documents how to file issues using the `tea` command‑line tool, including the correct flag for the issue body and a gotcha note about the `--description` flag.
2026-06-24 05:15:52 +03:00
claude_code
acf6d85b07 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-24 04:41:07 +03:00
8a6ee78c44 Merge pull request 'fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)' (#147) from fix/146-nodeview-content-order into develop
Reviewed-on: #147
2026-06-24 04:40:59 +03:00
claude_code
554da63b0e fix(ai-chat): keep typing-indicator name gap stable mid-stream
The standalone "Thinking…" indicator's agent-name label switched owners a couple seconds into a turn: at first TypingIndicator rendered name+dots (4px gap), then once useChat materialized the empty/reasoning-only assistant message the name moved to the empty MessageItem row (16px messageRow margin), pushing the dots ~20px away — a visible layout jump.

Introduce a shared pure helper assistantMessageHasVisibleContent() as the single source of truth mirroring MessageItem's render decisions. MessageItem now returns null for an assistant message with no visible content, and typingIndicatorShowsName keeps the name on the indicator until the assistant row has visible content. Exactly one element owns the name throughout the pre-content gap, so the layout no longer reflows.

- new utils/message-content.ts + unit tests (12 cases)
- message-list.tsx: typingIndicatorShowsName uses the helper
- message-item.tsx: early return null when no visible content
- typing-indicator-shows-name.test.ts: updated expectations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:36:43 +03:00
claude_code
8bb441870a test(editor): address PR #147 review — reflow tests, code-block guard, a11y
Resolve the pre-merge review items for the #146 NodeView content-first fix:

- Export collectScrollAncestors/reflowAfterPaste and add editor-paste-handler
  unit tests covering ancestor selection (overlay included, non-overflowing
  auto excluded, X axis), the scrollHeight>clientHeight gate, scrollingElement
  dedup, the docEl==null branch, and the double-rAF nudge.
- Extend the structural guard with CodeBlockView and merge the two it.each
  blocks into one document-order assertion (handles the <pre> nesting where the
  contentDOM is not the literal first child).
- Simplify the post-paste nudge to a single scrollTo(scrollLeft, scrollTop).
- Document that the post-paste reflow runs on every paste path intentionally,
  and cross-reference the two #146 mitigations in both fixes.
- a11y: aria-hidden the decorative footnotes heading and number marker, and
  label the footnotes list via role="group" + aria-label so the visual reorder
  does not break screen-reader reading order (WCAG 1.3.2).
- CHANGELOG: add a Fixed entry noting the caret fix is macOS-verified manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:36:39 +03:00
f11c8d7bf1 Merge pull request 'feat(comments): attribute MCP agent comments as AI (unspoofable provenance)' (#143) from feat/mcp-comments-ai-attribution into develop
Reviewed-on: #143
2026-06-24 02:05:06 +03:00
claude_code
cbaa120037 chore(backlog): remove completed mcp-comments-ai-attribution plan (#143)
Per AGENTS.md §5, a task's backlog plan is deleted once implemented; this PR
ships that design, so the plan leaves the work queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:04:23 +03:00
claude_code
683b9d5de2 fix(provenance): address #143 review — page-stamp tests, confine is_agent, doc fixes
Resolves the open items from the latest PR #143 code review:

- test(page): cover the four agentSourceFields stamp sites (create, update,
  movePage, movePageToSpace) with agent + normal-user payload assertions;
  add findById({ includeIsAgent: true }) wiring guards to the JWT and collab
  auth-seam specs so a future drop of the option is caught.
- fix(privacy): drop `isAgent` from UserRepo.baseFields and gate it behind a
  new opt-in `findById({ includeIsAgent })`, requested only by the two auth
  seams that derive provenance — stops the flag leaking via the workspace
  member list and generic user payloads.
- docs: correct the agentSourceFields JSDoc and the two UPDATE-site comments
  to distinguish INSERT (omitted column → DB default 'user') from UPDATE
  (omitted column → existing value kept, Kysely writes only present keys).
- style(page): collapse three stray double blank lines left by an earlier edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:04:23 +03:00
claude code agent 227
67057de214 test(editor): guard #146 contentDOM-first invariant + fix code-block comment (PR #147 review)
Addresses the #147 review (Approve with comments):
- Add footnote-views.structure.test.tsx: a structural regression guard asserting
  the editable NodeViewContent is the FIRST child of FootnotesListView and
  FootnoteDefinitionView, with no contenteditable=false chrome before it. The
  whole #146 fix rests on this DOM-order invariant; the macOS caret symptom needs
  a real browser, but the order proxy is testable in jsdom. Stubs @tiptap/react
  so the views render as plain DOM — the test passes on the fixed order and fails
  on the pre-fix chrome-first order.
- Reword the code-block-view comment: it claimed a "top-right overlay (the
  transclusion pattern)", but the menu stays fully in flow as a full-width row
  lifted via flex `order: -1` (the .codeBlock wrapper is a flex column). No
  overlay/absolute positioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:47:29 +03:00
claude code agent 227
1c39a45bc5 fix(editor): reflow scroll containers after paste to refresh click hit-testing (#146)
Pasting markdown/code inserts React NodeViews that mount asynchronously; until
the next reflow the browser's hit-test geometry is stale, so ProseMirror's
posAtCoords/caretRangeFromPoint maps a click to the wrong (offset) line — which
users reported clears itself on any scroll. Reproduce that scroll's side effect
with a ZERO-delta nudge (re-assign scrollTop/scrollLeft to their current value)
on every scrollable ancestor + the document scrolling element, run across two
animation frames so it lands after the pasted content + NodeViews commit. The
nudge does not move the viewport.

Wired into editor-paste-handler's handlePaste, which ProseMirror's someProp runs
(as an editorProps handler) before the MarkdownClipboard plugin that performs the
markdown/code insert — so the nudge is scheduled on exactly the paste path that
triggers the bug. Complements the structural NodeViewContent-order fix in this
branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:37:56 +03:00
claude code agent 227
7705d44fc6 fix(provenance): address #143 re-review — shared resolver + decoupled badge
Architecture & design:
- Arch A: introduce resolveProvenance() as the single source of truth for
  deriving a write's actor/aiChatId from the SIGNED identity, and wire it into
  BOTH transport seams — the REST jwt.strategy and the collab
  authentication.extension. Previously the collab seam derived actor from the
  token claim alone and ignored user.isAgent, so a flagged service account's
  page-content edits over the websocket persisted as lastUpdatedSource='user',
  drifting from REST. The seams now share one resolver and can't diverge.
- Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no
  longer imports historyAtoms; it exposes an onActivate callback fired after the
  deep-link, and the history row passes onActivate to close its own modal.

Suggestions/warnings:
- S1: soften the jwt.strategy provenance comment (applies to every REST write).
- S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test
  (covered by ai-agent-badge.test.tsx).
- S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half
  duplicated test #2); keep only the signed actor='agent' claim assertion.
- W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key).
- W3: flip the design doc status to "реализовано (#143)".

Tests:
- new auth-provenance.decorator.spec.ts unit-tests resolveProvenance +
  agentSourceFields.
- new collab-seam test: is_agent user with no claim → actor='agent'
  (Arch A regression guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:27:03 +03:00
claude code agent 227
1d54f8ed1c refactor(provenance): extract agentSourceFields write-stamp helper (#143 review #5)
The agent write-stamp idiom — `...(isAgent ? { <source>: 'agent', <chat>: aiChatId } : {})`
— was hand-reimplemented at every REST write site, so each new path risked a
wrong literal or a forgotten aiChatId. Extract a single
`agentSourceFields(provenance, sourceKey, chatKey)` next to AuthProvenanceData and
call it at the 5 uniform spread sites:

- comment.service create  -> createdSource / aiChatId
- page.service create/update/orphan-move/move -> lastUpdatedSource / lastUpdatedAiChatId

Sites that must CLEAR the source on a non-agent action keep their own conditional
(comment un-resolve writes an explicit null), and the collab persistence path keeps
its sticky-window logic — both noted in the helper's doc.

Behavior-preserving (the helper returns the identical object/`{}`). Typecheck
clean; server comment/page/auth/collab suites 246 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:05:54 +03:00
claude code agent 227
0647faefcd chore(comments): address PR #143 review (operator doc, badge test, dedup, type)
- [warn 1] Document the is_agent operator setup so it survives plan deletion:
  added an AI-agent block to .env.example (use a DEDICATED account, set is_agent
  via SQL, never flag a human/shared account) + a CHANGELOG "Added" entry.
- [warn 2] Test the badge deep-link side effects: ai-agent-badge.test.tsx now
  renders inside an explicit jotai store, clicks the badge, and asserts the
  active chat id, window-open, cleared draft, closed history modal, AND that
  stopPropagation keeps a parent onClick from firing.
- [suggestion 3] Hoist the window.matchMedia stub into vitest.setup.ts and drop
  the duplicated beforeAll block from the three test files (ai-agent-badge,
  comment-list-item, role-cards).
- [suggestion 4] Merge the two near-duplicate "non-clickable" cases via it.each.
- [follow-up 6] Introduce a single ProvenanceSource = 'user' | 'agent' type in
  jwt-payload.ts and reference it from AuthProvenanceData, JwtPayload/
  JwtCollabPayload, and resolveSource() — so a typo can't slip through as a bare
  string. (Server auth chain; client IComment mirroring left as a follow-up.)

Follow-up 5 (shared agentSourceFields write-stamp helper) is deferred as the
review marked it — the 6 REST sites use varied shapes (create-spread vs
resolve-conditional-null vs page move), so it's a separate focused refactor.

Tests: client badge/comment/role-cards suites 11/11 pass; server auth+comment
suites 62 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:56:26 +03:00
claude code agent 227
38544e2ddc fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)
Three editable NodeViews rendered a contentEditable=false "chrome" element IN
FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing
(posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the
caret to the previous node — the caret/selection lands a line (code block) or
several lines (footnotes, into the body) above where the user clicked.

Fix (the transclusion pattern / issue #146 plan): make the editable
NodeViewContent the FIRST child in the DOM and move the non-editable chrome
AFTER it, restoring its visual position with CSS:

- code-block-view: <pre><NodeViewContent/></pre> first; the language/copy menu
  follows and is lifted above via flex `order` (.codeBlock is now a flex column).
- footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and
  is lifted above via flex `order` (.list is a flex column; the separator border
  stays on the container).
- footnote-definition-view: NodeViewContent first; the "N." marker follows with
  `order:-1` to stay on the left; the ↩ back-link stays on the right.

Layout is visually unchanged. Verified in a real browser (Chromium): the
contentDOM is now the first child of every editable NodeView wrapper (no
contentEditable=false element precedes it), and the menu/heading/marker still
render in their original positions.

NOTE: the caret-offset itself is macOS-specific text hit-testing and does not
reproduce in headless Chromium/WebKit on Linux (verified extensively), so the
visible fix is best confirmed on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:21 +03:00
claude_code
aeea315618 fix(ai-chat): stop duplicating the assistant name in the typing indicator
While a multi-step agent turn is "thinking between tool steps", the
assistant identity name (e.g. the role name) was rendered twice, stacked:
once by the assistant message row (MessageItem) and once by the standalone
TypingIndicator below it. The indicator's name label only makes sense when
it stands in for a not-yet-started assistant row; between steps the row
above already shows the same name.

Render the indicator's dimmed name label only when it is standalone (no
assistant row at the tail yet); otherwise show just the "Thinking…" dots.

- typing-indicator.tsx: optional showName prop (default true); the name
  label renders only when showName !== false
- message-list.tsx: exported typingIndicatorShowsName(messages) helper;
  pass showName to the indicator at the render site
- typing-indicator-shows-name.test.ts: unit-cover the four cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:32:20 +03:00
claude_code
4704c3b7f9 chore(vscode): add push task for github and gitea
Provides a VSCode task to push the develop branch to both GitHub and Gitea with a single command.
2026-06-23 18:54:24 +03:00
claude_code
5161de8ba9 revert(ai-http): drop resilient fetch/RetryAgent layer (#140)
The custom undici RetryAgent + aiFetch transport added for issue #140
did not actually heal mid-stream provider drops: undici's retry path is
a Range-based download-resume that SSE/chat-completions endpoints cannot
satisfy, so a reset after the first byte only swapped ECONNRESET for a
"server does not support the range header" error. Its only real effect
was reconnecting a poisoned keep-alive socket before the first byte, and
PR #141 on top of it turned the 60s headers timeout into deterministic
~61s failures (plus CONTENT_LENGTH_MISMATCH from retrying a POST body
after a timeout abort). The root cause is the z.ai coding endpoint, not
our transport.

Remove the whole layer and return all AI provider calls to Node's
default global fetch.

- delete integrations/ai/ai-http.ts and its spec
- ai.service.ts: drop the aiFetch import, the AI_BYPASS_RESILIENT_FETCH
  diagnostic toggle, and fetch:aiFetch from every chat/embedding/STT
  factory; raw STT call back to global fetch
- ai-chat.controller.ts: drop the stream-timing START log + startedAt
- ai-chat.service.ts: drop the first-chunk/FINISHED/ERROR timing logs
- .env.example: drop AI_BYPASS_RESILIENT_FETCH

Reverts: 1af5d34a, 7c308728, b7abb7ea, 35fc58ea, d6cd2754, 6efb8656.
Preserved (not part of the rollback): client-disconnect abort, title
generation in onFinish, partial-answer persistence, Safari SSE heartbeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:48:33 +03:00
claude code agent 227
989f99abae feat(comments): attribute MCP agent comments as AI (unspoofable provenance)
Mark comments (and, via existing page provenance, pages) created under an
is_agent service account as authored by AI, derived from the SIGNED server
identity rather than any client field, and render the existing AI badge in
the comments sidebar.

Backend (B1):
- Add additive users.is_agent boolean (default false) migration; reflect in
  the Users Kysely type, the user repo baseFields, and (via Selectable) the
  User entity.
- jwt.strategy: derive req.raw.actor from user.isAgent (an is_agent account
  stamps every write 'agent'); external MCP has no internal ai_chats row so
  aiChatId stays null. Non-spoofable: a plain user cannot obtain
  created_source='agent'.
- Loosen the provenance aiChatId type to string|null across token.service and
  the JwtPayload/JwtCollabPayload claims (type-level only; the internal AI-chat
  path still passes a real aiChatId).

Frontend (B2):
- Extend IComment with createdSource/aiChatId/resolvedSource (backend already
  returns them via selectAll).
- Extract the local AiAgentBadge from history-item into a shared
  components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present,
  plain label when null/absent); reuse it in history-item and render it in
  comment-list-item next to the author name when createdSource==='agent'.

Tests: comment.service agent/null-aiChatId provenance, jwt.strategy provenance
derivation + anti-spoof, AiAgentBadge clickable/non-clickable branches, and
comment-list-item badge render/no-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:29:12 +03:00
0fabaa5bfb Merge pull request 'test: unit tests for the 10 candidates (#139)' (#142) from test/unit-tests-139 into develop
Reviewed-on: #142
2026-06-23 04:16:50 +03:00
6efb865625 Merge pull request 'fix(ai-http): fail fast + retry on provider header stall (#140)' (#141) from fix/ai-stream-headers-timeout into develop
Reviewed-on: #141
2026-06-23 04:16:37 +03:00
claude code agent 227
7c48bab1f2 test: add unit tests for 10 candidates from issue #139
Adds co-located unit tests for ten targets (client → vitest *.test.ts(x),
server → jest *.spec.ts), plus minimal behavior-preserving extractions/exports
where the issue required a pure function to test:

- encode-wav: WAV header + PCM16 clamping
- editor-ext embed-provider / utils (sanitizeUrl, isInternalFileUrl) / indent
  (export clampIndent)
- label.dto @Matches regex
- move-page.dto vs generateJitteredKeyBetween parity (bug locked via test.failing)
- new-note-button canCreatePage (extracted to can-create-page.ts)
- history-editor diff (extracted pure computeHistoryDiff into history-diff.ts)
- notification getTypesForTab + repo contract (direct-tab divergence locked via
  test.failing)
- search buildTsQuery (extracted + sanitizes operator inputs so adversarial
  queries no longer risk a to_tsquery 500)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude code agent 227
d6cd275469 test(ai-http): cover header-stall fail-fast + retry (#140)
Extend ai-http.spec with two loopback-server tests: a provider that stalls
without sending headers triggers the (lowered) headersTimeout and is retried on a
fresh connection, recovering; a healthy fast response passes through in one
attempt. No external network calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude code agent 227
35fc58eaaa fix(ai-http): fail fast + retry on provider header stall (#140)
The z.ai GLM coding endpoint intermittently accepts the chat request but never
sends response headers; undici's default 300s headersTimeout then hung the user
for five minutes before failing, and UND_ERR_HEADERS_TIMEOUT was not in the
RetryAgent's retried error set, so there was no recovery.

headersTimeout only bounds time-to-FIRST-headers (before any body) — it is NOT
the streaming budget, so lowering it does not truncate live SSE streams. Cap it
(env AI_HTTP_HEADERS_TIMEOUT_MS, default 60s) so a header stall fails fast, and
add UND_ERR_HEADERS_TIMEOUT to the retried error codes so the stalled request is
retried on a fresh connection (which usually responds in seconds). bodyTimeout
kept generous (env AI_HTTP_BODY_TIMEOUT_MS, default 300s) so slow streams with
sparse chunks survive. UND_ERR_BODY_TIMEOUT is deliberately NOT retried (mid-body,
partial SSE already delivered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00
claude_code
7884dc2e1a feat(editor): add gitmost bridge for listing spaces/pages and creating a page with a recording
Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:

- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
  -> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
  filename, mimeType}): validate/decode the audio first (so a bad payload
  leaves no junk page), resolve the space slug via getSpaceById (no-space
  probe), createPage(), navigate via the router (no reload), wait for the
  new page's editor to be mounted+editable+Yjs-connected, then run the same
  uploadAudioAction path as insertRecording. Resolve-only error contract:
  no-space | create-failed | editor-timeout | insert-failed.

DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).

Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:51:17 +03:00
97002f318a Merge pull request 'fix(ai-chat): adopt the server-returned chat id (two-tab adoption race #137)' (#138) from fix/ai-chat-chatid-adoption into develop
Reviewed-on: #138
2026-06-23 03:35:03 +03:00
claude code agent 227
870df458ed test(ai-chat): cover double onTurnFinished; name hook option/result types
Closes the 4th PR #138 review (3 suggestions, no blockers).

- Double-call safety: a failed turn fires both useChat onFinish AND onError, so
  onTurnFinished can run twice in one turn (streamed id, then no id) before a
  re-render. onTurnFinished now reads the live id from a ref (set imperatively on
  primary adoption) instead of the stale closure, so the 2nd no-id call cannot
  re-arm the error-path fallback at the source; the render-phase reconciler is the
  second layer. Added a hook test for the sequence — verified it fails only if
  BOTH layers are removed (non-tautological).
- Conventions: extracted named UseChatSessionOptions / UseChatSessionResult
  interfaces (was an inline param literal + ChatSession); the test derives its
  driver props from them.
- Simplification: extracted the chatIdSnapshot(chats) projection used at both the
  fallback arm site and the resolver effect.

Architecture notes from the review (caller-driven disarm contract; cross-process
{chatId} type) intentionally left as Variant A per the reviewer's recommendation.

tsc clean; 128 ai-chat tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:33:15 +03:00
claude code agent 227
8f9a218c68 fix(ai-chat): disarm pending adoption on New chat/select; drop dead helper
Closes the 3rd PR #138 review.

Warning fix: the render-phase reconciler only disarms the error-path adoption
fallback when activeChatId actually changes. Pressing 'New chat' while ALREADY
in a new chat keeps activeChatId === null (a no-op atom write), so the reconciler
never fired and a stale armed fallback could adopt the just-failed chat from a
late refetch, yanking the user out of their fresh chat. useChatSession now
returns cancelPendingAdoption(); the window calls it from startNewChat AND
selectChat. (The hook call moved above those callbacks so they can reference it.)
Added a hook test that fails without the explicit disarm, plus a test for the
existing-chat onTurnFinished branch (no adoption + per-chat invalidation).

Cleanups: removed the dead pickNewlyCreatedChatId (the fallback effect uses
newlyAddedChatIds directly with the 0/1/>1 decision inline) and its tests/doc
mention; inlined the two invalidation closures (onTurnFinished is read live by
useChat's onFinish, never in an effect dep array, so memoizing them was needless
ceremony).

Verified: tsc clean, 127 ai-chat tests green; live (z.ai glm-5.2) new chat + 2nd
turn recalled the number in the SAME row (1 chat / 4 messages), no page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:04:40 +03:00
claude_code
b7abb7ea01 feat(ai-http): log detailed fetch error cause chain
Node's fetch returns a generic "fetch failed" error, hiding the actual
reason (e.g., ECONNRESET, timeout) in the error's cause chain. This
change extracts up to three levels of the cause, formats each with its
code and message, and includes the chain in the warning log, making
failures more actionable.
2026-06-23 03:01:10 +03:00
claude_code
fd66ee6cce fix(ai-chat): stop title generation racing the chat stream (provider stall)
A new-chat turn fired the chat stream (streamText) and title generation
(generateText) concurrently to the same z.ai coding endpoint. That plan
stalls one of two concurrent requests, so the chat stream black-holed for
~300s (undici headers timeout) and the turn hung forever in every browser;
the AI SDK then retried 3x. Server logs showed two concurrent POSTs to
/chat/completions per turn — one 200 in ~8s, the other "fetch failed after
301209ms". Bypassing the custom undici transport did not help, confirming
the cause is the concurrency, not the transport.

Move generateTitle from before the response pipe into onFinish, so it runs
solo AFTER the stream's provider call completes. A first turn that errors or
aborts no longer auto-titles (fallback "Untitled chat" already handles a
null title) — acceptable, and it removes the request that was stalling.
2026-06-23 02:41:14 +03:00
claude_code
a8d1caf039 Merge branch 'feat/ai-settings-per-card-save' into develop
Add a per-card "Save and test" button to the AI provider settings (save the
whole form, then probe the endpoint on success).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:21 +03:00
claude_code
6946ee4415 feat(ai-settings): add per-card "Save and test" button
The single global "Save endpoints" button sat far below the fold and the
per-card "Test endpoint" button probed the server-stored settings, so it
ignored unsaved form edits. Replace each endpoint card's "Test endpoint"
button with a combined "Save and test" button that persists the whole form
first and only runs the card's connection probe on a successful save; the
global "Save endpoints" button is kept for save-only.

- Add handleSaveAndTest: save (rethrows on failure) then probe; skip the
  test if the save fails (the mutation already surfaces the error).
- Add savingTestCapability state so only the clicked card spins during the
  shared save while all save controls stay disabled (no concurrent saves).
- Reset the previous probe result when a new save+test starts.
- Add the "Save and test" en-US translation key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:13 +03:00
claude code agent 227
f59ca3cb0d refactor(ai-chat): extract useChatSession hook + lock the id lifecycle with tests
Addresses the 2nd PR #138 review (test debt + the Variant-B architecture ask).

The new→persisted chat id lifecycle (mount key, both adoption paths, the
history-load latch, the render-phase reconciler, onTurnFinished) is moved out of
the 768-line window into a new useChatSession hook driven by a pure
threadSessionReducer (reconcile/adopt), so adopt-vs-switch is one explicit
dispatch point and the scattering the review flagged is gone (window: 768→~620).

Tests (the blockers):
- use-chat-session.test.tsx — hook-level locks incl. the #137 regression
  (adopts the authoritative streamed id 'A', NOT chats.items[0]='B' — fails on
  the old heuristic), the error-path fallback (arm/adopt/ambiguous/add+delete),
  the disarm-on-reconcile lock (a fallback armed then switched away must not be
  adopted by a late refetch), in-place-adopt-keeps-key vs external-switch-remount,
  and the waitingForHistory latch.
- extractServerChatId (reading message.metadata.chatId) and newlyAddedChatIds
  extracted as pure helpers with unit tests; threadSessionReducer tested.

Cleanups: single canonical #137 explanation in adopt-chat-id.ts (other sites
reference it); fallback effect computes the set diff once; invalidate callbacks
memoized; redundant invariant tests folded.

Behavior preserved — re-verified live (z.ai glm-5.2): new-chat adopt + 2nd turn
in the same row, no mid-conversation remount, two-tab race leak-free, switch to
an existing chat reseeds full history, reload restores history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:25:52 +03:00
claude_code
1efc016cd9 Merge branch 'feature/gitmost-audio-bridge' into develop
Expose window.gitmost native bridge for inserting recorded audio as audio blocks.
2026-06-23 02:24:39 +03:00
claude_code
10fe605159 feat(editor): expose window.gitmost bridge to insert recordings as audio blocks
Add a stable native-host JS-API on window.gitmost so the gitmost.app
WKWebView wrapper can hand a recorded audio file to the current page as an
audio block without depending on editor internals (atoms/Tiptap/Yjs).

- page-editor.tsx: register/tear down window.gitmost only while an editable
  page editor is mounted (ready=true, version=1). insertRecording validates
  mime/size, decodes base64 in 4-char-aligned chunks, rejects oversized
  payloads before decoding (too-large), reuses the existing uploadAudioAction
  pipeline, resolves machine-readable error codes
  (no-editor/bad-type/too-large/insert-failed) and never throws. Cleanup is
  identity-guarded so an unmounting PageEditor cannot disable a newer live
  registration.
- editor-ext audio-upload: return the uploaded attachment from the upload fn
  so the bridge can report success + attachmentId. Backward compatible:
  existing fire-and-forget callers ignore the return value; the error path
  still swallows and returns undefined (no re-throw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:24:28 +03:00
claude_code
7c308728de chore(ai-chat): add stream timing logs + env-gated aiFetch bypass (diagnostics)
The streaming chat turn hangs in all browsers while the non-streaming test
endpoint works — both use the same model/transport (createOpenAI + aiFetch),
so the suspect is the streaming path / custom undici RetryAgent transport.

- ai-http.ts: wrap aiFetch with per-request timing logs (start, ms-to-headers
  on success, elapsed ms + cause on failure). Chat at info, embeddings at
  debug. Only host+path logged.
- ai-chat.controller.ts / ai-chat.service.ts: log turn START, first-chunk
  latency, FINISHED duration, and elapsed ms on disconnect/error/abort.
- ai.service.ts: AI_BYPASS_RESILIENT_FETCH=true makes the CHAT model omit
  fetch:aiFetch and use the default global fetch — isolates transport vs
  request-shape. Chat-only; embeddings/STT untouched; reversible via env.
- .env.example: document the flag.

No timeout/retry change. tsc clean; ai-chat + ai suites pass (292).
2026-06-23 02:13:54 +03:00
claude_code
da058bb6a0 feat(editor): transcribe embedded audio blocks
Add a gated "Transcribe" action to the audio block's bubble menu so an
already-embedded audio file can be transcribed (previously only live
microphone dictation was supported). The button fetches the embedded
file, normalizes its MIME type to the STT whitelist, reuses the existing
POST /ai-chat/transcribe endpoint, and inserts the result as a paragraph
right below the audio block.

- Mount the previously-unwired AudioMenu in page-editor (edit mode only),
  which also surfaces the existing Download/Delete actions for audio.
- Gate the Transcribe button on settings.ai.dictation; show a spinner and
  block double-submits while transcribing; map errors like the mic hook.
- Disambiguate duplicate-src blocks by re-scanning the doc and inserting
  after the audio node closest to the originally selected one.
- Add i18n keys (en-US, ru-RU): Transcribe, Transcribing…, No speech
  detected, plus ru-RU translations for the transcription error messages.
2026-06-23 01:47:34 +03:00
claude code agent 227
ba90147749 refactor(ai-chat): consolidate thread id lifecycle into one atomic identity
Addresses the PR #138 review's architecture note (the deferred 'non-blocking'
item). The brand-new -> persisted chat identity was spread across two separate
useState slots (threadKey + liveThreadChatId) plus a render-phase guard, so the
mount key and the live thread's chat id could in principle diverge.

Consolidate them into ONE atomic state object { key, chatId } with pure,
unit-tested transitions in thread-identity.ts:
- newThread(newKey)       — a brand-new id-less chat (fresh session key);
- switchThread(chatId)    — switch to an existing chat (key := chat id) -> remount;
- adoptThread(prev, id)   — a new chat learns its real id IN PLACE (key unchanged
                            -> no remount, live useChat store preserved).
The 'key vs chatId diverged' state is now unrepresentable. The render-phase
reconciliation stays (the atom is also set externally, e.g. page-history's
history-item opening a referenced chat), but adoption vs switch is now explicit.

Behavior is unchanged; verified live: new-chat adopt + 2nd turn in the same row,
no mid-conversation remount, the two-tab race stays leak-free, switch-to-existing
remounts + reseeds, and reload restores full history. Adds thread-identity.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:46:15 +03:00
claude_code
93c1e6e3e4 refactor(ui): replace comments icon with button
Switched the comments ActionIcon to a Button component, using a text label instead of an icon. This improves clarity and aligns the header menu with the current design guidelines.
2026-06-23 01:31:01 +03:00
claude_code
d21e1430b0 refactor(ui): replace star icon with message icon in app header 2026-06-23 01:25:48 +03:00
claude code agent 227
580f3442b8 fix(ai-chat): prevent duplicate chat row on first-turn error; add adoption tests
Addresses the PR #138 review.

Blocker 1 — duplicate chat row: a brand-new chat whose first turn errors BEFORE
the SSE 'start' chunk never receives the authoritative chatId, so metadata
adoption can't run; a retry then sent chatId:null and the server inserted a
SECOND chat row, orphaning the first turn. Keep metadata adoption as the primary
path (resolveAdoptedChatId) and add a bounded, unambiguous fallback: on a
new-chat finish with no server id, snapshot the known chat ids and, once the
list refetch lands, adopt the SINGLE newly-appeared id (pickNewlyCreatedChatId).
Zero or >1 new ids (e.g. two tabs racing) → no adoption — no items[0] guessing,
so #137 stays fixed. The wait-for-refetch guard compares set membership (robust
to a concurrent delete), and the diff dedupes so a repeated id from a paginated
list never reads as ambiguous.

Blocker 2 — tests: new adopt-chat-id.test.ts covers both pure helpers (adopt
decision + newly-created-id diff incl. dedupe/reorder); the server
messageMetadata callback is extracted to chatStreamStartMetadata and unit-tested
(start -> {chatId}, otherwise undefined).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:17:30 +03:00
claude_code
1b4de2b420 fix(ai-chat): keep SSE stream alive in Safari (heartbeat + strip hop-by-hop headers)
Safari/WebKit dropped the AI chat answer stream mid-turn ("Load failed",
shown as "Lost connection to the server") while Chrome/Firefox were fine.
Two Safari-specific causes: (1) during model think/tool gaps the UI-message
SSE stream emits no bytes and WebKit aborts a non-progressing fetch far more
aggressively than Chrome; (2) the AI SDK sets a hop-by-hop `Connection:
keep-alive` header which is illegal on HTTP/2 — Chrome/Firefox ignore it,
Safari rejects the whole response. Earlier commits only improved the error
text, never the drop itself.

Add apps/server/src/core/ai-chat/sse-resilience.ts with two helpers wired into
both stream paths (authenticated + public share):
- startSseHeartbeat: writes a `: ping` SSE comment every 15s (ignored by the
  client's EventSourceParserStream) so bytes keep flowing; unref'd timer,
  guarded writes, auto-clear on finish/close.
- stripStreamingHopByHopHeaders: wraps writeHead once to drop Connection/
  Keep-Alive before the head is sent, so they can never leak into an HTTP/2
  response.
Add sse-resilience.spec.ts (7 tests). tsc + eslint clean.
2026-06-23 01:02:55 +03:00
claude_code
544355a3c8 Merge branch 'feat/dictation-streaming-toggle' into develop
Gate streaming (silence-cut) dictation behind the per-workspace
settings.ai.dictationStreaming flag (default off); batch dictation stays the
default and fallback. Removes the implemented backlog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:14:28 +03:00
claude_code
44a1b5b003 feat(dictation): gate streaming dictation behind a workspace toggle
Streaming (silence-cut) dictation was hardcoded on. Put it behind a per-workspace
flag settings.ai.dictationStreaming, default off, with batch dictation as the
default and fallback. Mirrors the existing settings.ai.dictation flag end to end:

- server: aiDictationStreaming on UpdateWorkspaceDto + workspace.service writes
  settings.ai.dictationStreaming via updateAiSettings (jsonb merge keeps siblings)
- client: IWorkspaceAiSettings.dictationStreaming, an optimistic "Streaming
  dictation" sub-toggle under "Voice dictation" (disabled when dictation is off)
- gate the MicButton streaming prop in the editor toolbar and chat composer on
  the flag instead of a literal true

When the flag is absent/false both call sites pass streaming=false, so the VAD
model/wasm are never fetched and behavior is unchanged. Reuses the existing STT
model and /ai-chat/transcribe — no new provider/model/endpoint fields.

Removes the backlog entry now that it is implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:59:35 +03:00
claude code agent 227
0edc5aeda8 docs(agents): clarify where DB migrations auto-apply (prod) vs not (dev)
Migrations auto-run on boot only in production (the built image / start:prod);
the local dev stand (pnpm dev / nest start --watch) does NOT auto-run them, so
after pulling or switching branches you must apply them with
'pnpm --filter server migration:latest' or endpoints touching new columns 500
(e.g. a freshly-added ai_chats.page_id blanket-500s all of AI chat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:46:50 +03:00
claude code agent 227
1858a5800d fix(ai-chat): adopt the server-returned chat id, not the newest in the list
A brand-new chat (activeChatId === null) had no way to learn the id of the row
the server created: the SSE stream never returned it, so the client adopted the
NEWEST chat in the per-user list (chats.items[0]). With two tabs open, a second
tab creating a chat at ~the same time made its row the newest, so the first tab
adopted the wrong id — its later turns persisted into the other chat and the
agent rebuilt history from it (commands leaked between chats), while the live UI
still showed the original conversation. (#137)

The server now attaches the authoritative chatId to the streamed assistant
message via the AI SDK messageMetadata on the 'start' part, so it reaches the
client on the first chunk. The client reads message.metadata.chatId in useChat's
onFinish and adopts that id in place (no remount, so the live turn and the
thread's chatIdRef follow the real id and the next turn targets the right chat).
The chats.items[0] guess and the adoptNewChat ref are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:46:50 +03:00
claude_code
ee25d52965 docs(backlog): gate streaming dictation behind a feature toggle (default off)
Design entry: hide the silence-cut streaming dictation path behind a per-
workspace settings.ai.dictationStreaming flag, default false, with batch
dictation as the default and fallback. Reuses the existing STT model and
/ai-chat/transcribe — no new provider/model/endpoint fields. Lists the server
+ client touch points, acceptance criteria, and edge cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:40:53 +03:00
claude_code
2d7f85fccb Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-22 21:14:05 +03:00
86bb2742c7 Merge pull request 'fix(qa): resolve QA-pass issues #122–#134' (#135) from fix/qa-issues-122-134 into develop
Reviewed-on: #135
2026-06-22 21:07:19 +03:00
claude code agent 227
8915a875a2 fix(qa): address PR #135 review notes
- Add the two new strings to en-US locale ('Go to login page', 'Move to
  space') so they aren't missing from the base locale (review note 1).
- Avatar upload: accept any image/* MIME instead of a hardcoded png/jpeg/jpg
  list, so webp/gif/etc. are no longer wrongly rejected client-side while
  genuine non-images still surface the error (review note 2).
- Reindex polling: align the deadline-clearing effect with the refetchInterval
  stop condition (indexed >= total, empty workspace included) so the deadline
  clears promptly instead of waiting out the cap (review note 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:05:45 +03:00
claude_code
ebc3b01dc2 feat(ai-chat): mark interrupted turns with a "stopped" notice
A turn that ends without a clean finish now shows a neutral marker, so an
interrupted answer is visible instead of trailing off silently. Errors keep
their existing red banner; this covers the aborted case.

- chat-stopped-notice.tsx: new neutral (gray) notice component
- chat-thread.tsx: live marker driven by useChat onFinish flags — distinguishes
  a manual Stop (isAbort) from a dropped connection (isDisconnect); cleared when
  the next turn streams; flushNext still runs only on a clean finish
- message-item.tsx: per-message marker in reopened history for finishReason
  'aborted' with no error (combined wording, since the server can't tell a
  manual Stop from a dropped connection)
- ai-chat.types.ts: add metadata.finishReason; rowToUiMessage now carries it
- en-US: three new strings

Frontend only — the server already persists partial work and finishReason and
replays it to the model on the next turn (continue, not restart).
2026-06-22 20:56:30 +03:00
claude code agent 227
9e1d057878 fix(qa): resolve QA-pass issues #122–#134
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.

Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
  skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
  reconnected; read the token via a ref, guard the decode (incl. missing exp),
  and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
  enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
  computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
  also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
  size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
  polls coverage (bounded) so the counter climbs live; misleading server comment
  reworded.

UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
  space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
  placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
  style the invalid-invitation page with a CTA; hide Share for read-only users;
  align the dictation "not configured" message; "Go to login page" typo.

Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:47:40 +03:00
claude_code
c53ce35312 feat(ui): swap AI-chat and comments icons to avoid confusion
The AI chat button used IconSparkles and the page comments button used
IconMessage, which read as visually similar speech bubbles. Replace the
AI icon with IconMessageCircleStar (chat bubble + star) and the comments
icon with IconMessages (overlapping bubbles) so the two are clearly
distinct.

- app-header.tsx: IconSparkles -> IconMessageCircleStar
- page-header-menu.tsx: IconMessage -> IconMessages
2026-06-22 20:34:39 +03:00
claude_code
fc262636ab fix(ai-chat): persist partial answer when a turn errors mid-stream
A provider error (e.g. read ECONNRESET) routed the turn through the
streamText onError callback, which persisted an EMPTY assistant record
(buildErrorAssistantRecord -> text:'', parts:[]). The answer text already
streamed to and shown by the client was therefore lost from the persisted
row, the chat export, and reopened history — leaving only the error line.

The AI SDK v6 onError callback receives only { error } (no steps/text),
and the visible final answer streams in the last, not-yet-finished step,
so it is absent from every finished step.text. Accumulate it ourselves:
onChunk folds each 'text-delta' into inProgressText; onStepFinish moves a
finished step into capturedSteps and resets inProgressText. onError and
onAbort now persist the partial answer (finished steps' text + tool parts
via assistantParts, then the in-progress text appended last) through a new
shared pure helper buildPartialAssistantRecord, recording the cause in
metadata.error on the error path. Replaces buildErrorAssistantRecord; its
empty-turn shape is preserved when nothing streamed.

Complementary to the resilient-fetch reconnect: that reduces how often a
turn dies; this preserves what was produced when it dies anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:30:59 +03:00
claude_code
aebdb6c00c Merge branch 'fix/ai-econnreset-resilience' into develop 2026-06-22 20:26:58 +03:00
claude_code
1af5d34ae3 fix(ai-chat): reconnect on provider ECONNRESET via a resilient fetch
Outbound LLM calls used Node's default global undici agent (default
keep-alive pooling, no transport-level reconnect), so a TCP RST on a
reused/poisoned keep-alive socket surfaced as
"Cannot connect to API: read ECONNRESET" and failed the chat stream and
title generation after the AI SDK's own retries were exhausted.

Add a dedicated resilient outbound HTTP layer (ai-http.ts): a shared
undici RetryAgent over a tuned Agent, exposed as `aiFetch` and injected
into every AI provider factory (createOpenAI chat/embeddings/STT,
createGoogleGenerativeAI, createOllama) plus the raw JSON STT fetch. The
RetryAgent reconnects on connection-level errors (ECONNRESET, ...) on a
FRESH socket, opts POST into the retry methods (undici's default list
excludes POST), and leaves HTTP-status retries (429/5xx + Retry-After) to
the AI SDK to avoid double-retry.

- ai-http.ts: shared RetryAgent(Agent) + aiFetch (maxRetries 2,
  conservative keep-alive, connect timeout, streaming-safe timeouts)
- ai.service.ts: inject fetch: aiFetch into every provider factory
- ai-http.spec.ts: regression test that aiFetch injects the RetryAgent
  dispatcher into the underlying fetch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:23:35 +03:00
claude_code
83c61641c9 fix(ai-chat): prevent error banner from clipping its text
The error banner is a flex child of the chat panel column. Mantine's
Alert root is `overflow: hidden`, which (per the CSS flexbox spec) drops
its automatic min-height to 0, so when the message history fills the
panel the flexbox compressed the banner below its content height and the
overflow:hidden clipped the detail text (e.g. "Please try again.").

Set flex-shrink: 0 on the banner so it always shows its full content; the
scrollable message list absorbs the height pressure instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:22:53 +03:00
claude_code
7ddd0cba05 feat(ai-chat): include in-progress streaming turn in chat export
The "Copy chat" export read only persisted DB rows (messageRows), so an
assistant reply that was still streaming — and the user message that
triggered it — were absent from the export until the turn finished and
the messages query was refetched.

ChatThread now mirrors its live useChat snapshot ({ messages,
isStreaming }) into a parent-owned ref; the effect clears the ref on
unmount so a thread switch can't leak its tail into the next chat.
AiChatWindow.handleCopy computes the not-yet-persisted live tail
(messages whose id is absent from messageRows, only while streaming) and
passes it to buildChatMarkdown as `pending`. buildChatMarkdown appends
pending messages after the persisted rows (continuing the heading
numbering), flags the streaming assistant message with an
"still being generated" note, and reuses an extracted renderMessageParts
helper so persisted and pending rendering stay identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:00:35 +03:00
claude_code
11d5a75c79 fix(ai-chat): improve error banner layout
The AI chat error Alert stranded the warning icon in the top-left while
the detail text hung indented under the heading, wrapping to 3 narrow
lines with empty space below. Switch to a "full-width detail" layout
(icon + bold heading on the first row, detail spanning full width below)
and extract the markup, previously duplicated in ChatThread and
MessageItem, into a single shared ChatErrorAlert component.

- add apps/client/src/features/ai-chat/components/chat-error-alert.tsx
- use it for the live stream error in chat-thread.tsx (mb="xs")
- use it for the persisted history error in message-item.tsx (mt={4})
- heading/icon use the adaptive --mantine-color-red-light-color so the
  banner stays correct in dark mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:54:17 +03:00
177 changed files with 8500 additions and 1022 deletions

View File

@@ -123,6 +123,14 @@ MCP_DOCMOST_PASSWORD=
# expose the port publicly).
# MCP_TOKEN=
# MCP_SESSION_IDLE_MS=1800000
#
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
# attribution is driven by a per-user `is_agent` flag on the users row. There is
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
# account for the MCP fallback above and flag ONLY that account, e.g.:
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.

14
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
// VSCode tasks for this repo.
"version": "2.0.0",
"tasks": [
{
"label": "git push (github + gitea)",
"type": "shell",
"command": "git push github develop && git push gitea develop",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
"problemMatcher": []
}
]
}

View File

@@ -157,6 +157,19 @@ below.
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | The original Docmost — **never push** |
## Creating issues (Gitea `tea` CLI)
Issues are filed with the official Gitea CLI `tea`, already logged in as
`claude_code` (`tea logins list` shows the `gitea` login as default):
```bash
tea issues create --repo vvzvlad/gitmost --labels feature \
--title '<title>' --description "$(cat body.md)"
```
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
---
# Architecture and codebase
@@ -210,7 +223,7 @@ pnpm --filter @docmost/mcp test # node --test (unit + mock)
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
```
**Database migrations** (Kysely, run from `apps/server`; they auto-run on server startup too):
**Database migrations** (Kysely, run from `apps/server`). **Where they auto-apply:** in **production** (the built image / `start:prod`) pending migrations run automatically on server boot. In **local dev** (the `pnpm dev` stand / `nest start --watch`) they do **NOT** auto-run — after you pull or switch branches you must apply them yourself with `pnpm --filter server migration:latest`, or any endpoint touching a new column/table 500s (e.g. a freshly-added `ai_chats.page_id` blanket-500s all of AI chat until migrated).
```bash
pnpm --filter server migration:create --name=my_change # new empty migration
pnpm --filter server migration:latest # apply all pending

View File

@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
the MCP endpoint by a dedicated agent account are now badged as "AI", with
unspoofable provenance derived from a per-user `is_agent` flag (not from the
request body). **Operator setup:** use a *dedicated* service account for the
MCP fallback and set the flag with SQL —
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
human or shared account, or its normal edits get mis-attributed as AI. See the
AI-agent block in `.env.example`. (#143)
### Changed
- **Public share AI: default per-workspace hourly assistant cap lowered
@@ -19,6 +30,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
keep the previous limit. (#62)
### Fixed
- **Editor: caret/selection landed on the wrong line when clicking inside code
blocks and footnotes.** The affected NodeViews rendered their non-editable
chrome (language menu, footnotes heading, footnote number marker) before the
editable content, so the browser's click hit-testing missed the contentDOM and
snapped the caret to a previous node. Content now renders first in the DOM
(chrome is lifted back into place via CSS flex `order`), and scroll containers
are nudged after a paste to refresh stale hit-testing geometry. The caret
symptom is macOS-specific and was confirmed manually on macOS; the automated
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
## [0.93.0] - 2026-06-21
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,

View File

@@ -420,6 +420,8 @@
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"{{count}} result found_one": "{{count}} result found",
"{{count}} result found_other": "{{count}} results found",
"Equal columns": "Equal columns",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -1127,15 +1129,32 @@
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Label added": "Label added",
"Label removed": "Label removed",
"Image updated": "Image updated",
"Unsupported image type": "Unsupported image type",
"Member deactivated": "Member deactivated",
"Member activated": "Member activated",
"Name is required": "Name is required",
"Name must be 40 characters or fewer": "Name must be 40 characters or fewer",
"Group name must be at least 2 characters": "Group name must be at least 2 characters",
"Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer",
"Description must be 500 characters or fewer": "Description must be 500 characters or fewer",
"Invalid invitation link": "Invalid invitation link",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"AI chat": "AI chat",
"Ask a question about this documentation.": "Ask a question about this documentation.",
"Ask a question…": "Ask a question…",
"Thinking…": "Thinking…",
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant",
"Enabled": "Enabled",
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
"Public assistant model": "Public assistant model",
"Defaults to the chat model": "Defaults to the chat model",
@@ -1145,6 +1164,7 @@
"Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"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…",
@@ -1154,6 +1174,9 @@
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
"Response stopped (manually or the connection dropped).": "Response stopped (manually or the connection dropped).",
"Chat menu": "Chat menu",
"No chats yet.": "No chats yet.",
"Delete this chat?": "Delete this chat?",
@@ -1185,8 +1208,11 @@
"Semantic search": "Semantic search",
"Voice / STT": "Voice / STT",
"Voice dictation": "Voice dictation",
"Streaming dictation": "Streaming dictation",
"Transcribe as you speak, cutting on pauses": "Transcribe as you speak, cutting on pauses",
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
"Test endpoint": "Test endpoint",
"Save and test": "Save and test",
"Save endpoints": "Save endpoints",
"Configured and enabled": "Configured and enabled",
"Configured but disabled": "Configured but disabled",
@@ -1219,6 +1245,8 @@
"No microphone found": "No microphone found",
"Could not start recording": "Could not start recording",
"Transcription failed": "Transcription failed",
"Transcribe": "Transcribe",
"No speech detected": "No speech detected",
"Voice dictation is not configured": "Voice dictation is not configured",
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
@@ -1245,6 +1273,10 @@
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
"Start automatically": "Start automatically",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
"Launch message": "Launch message",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
"Agent roles": "Agent roles",
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
"No roles configured": "No roles configured",
@@ -1264,5 +1296,16 @@
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
"Analytics / tracker": "Analytics / tracker",
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
"Showing {{count}} subpages_other": "Showing {{count}} subpages"
}

View File

@@ -385,6 +385,11 @@
"Quote": "Цитата",
"Image": "Изображение",
"Audio": "Аудио",
"Transcribe": "Транскрибировать",
"Transcribing…": "Транскрибация…",
"No speech detected": "Речь не распознана",
"Transcription failed": "Не удалось распознать речь",
"Voice dictation is not configured": "Голосовой ввод не настроен",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
@@ -672,9 +677,21 @@
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"Start automatically": "Запускать автоматически",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
"Launch message": "Стартовое сообщение",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…",
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
@@ -685,6 +702,7 @@
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
@@ -1132,5 +1150,15 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
}

View File

@@ -42,6 +42,23 @@ export default function AvatarUploader({
return;
}
// Validate file type. The `accept` attribute only filters the dialog;
// a user can still select a non-image file, which previously failed
// silently. Surface a visible error instead (issue #133). Accept any
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
// what the server accepts; only genuinely non-image files are rejected.
if (!file.type.startsWith("image/")) {
notifications.show({
message: t("Unsupported image type"),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
@@ -58,6 +75,8 @@ export default function AvatarUploader({
try {
await onUpload(file);
// Notify on success so the upload gives visible feedback (issue #128)
notifications.show({ message: t("Image updated") });
} catch (error) {
console.error(error);
notifications.show({
@@ -117,7 +136,7 @@ export default function AvatarUploader({
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
accept="image/*"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}

View File

@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -5,7 +5,7 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
@@ -107,7 +107,7 @@ export function AppHeader() {
aria-label={t("AI chat")}
onClick={() => setAiChatWindowOpen((v) => !v)}
>
<IconSparkles size={20} />
<IconMessage size={20} />
</ActionIcon>
</Tooltip>
)}

View File

@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
import classes from "./app-shell.module.css";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
@@ -157,6 +158,10 @@ export default function GlobalAppShell({
{/* Floating AI chat window. Mounted once globally; it is position: fixed
and self-hides when closed, so its place in the tree is not critical. */}
<AiChatWindow />
{/* Global gitmost native bridge: registers listSpaces / listPages /
createPageWithRecording on window.gitmost so the native host can
create a page with a recording even when no page editor is open. */}
<GitmostGlobalBridge />
</>
);
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});

View File

@@ -0,0 +1,99 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;

View File

@@ -1,4 +1,22 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Persisted floating AI chat window geometry (position + size). Held in
* localStorage so a drag/resize survives a full page reload. `null` means
* "never placed yet" — the window then computes an initial top-right placement.
* On restore the value is clamped to the current viewport (see AiChatWindow).
*/
export type AiChatWindowGeom = {
left: number;
top: number;
width: number;
height: number;
};
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
"ai-chat-window-geom",
null,
);
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { generateId } from "ai";
import { type UIMessage } from "@ai-sdk/react";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
@@ -25,6 +25,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
@@ -40,6 +41,7 @@ import {
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
@@ -99,7 +101,8 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
/**
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
* chat, new chat, adopt-new-chat, open-page context, token sum) and wraps the
* chat, new chat, in-place id adoption from streamed metadata, open-page
* context, token sum) and wraps the
* reused inner components (ConversationList + ChatThread) in window chrome
* ported from the GitmostAgent.jsx design.
*/
@@ -122,39 +125,13 @@ export default function AiChatWindow() {
minimizedRef.current = minimized;
const winRef = useRef<HTMLDivElement>(null);
// Live window geometry (position + size); initialized lazily on first open so
// it is anchored to the current viewport (top-right corner). Kept in state so
// a user resize survives close/reopen and can be re-clamped to the viewport.
const [geom, setGeom] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
// Track whether we are awaiting the id of a just-created (new) chat, so we
// can adopt it once the chat list refreshes after the first turn finishes.
const adoptNewChat = useRef(false);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// Mount key for ChatThread + the chat the currently-mounted thread represents.
// `threadKey` normally tracks the active chat, so selecting a different chat
// (incl. from page history) remounts and re-seeds. The ONE exception is
// in-place adoption of a brand-new chat's server id: the adopt effect moves
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
// check below does not fire and the SAME thread stays mounted (its useChat
// already holds the just-finished turn) instead of being re-seeded from
// not-yet-persisted history.
const [threadKey, setThreadKey] = useState<string>(
() => activeChatId ?? `new-${generateId()}`,
);
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
activeChatId,
);
// Live window geometry (position + size); persisted to localStorage so a
// drag/resize survives a full page reload (and close/reopen). `null` means
// "never placed yet" — the layout effect below then computes an initial
// top-right placement anchored to the current viewport, and on restore it is
// re-clamped to the viewport (so a placement saved on a larger screen is not
// left partly off-screen).
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
const { data: chats } = useAiChatsQuery();
// Roles for the new-chat picker (any member may list them). Only fetched while
@@ -171,6 +148,20 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// Live snapshot of the active thread's useChat state, kept up to date by
// ChatThread. Lets the export include the in-progress (not-yet-persisted)
// streaming turn. A ref avoids re-rendering this window on every token.
const liveThreadRef = useRef<{ messages: UIMessage[]; isStreaming: boolean }>({
messages: [],
isStreaming: false,
});
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -188,21 +179,42 @@ export default function AiChatWindow() {
? { id: openPageData.id, title: openPageData.title }
: null;
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
// paths, the history-loaded latch, the render-phase reconciler) lives in this
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
// The invalidate closures are passed inline: `onTurnFinished` is read live by
// useChat's onFinish (never in an effect dep array), so their identity does not
// matter — no memoization ceremony needed.
const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } =
useChatSession({
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList: () =>
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
onInvalidateChatMessages: (id) =>
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
});
// startNewChat/selectChat set the public atom; the hook's render-phase
// reconciler handles the remount when activeChatId actually CHANGES. But
// pressing "New chat" while already in a new chat leaves activeChatId === null
// (a no-op for the atom), so the reconciler never fires — explicitly disarm any
// armed error-path fallback here so a late refetch can't yank the user into a
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
// 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;
cancelPendingAdoption();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
const selectChat = useCallback(
(chatId: string): void => {
// Cancel any pending adoption so it can't override an explicit selection.
adoptNewChat.current = false;
cancelPendingAdoption();
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
@@ -210,30 +222,9 @@ export default function AiChatWindow() {
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft, setSelectedRoleId],
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
// yet), the server has just created the row; adopt the newest chat id so the
// thread switches from "new" to the persisted chat (and loads its history on
// later opens).
const onTurnFinished = useCallback(() => {
if (activeChatId === null) adoptNewChat.current = true;
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]);
// The active chat object (for its title) and an export gate: only enable the
// export button when an existing chat with loaded persisted rows is active.
const activeChat = useMemo(
@@ -259,72 +250,33 @@ export default function AiChatWindow() {
// feedback.
const handleCopy = useCallback(() => {
if (!activeChatId || !messageRows || messageRows.length === 0) return;
// While the active thread is streaming, the current user message and the
// in-progress assistant reply are NOT yet in messageRows (the persisted
// query is only refetched after the turn finishes). Pull the live tail —
// messages whose id is not among the persisted rows — and append them,
// flagging the streaming assistant message as still generating.
const live = liveThreadRef.current;
const rowIds = new Set(messageRows.map((r) => r.id));
const pending = live.isStreaming
? live.messages
.filter((m) => !rowIds.has(m.id))
.map((m) => ({
role: m.role,
parts: (m.parts ?? []) as { type: string; text?: string }[],
generating: m.role === "assistant",
}))
: [];
const markdown = buildChatMarkdown({
title: activeChat?.title ?? null,
chatId: activeChatId,
rows: messageRows,
pending,
t,
});
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
}, [activeChatId, messageRows, activeChat, clipboard, t]);
// When awaiting a new chat's id, adopt the most-recent chat (the list is
// ordered newest-first) once it appears.
useEffect(() => {
if (!adoptNewChat.current) return;
const newest = chats?.items?.[0];
if (newest) {
adoptNewChat.current = false;
// In-place adoption: move the active chat AND the live-thread marker to the
// new id together, so the threadKey derivation below sees no "switch" and
// keeps the SAME mounted thread (its useChat already holds the finished
// turn) instead of remounting and re-seeding from not-yet-persisted history.
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
// render so the render-phase guard never observes the new activeChatId with
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
// batching inside this effect callback guarantees that; if the store/atom
// mechanism ever changes, gate adoption on an explicit flag instead.
setLiveThreadChatId(newest.id);
setActiveChatId(newest.id);
}
}, [chats, setActiveChatId]);
// Adjust the derived thread state during render when the active chat genuinely
// changes — the React-sanctioned alternative to an effect (it re-renders before
// paint, no extra commit, and converges since the next render finds them equal).
// In-place adoption of a new chat's id never reaches here because the adopt
// effect moves liveThreadChatId in lockstep with activeChatId.
if (activeChatId !== liveThreadChatId) {
setLiveThreadChatId(activeChatId);
setThreadKey(activeChatId ?? `new-${generateId()}`);
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages
// invalidation, which can transiently flip hasNextPage for a chat whose
// message count is an exact multiple of the server page size) does not tear
// the live thread down to a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
threadKey === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so we keep showing the live thread instead of unmounting it behind a
// loader; and once a chat's history has loaded, a later background refetch no
// longer tears the thread back down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
threadKey === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
// Current context size for the active chat: how much the conversation now
// occupies in the model's context window — NOT the cumulative tokens spent.
// We read the most recent assistant row that carries a context figure:
@@ -390,6 +342,10 @@ export default function AiChatWindow() {
useEffect(() => {
if (!windowOpen || minimized) return;
const el = winRef.current;
// `geom` is in the deps so this re-runs once geometry is settled and the
// window is actually rendered (on the first open `geom` is still null on the
// render that flips windowOpen, so winRef.current is null then — without the
// geom dep the observer would never attach and resizes would not persist).
if (!el) return;
const ro = new ResizeObserver(() => {
const width = el.offsetWidth;
@@ -401,7 +357,7 @@ export default function AiChatWindow() {
});
ro.observe(el);
return () => ro.disconnect();
}, [windowOpen, minimized]);
}, [windowOpen, minimized, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
// Ignore drags that originate on a button (minimize/close/new chat).
@@ -535,11 +491,19 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span>
</Tooltip>
) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
</Tooltip>
)}
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -657,6 +621,8 @@ export default function AiChatWindow() {
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

@@ -111,6 +111,24 @@
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
/* Collapsible "Thinking" (reasoning) block: a subtle left rule, dimmer than the
answer so it reads as secondary thinking context above the real answer. */
.reasoningBlock {
border-left: 2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: 8px;
}
.reasoningText {
margin-top: 4px;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
white-space: pre-wrap;
}
.reasoningText p {
margin: 0 0 4px;
}
.inputWrapper {
flex: 0 0 auto;
padding-top: var(--mantine-spacing-xs);

View File

@@ -0,0 +1,49 @@
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
/**
* A classified AI chat error banner: a warning icon + bold heading on the first
* row, with the detail text spanning the full width below. Rendered for BOTH the
* live stream error (ChatThread) and a persisted assistant error (MessageItem),
* so this markup lives in one place. The detail is full-width (no hanging indent
* under the heading) so it wraps less and leaves no stranded icon / empty gap.
* The heading reuses Mantine's adaptive red "light" colour so it stays correct
* in dark mode. Layout-only props (mb/mt/...) are forwarded to the Alert root.
*/
interface ChatErrorAlertProps extends Omit<AlertProps, "title" | "children"> {
title: string;
detail: string;
}
export default function ChatErrorAlert({
title,
detail,
style,
...alertProps
}: ChatErrorAlertProps) {
// Mantine's own "light" alert colour, adaptive across light/dark schemes.
const accent = "var(--mantine-color-red-light-color)";
return (
// flexShrink: 0 keeps the banner fully visible. Mantine's Alert root is
// `overflow: hidden`, so as a flex child of the chat panel it can otherwise
// be compressed below its content height and clip the detail text; the
// scrollable message list absorbs the height pressure instead.
<Alert
{...alertProps}
variant="light"
color="red"
p="xs"
style={[{ flexShrink: 0 }, style]}
>
<Group gap={8} wrap="nowrap" align="center" mb={4}>
<IconAlertTriangle size={18} style={{ flex: "none", color: accent }} />
<Text fw={700} size="sm" lh={1.2} style={{ color: accent }}>
{title}
</Text>
</Group>
<Text size="sm" lh={1.4}>
{detail}
</Text>
</Alert>
);
}

View File

@@ -35,6 +35,10 @@ export default function ChatInput({
const [value, setValue] = useAtom(aiChatDraftAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
// keeps the stable batch path.
const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true;
const submit = (): void => {
const text = value.trim();
@@ -71,7 +75,7 @@ export default function ChatInput({
{isDictationEnabled && (
<MicButton
size="lg"
streaming
streaming={streamingDictation}
disabled={isStreaming || disabled}
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
/>

View File

@@ -0,0 +1,41 @@
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
import { IconPlayerStopFilled } from "@tabler/icons-react";
/**
* A neutral "turn was interrupted" notice (NOT an error). Rendered for an
* aborted turn — a manual Stop or a dropped connection — both live (ChatThread)
* and in reopened history (MessageItem). Deliberately gray/subtle so it reads as
* an informational marker, distinct from the red ChatErrorAlert. Layout-only
* props (mt/mb/...) are forwarded to the Alert root.
*/
interface ChatStoppedNoticeProps extends Omit<AlertProps, "title" | "children"> {
text: string;
}
export default function ChatStoppedNotice({
text,
style,
...alertProps
}: ChatStoppedNoticeProps) {
return (
<Alert
{...alertProps}
variant="light"
color="gray"
p="xs"
// flexShrink: 0 mirrors ChatErrorAlert so the notice is not compressed as a
// flex child of the chat panel.
style={[{ flexShrink: 0 }, style]}
>
<Group gap={8} wrap="nowrap" align="center">
<IconPlayerStopFilled
size={16}
style={{ flex: "none", color: "var(--mantine-color-dimmed)" }}
/>
<Text size="sm" lh={1.3} c="dimmed">
{text}
</Text>
</Group>
</Alert>
);
}

View File

@@ -1,18 +1,33 @@
import { useCallback, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
} from "react";
import { generateId } from "ai";
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import {
roleLaunchMessage,
shouldResetRolePicked,
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import {
dequeue,
enqueueMessage,
@@ -48,9 +63,23 @@ interface ChatThreadProps {
/** 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
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
/** Called when a turn finishes; the parent refreshes the chat list and, for a
* new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
/** Parent-owned ref that this thread keeps updated with its live useChat
* snapshot (full message list + streaming flag), so the header's
* "Copy chat" export can include the in-progress, not-yet-persisted
* assistant message. A ref (not state) avoids re-rendering the parent on
* every streamed delta. */
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
}
/**
@@ -65,13 +94,18 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
? row.metadata.parts
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
const error = row.metadata?.error;
const finishReason = row.metadata?.finishReason;
const metadata: Record<string, unknown> = {};
if (error) metadata.error = error;
if (finishReason) metadata.finishReason = finishReason;
return {
id: row.id,
role,
parts,
// Carry a persisted turn error so MessageItem can render it after a remount
// (e.g. when a new chat adopts its id) and in reopened chat history.
...(error ? { metadata: { error } } : {}),
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
// can render the error banner / "stopped" marker after a remount and in
// reopened history.
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
} as UIMessage;
}
@@ -89,6 +123,8 @@ export default function ChatThread({
onRolePicked,
assistantName,
onTurnFinished,
liveStateRef,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -225,16 +261,27 @@ export default function ChatThread({
// sending after the user hit Stop — or blindly retrying after a failure —
// would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design.
onTurnFinished(extractServerChatId(message));
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
if (isAbort || isDisconnect || isError) return;
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.
// banner via `error` below. We still call `onTurnFinished()` with NO server id
// (idempotent with the onFinish call): for a brand-new chat that ARMS the
// bounded list-refetch fallback (adopt the single newly-appeared chat once the
// refetch lands); for an existing chat it just refreshes the chat list
// immediately rather than after a manual refresh.
onError: (streamError) => {
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
@@ -246,23 +293,127 @@ export default function ChatThread({
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// Live "turn was interrupted" marker for the CURRENT session. The red error
// banner (driven by `error`) covers the error case; this covers an aborted
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
// (`isDisconnect`) — a distinction only available live (the server persists
// both as finishReason 'aborted'). Cleared when the next turn starts.
const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
null,
);
const isStreaming = status === "submitted" || status === "streaming";
// Clear the stopped marker as soon as a new turn begins streaming.
useEffect(() => {
if (isStreaming) setStopNotice(null);
}, [isStreaming]);
// Mirror the live useChat snapshot into the parent-owned ref so the export
// (handled in AiChatWindow) can include the in-progress streaming turn. The
// cleanup clears the ref on unmount so a thread torn down by `key` on chat
// switch can't leak its (possibly still-streaming) tail into the next chat's
// export before the new thread's effect repopulates the ref.
useEffect(() => {
if (!liveStateRef) return;
liveStateRef.current = { messages, isStreaming };
return () => {
liveStateRef.current = { messages: [], isStreaming: false };
};
}, [liveStateRef, messages, isStreaming]);
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while
// streaming, authoritative once a step reports usage) is the live figure. When
// the turn ends we emit a final exact value, then `null` so the parent reverts
// the badge to the persisted context size.
const lastEmitRef = useRef(0);
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!onLiveTurnTokens) return;
if (!isStreaming) {
// Turn ended (or never started): clear any pending throttle and revert.
if (emitTimerRef.current) {
clearTimeout(emitTimerRef.current);
emitTimerRef.current = null;
}
lastEmitRef.current = 0;
onLiveTurnTokens(null);
return;
}
const tail = messages[messages.length - 1];
const live =
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0;
const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz)
const elapsed = now - lastEmitRef.current;
if (elapsed >= MIN_INTERVAL) {
lastEmitRef.current = now;
onLiveTurnTokens(total);
} else if (!emitTimerRef.current) {
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
emitTimerRef.current = setTimeout(() => {
emitTimerRef.current = null;
lastEmitRef.current = Date.now();
onLiveTurnTokens(total);
}, MIN_INTERVAL - elapsed);
}
}, [messages, isStreaming, onLiveTurnTokens]);
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
// trailing emit can't fire into a torn-down thread's parent.
useEffect(() => {
return () => {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
};
}, []);
// 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.
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)
// so the user can type the first message themselves. roleIdRef is already set,
// so that first manual message carries the roleId.
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
// Clicking a role card always binds the role to THIS new chat. Whether it also
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
// synchronously here because the parent's selectedRoleId state update would
// only reach roleIdRef on the next render — after this synchronous sendMessage
// has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
const launch = roleLaunchMessage(
role,
t("Take a look at the current document"),
);
if (launch !== null) {
sendMessage({ text: launch });
} else {
// autoStart=false -> bind only: hide the cards, show the composer.
setRolePickedNoSend(true);
}
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
// chatId null, so the thread never remounts and the flag would stay set, hiding
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
// change"): one-shot — it re-renders with the flag false and the guard no longer
// matches, so it cannot loop. (Review of #149.)
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
setRolePickedNoSend(false);
}
const showRoleCards =
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
@@ -276,17 +427,22 @@ export default function ChatThread({
assistantName={assistantName}
/>
{errorView && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mb="xs"
{errorView ? (
<ChatErrorAlert
title={errorView.title}
>
{errorView.detail}
</Alert>
)}
detail={errorView.detail}
mb="xs"
/>
) : stopNotice ? (
<ChatStoppedNotice
text={
stopNotice === "manual"
? t("Response stopped.")
: t("Connection lost — the answer was interrupted.")
}
mb="xs"
/>
) : null}
<Stack gap={0} className={classes.inputWrapper}>
{queued.length > 0 && (

View File

@@ -1,11 +1,15 @@
import { Alert, Box, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -65,12 +69,41 @@ export default function MessageItem({
);
}
// An assistant message with nothing visible to render yet (an empty streaming
// text part, or a reasoning/step-start part while the model is still thinking)
// renders nothing here. The standalone TypingIndicator stands in for the nascent
// bubble (name + dots) until real content arrives, so exactly one element owns
// the agent name during the pre-content gap and the layout never jumps. Persisted
// errored/aborted turns DO have visible content per the helper (metadata.error /
// finishReason === "aborted"), so their banners below still render — this early
// return won't fire for them.
if (!assistantMessageHasVisibleContent(message)) return null;
// Authoritative reasoning token count to attribute to a reasoning block, or
// undefined when the block must estimate on its own. See reasoningTokensForPart
// for the #151 anti-double-count rule (only a single reasoning part may carry
// the turn total). The authoritative turn total is still surfaced live in the
// header badge regardless.
const reasoningTokens = reasoningTokensForPart(message);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{resolveAssistantName(assistantName) ?? t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "reasoning") {
// Reasoning ("thinking") -> a collapsible block with its own token
// count. Empty/whitespace reasoning with no authoritative count carries
// nothing to show, so skip it (avoids an empty 0-token block).
const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
return (
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
);
}
if (part.type === "text") {
// Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the
@@ -118,15 +151,27 @@ export default function MessageItem({
// cause plus a one-line detail.
const errorView = describeChatError(errorText, t);
return (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mt={4}
<ChatErrorAlert
title={errorView.title}
>
{errorView.detail}
</Alert>
detail={errorView.detail}
mt={4}
/>
);
})()}
{/* A persisted turn that was aborted (manual Stop or a dropped connection)
with no error banner. The server cannot tell a manual Stop from a
connection drop (both persist as finishReason 'aborted'), so reopened
history uses a combined wording. */}
{(() => {
const meta = message.metadata as
| { error?: string; finishReason?: string }
| undefined;
if (meta?.error || meta?.finishReason !== "aborted") return null;
return (
<ChatStoppedNotice
text={t("Response stopped (manually or the connection dropped).")}
mt={4}
/>
);
})()}
</Box>

View File

@@ -5,6 +5,8 @@ import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -77,6 +79,35 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
return true;
}
/**
* Whether the standalone typing indicator should render its own assistant-name
* label. The indicator OWNS the name while the tail assistant row has no visible
* content yet (an empty streaming text part, or reasoning/step-start while the
* model is still thinking): in that gap the assistant MessageItem renders nothing,
* so the indicator stands in for the nascent bubble (name + dots) at a constant
* gap. It hides the name only once that row shows visible content, because then
* MessageItem draws the same name — avoids a duplicate stacked label and the
* layout jump that switching owners mid-stream used to cause.
*/
export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return true;
return !assistantMessageHasVisibleContent(last);
}
/**
* The live thinking-token count to show on the standalone typing indicator. It
* is the reasoning split of the tail assistant message (estimate while streaming,
* authoritative once the server attaches usage at a step/turn boundary). Returns
* 0 when the turn has produced no reasoning yet — the indicator then shows the
* plain "Thinking…" line.
*/
export function tailThinkingTokens(messages: UIMessage[]): number {
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return 0;
return liveTurnTokens(last).reasoning;
}
/**
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
* but only while the user is pinned to the bottom — if they scrolled up to read
@@ -173,7 +204,13 @@ export default function MessageList({
assistantName={assistantName}
/>
))}
{typing && <TypingIndicator assistantName={assistantName} />}
{typing && (
<TypingIndicator
assistantName={assistantName}
showName={typingIndicatorShowsName(messages)}
thinkingTokens={tailThinkingTokens(messages)}
/>
)}
</Stack>
</ScrollArea>
);

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps the assertions on the component's OWN count logic (authoritative vs
// estimate) rather than on translation, and mirrors the t-mock pattern used by
// other component tests in the repo.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts && typeof opts.count === "number"
? key.replace("{{count}}", String(opts.count))
: key,
}),
}));
import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBlock(props: { text: string; tokens?: number }) {
return render(
<MantineProvider>
<ReasoningBlock {...props} />
</MantineProvider>,
);
}
describe("ReasoningBlock", () => {
it("shows the authoritative count in the header when tokens > 0", () => {
// Text "thinking…" estimates to ceil(9/4) = 3, but the authoritative 42
// must win, so the header shows 42 (and NOT the 3-token estimate).
renderBlock({ text: "thinking…", tokens: 42 });
expect(screen.getByText("Thinking · 42 tokens")).toBeDefined();
expect(screen.queryByText("Thinking · 3 tokens")).toBeNull();
});
it("falls back to the text-length estimate when no authoritative tokens", () => {
const text = "some reasoning prose that streams in";
const estimate = estimateTokens(text);
renderBlock({ text });
expect(estimate).toBeGreaterThan(0);
expect(screen.getByText(new RegExp(`${estimate} tokens`))).toBeDefined();
});
it("header-only when text is empty but an authoritative count is present", () => {
renderBlock({ text: "", tokens: 17 });
expect(screen.getByText(/17 tokens/)).toBeDefined();
// No disclosure body to expand: the toggle button is disabled.
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(true);
});
it("renders the reasoning body (markdown or raw-text fallback)", () => {
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// The toggle is enabled because there IS body text to expand.
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(false);
// The body prose renders (markdown -> sanitized html, or raw-text fallback);
// either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined();
});
});

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ReasoningBlockProps {
/** The streamed/persisted reasoning (thinking) text. May be empty when the
* provider reports only a reasoning token COUNT without the text. */
text: string;
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
* step/turn has finished. When absent (or 0) the count is estimated from the
* text length so it ticks live as the reasoning streams in. */
tokens?: number;
}
/**
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
* Code's surfacing of the model's thinking: a header that shows the thinking
* token count (authoritative when the step has reported usage, else a live
* estimate from the streamed text) and an expandable body with the reasoning
* prose. Collapsed by default so it never crowds out the answer.
*
* Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible.
*/
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";
return (
<Box className={classes.reasoningBlock} mb={6}>
<UnstyledButton
onClick={() => setOpen((o) => !o)}
// No body to expand when the provider reported only a token count.
disabled={!trimmed}
aria-expanded={open}
>
<Group gap={6} wrap="nowrap" align="center">
<IconChevronDown
size={12}
style={{
transform: open ? "none" : "rotate(-90deg)",
transition: "transform 150ms ease",
opacity: trimmed ? 1 : 0.4,
}}
/>
<Text size="xs" c="dimmed">
{count > 0
? t("Thinking · {{count}} tokens", { count })
: t("Thinking")}
</Text>
</Group>
</UnstyledButton>
{trimmed && (
<Collapse in={open}>
{html ? (
<div
className={classes.reasoningText}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<Text
className={classes.reasoningText}
style={{ whiteSpace: "pre-wrap" }}
>
{trimmed}
</Text>
)}
</Collapse>
)}
</Box>
);
}

View File

@@ -1,26 +1,10 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { 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(),
}),
});
});
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const roles: IAiRole[] = [
{
@@ -29,6 +13,8 @@ const roles: IAiRole[] = [
emoji: "🏴‍☠️",
description: "Talks like a pirate",
enabled: true,
autoStart: true,
launchMessage: null,
},
{
id: "r2",
@@ -36,6 +22,8 @@ const roles: IAiRole[] = [
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
},
];

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { tailThinkingTokens } from "@/features/ai-chat/components/message-list.tsx";
/**
* Pure-helper tests for `tailThinkingTokens`: the live thinking-token count the
* standalone typing indicator shows. It is the reasoning split of the tail
* assistant message (estimate while streaming, authoritative once usage arrives).
*/
const msg = (
role: "user" | "assistant",
parts: unknown[],
metadata?: unknown,
): UIMessage =>
({ id: Math.random().toString(), role, parts, metadata }) as UIMessage;
describe("tailThinkingTokens", () => {
it("is 0 when there are no messages", () => {
expect(tailThinkingTokens([])).toBe(0);
});
it("is 0 when the tail message is the user's", () => {
expect(tailThinkingTokens([msg("user", [{ type: "text", text: "q" }])])).toBe(0);
});
it("is 0 when the assistant has produced no reasoning yet", () => {
expect(
tailThinkingTokens([msg("assistant", [{ type: "text", text: "answer" }])]),
).toBe(0);
});
it("estimates reasoning tokens from streamed reasoning text", () => {
// 8 chars -> 2 tokens.
expect(
tailThinkingTokens([
msg("assistant", [{ type: "reasoning", text: "12345678" }]),
]),
).toBe(2);
});
it("uses authoritative usage.reasoningTokens once the server attaches it", () => {
expect(
tailThinkingTokens([
msg("assistant", [{ type: "reasoning", text: "x" }], {
usage: { outputTokens: 100, reasoningTokens: 42 },
}),
]),
).toBe(42);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { typingIndicatorShowsName } from "@/features/ai-chat/components/message-list.tsx";
/**
* Pure-helper tests for whether the standalone "Thinking…" indicator renders its
* own dimmed assistant-name label. The indicator OWNS the name while the tail
* assistant row has no visible content yet (an empty streaming text part, or
* reasoning/step-start while the model is still thinking) — in that gap the
* assistant MessageItem renders nothing, so the indicator stands in for the
* nascent bubble (name + dots). It hides the name only once the tail assistant
* row shows visible content, because then MessageItem draws the same name — this
* avoids a duplicate stacked label and the layout jump that switching owners
* mid-stream used to cause.
*/
const msg = (
role: "user" | "assistant",
parts: UIMessage["parts"],
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
describe("typingIndicatorShowsName", () => {
it("shows the name with no messages yet (standalone, just submitted)", () => {
expect(typingIndicatorShowsName([])).toBe(true);
});
it("shows the name when the last message is still the user's", () => {
expect(
typingIndicatorShowsName([msg("user", [{ type: "text", text: "q" }])]),
).toBe(true);
});
it("shows the name when the tail assistant row has no visible content yet (empty text part)", () => {
// The empty streaming text part has no visible content, so MessageItem renders
// nothing and the indicator owns the name (the nascent bubble).
expect(
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "" }])]),
).toBe(true);
});
it("hides the name once the tail assistant row shows content (a tool part)", () => {
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(
typingIndicatorShowsName([msg("assistant", [doneTool])]),
).toBe(false);
});
it("hides the name once the tail assistant row shows content (non-empty text)", () => {
expect(
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "answer" }])]),
).toBe(false);
});
});

View File

@@ -10,6 +10,18 @@ interface TypingIndicatorProps {
* (agent role) name.
*/
assistantName?: string;
/**
* Whether to render the dimmed assistant-name label. Defaults to true
* (standalone behavior preserved). Set false between agent steps where the
* assistant row above already shows the same name, to avoid a duplicate label.
*/
showName?: boolean;
/**
* Live thinking/reasoning token count for the in-flight turn. When > 0 the
* typing line becomes `Thinking… · {count} tokens` (like Claude Code). Omitted
* / 0 keeps the plain `Thinking…` line.
*/
thinkingTokens?: number;
}
/**
@@ -24,15 +36,22 @@ interface TypingIndicatorProps {
* 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, showName = true, thinkingTokens }: TypingIndicatorProps) {
const { t } = useTranslation();
const name = resolveAssistantName(assistantName);
// Show the running thinking-token count only once there is something to count.
const thinkingLine =
thinkingTokens && thinkingTokens > 0
? t("Thinking… · {{count}} tokens", { count: thinkingTokens })
: t("Thinking…");
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
</Text>
{showName !== false && (
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
</Text>
)}
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">
<span />
@@ -40,7 +59,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
<span />
</span>
<Text size="sm" c="dimmed">
{t("Thinking…")}
{thinkingLine}
</Text>
</Group>
</Box>

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useChatSession } from "./use-chat-session";
import type { UseChatSessionOptions } from "./use-chat-session";
// The props the test drives: the parent-owned subset of UseChatSessionOptions
// (the spies are injected by setup, not per-render). messagesLoading is optional
// here (defaulted to false in setup) for terser test call sites.
type DriverProps = Pick<UseChatSessionOptions, "activeChatId" | "chats"> & {
messagesLoading?: boolean;
};
// Drive the hook the way the window does: the parent owns `activeChatId` and
// passes it back in. `setActiveChatId` is a spy so we can assert the EXACT id the
// hook adopts (the #137 regression: it must be the authoritative streamed id, not
// the newest chat in the list).
function setup(initial: DriverProps) {
const setActiveChatId = vi.fn();
const onInvalidateChatList = vi.fn();
const onInvalidateChatMessages = vi.fn();
const { result, rerender } = renderHook(
(props: DriverProps) =>
useChatSession({
activeChatId: props.activeChatId,
setActiveChatId,
chats: props.chats,
messagesLoading: props.messagesLoading ?? false,
onInvalidateChatList,
onInvalidateChatMessages,
}),
{ initialProps: initial },
);
return {
result,
rerender,
setActiveChatId,
onInvalidateChatList,
onInvalidateChatMessages,
};
}
describe("useChatSession", () => {
beforeEach(() => vi.clearAllMocks());
it("#137 REGRESSION LOCK: adopts the authoritative streamed id, NOT items[0]", () => {
// Brand-new chat, list already holds a SIBLING chat B as items[0] (a second
// tab just created it). The server streams the real id "A" for THIS chat.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "B" }] },
});
result.current.onTurnFinished("A");
// Must adopt the authoritative id, not the newest-in-list guess.
expect(setActiveChatId).toHaveBeenCalledWith("A");
expect(setActiveChatId).not.toHaveBeenCalledWith("B");
});
it("fallback adopt: arms on a server-id-less finish, adopts the single new id after refetch", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
// No server id => arm the fallback (no adoption yet).
result.current.onTurnFinished(undefined);
expect(setActiveChatId).not.toHaveBeenCalled();
// The refetch lands with the new row => adopt it.
rerender({ activeChatId: null, chats: { items: [{ id: "x" }, { id: "new" }] } });
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
it("fallback ambiguous: two new ids appear => no adoption", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined);
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "n1" }, { id: "n2" }] },
});
expect(setActiveChatId).not.toHaveBeenCalled();
});
it("fallback add+delete in one window: adopts the new id (membership compare)", () => {
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "a" }, { id: "b" }] },
});
result.current.onTurnFinished(undefined);
// a was deleted, new was added — same length, but membership changed.
rerender({ activeChatId: null, chats: { items: [{ id: "b" }, { id: "new" }] } });
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
it("disarm on reconcile: a fallback armed then switched away is NOT adopted by a late refetch", () => {
// Arm the error-path fallback on a brand-new chat (snapshot before=["x"]).
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined);
// The user switches to an existing chat C BEFORE the refetch lands; the
// render-phase reconciler must DISARM the pending fallback.
rerender({ activeChatId: "C", chats: { items: [{ id: "x" }] } });
// ...then starts a fresh new chat again (back to null), without re-arming.
rerender({ activeChatId: null, chats: { items: [{ id: "x" }] } });
// A late refetch now brings a new row. Because the earlier fallback was
// disarmed on the switch (not left armed with the stale ["x"] snapshot), it
// must NOT be adopted. (Without the disarm this would wrongly adopt "new".)
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "new" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
});
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
// The Warning path the render-phase reconciler can't catch: pressing "New
// chat" while already in a new chat keeps activeChatId === null (a no-op for
// the atom), so only the explicit cancelPendingAdoption() disarms.
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
result.current.cancelPendingAdoption(); // window calls this from startNewChat
// The just-failed row lands in a late refetch; it must NOT be adopted.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "failed" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
});
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
const {
result,
setActiveChatId,
onInvalidateChatList,
onInvalidateChatMessages,
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
result.current.onTurnFinished("chat-1");
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
expect(onInvalidateChatList).toHaveBeenCalled();
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
});
it("double onTurnFinished on a failed-after-start turn: primary adopt, 2nd no-id call does NOT re-arm the fallback", () => {
// ai@6 fires onFinish AND onError on a failed turn. If the failure happened
// AFTER the `start` chunk, onFinish carries the streamed id and onError does
// not — so onTurnFinished runs twice in one turn (id, then no-id) before any
// re-render. The 2nd call must NOT re-arm the fallback off the still-null
// closure; otherwise a late refetch (parent hasn't reflected the adoption yet)
// would wrongly adopt a sibling row.
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished("A"); // onFinish: primary adoption
expect(setActiveChatId).toHaveBeenCalledWith("A");
result.current.onTurnFinished(undefined); // onError: same turn, no id
// Even in the worst case (the parent has NOT yet reflected activeChatId="A"
// and a late refetch lands a new row), the just-failed sibling must NOT be
// adopted. Two layers guarantee this: the ref guard keeps the 2nd call from
// re-arming at the source, and the render-phase reconciler disarms anything
// stale once thread.chatId ("A") diverges from the still-null activeChatId.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "late" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
});
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
const chats = { items: [{ id: "B" }] };
const { result, rerender } = setup({ activeChatId: null, chats });
const keyBefore = result.current.threadKey;
// Adopt the streamed id; the PARENT then reflects activeChatId="A" back in.
result.current.onTurnFinished("A");
rerender({ activeChatId: "A", chats });
// In-place adoption: SAME mount key (the live useChat store is preserved).
expect(result.current.threadKey).toBe(keyBefore);
// An EXTERNAL switch (not via adopt) to a different chat must remount: the
// key becomes the chat id.
rerender({ activeChatId: "C", chats });
expect(result.current.threadKey).toBe("C");
});
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
// Open an existing chat whose history is still loading => loader on.
const { result, rerender } = setup({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
messagesLoading: true,
});
expect(result.current.waitingForHistory).toBe(true);
// Once loading finishes, the latch flips and the loader is off.
rerender({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
messagesLoading: false,
});
expect(result.current.waitingForHistory).toBe(false);
});
});

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { generateId } from "ai";
import {
resolveAdoptedChatId,
newlyAddedChatIds,
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
import {
newThread,
switchThread,
threadSessionReducer,
} from "@/features/ai-chat/utils/thread-identity.ts";
/** Inputs to {@link useChatSession}. `activeChatId`/`setActiveChatId` are the
* public selection atom (also written from outside the window, e.g. page
* history); the rest is read-only context the hook needs. */
export interface UseChatSessionOptions {
activeChatId: string | null;
setActiveChatId: (id: string | null) => void;
chats: { items?: { id: string }[] } | undefined;
messagesLoading: boolean;
/** Wraps queryClient.invalidateQueries(AI_CHATS_RQ_KEY). */
onInvalidateChatList: () => void;
/** Wraps the per-chat messages invalidation. */
onInvalidateChatMessages: (chatId: string) => void;
}
/** What the window needs from a chat session: the ChatThread mount key, the
* history-loader gate, and the turn-finished callback. */
export interface UseChatSessionResult {
/** ChatThread mount key (was `thread.key`). */
threadKey: string;
/** Show the history loader instead of the live thread. */
waitingForHistory: boolean;
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
/** Disarm any pending error-path new-chat fallback. The window calls this from
* startNewChat/selectChat so a late refetch can't yank the user back into a
* just-failed chat after they explicitly moved on. */
cancelPendingAdoption: () => void;
}
/** Project a chat list to its id array (the before/after snapshot for the
* error-path fallback). */
function chatIdSnapshot(
chats: { items?: { id: string }[] } | undefined,
): string[] {
return chats?.items?.map((c) => c.id) ?? [];
}
/**
* Owns the AI-chat thread-identity lifecycle: the single atomic thread identity,
* both new-chat id adoption paths (primary streamed-metadata + bounded error-path
* fallback), the history-loaded latch, and the render-phase reconciler that keeps
* the thread's mount key in sync with the public `activeChatId` atom.
*
* This is the twice-bugged area for the #137 two-tab adoption race; the canonical
* explanation of the adoption design lives in adopt-chat-id.ts.
*/
export function useChatSession(
params: UseChatSessionOptions,
): UseChatSessionResult {
const {
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList,
onInvalidateChatMessages,
} = params;
// Live mirror of `activeChatId`, read by onTurnFinished. ai@6 fires both
// onFinish AND onError on a failed turn, so onTurnFinished can run twice in one
// turn (once with the streamed id, once without) BEFORE a re-render. Reading
// the ref — which the primary-adoption branch updates imperatively — makes that
// second call see the just-adopted id, so it cannot re-arm the fallback. (A
// plain closure over `activeChatId` would still read null on the second call.)
const activeChatIdRef = useRef(activeChatId);
activeChatIdRef.current = activeChatId;
// The mounted thread's identity: ONE atomic value tying ChatThread's mount key
// (`thread.key`) to the chat id that mounted thread holds (`thread.chatId`).
// Consolidating these makes the "key vs chat id diverged" state unrepresentable
// — every change goes through an explicit transition (see thread-identity.ts):
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
// session key with no chat id yet.
const [thread, dispatch] = useReducer(
threadSessionReducer,
undefined,
() =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
);
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
// reaches the client, so the primary metadata adoption cannot run. We then ARM
// this ref with a snapshot of the currently-known chat ids; once the list
// refetch lands with the just-created row, the fallback effect below adopts the
// SINGLE newly-appeared id. `null` = not armed. See adopt-chat-id.ts (#137).
const pendingNewChatRef = useRef<string[] | null>(null);
// Latch: the chat id whose full persisted history has finished loading while
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
// messages invalidation) never tears the live thread back down to the loader.
const historyLoadedKeyRef = useRef<string | null>(null);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
// list, which races a second tab — #137; see adopt-chat-id.ts).
const onTurnFinished = useCallback(
(serverChatId?: string) => {
// Read the live id from the ref, not the closure: on a failed turn this can
// run twice in one turn (onFinish + onError) before any re-render, and the
// primary branch below updates the ref so the second call sees the adopted id.
const current = activeChatIdRef.current;
const adopted = resolveAdoptedChatId(current, serverChatId);
if (adopted) {
// PRIMARY path. In-place adoption: set the public selection and the
// thread identity to the real id together. `adopt` keeps the SAME mount
// key, so the render-phase reconciler sees `activeChatId === thread.chatId`
// and keeps the SAME mounted thread (its useChat already holds the
// just-finished turn) instead of remounting + re-seeding from
// not-yet-persisted history.
activeChatIdRef.current = adopted; // a same-turn 2nd call now sees the id
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
// Primary adoption won — disarm any previously-armed fallback.
pendingNewChatRef.current = null;
} else if (current === null) {
// FALLBACK path: a brand-new chat finished with NO server id (the first
// turn errored before the `start` chunk). Arm the bounded list-refetch
// fallback by snapshotting the currently-known chat ids. `chats` is still
// the pre-refetch list here, so the just-created row is NOT yet in it; the
// effect below adopts the single id that newly appears after the refetch.
pendingNewChatRef.current = chatIdSnapshot(chats);
}
onInvalidateChatList();
// Re-sync the persisted message rows for the active chat so the Markdown
// export and token counters reflect the just-finished turn. The live thread
// renders from its own useChat store (stable thread.key), so this never
// re-seeds or tears down the open thread. For a brand-new chat `current` is
// still null here; later turns hit this with the adopted id.
if (current) {
onInvalidateChatMessages(current);
}
},
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
);
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
// turn errored before the `start` chunk (no authoritative id streamed). Once
// the per-user list refetch lands with the just-created row, adopt the SINGLE
// id that newly appeared relative to the pre-refetch snapshot. Adoption is IN
// PLACE (set activeChatId + `adopt` together) like the primary path, so the
// render-phase reconciler does not remount.
useEffect(() => {
const before = pendingNewChatRef.current;
if (before === null || activeChatId !== null) return; // not armed / already adopted
const after = chatIdSnapshot(chats);
const added = newlyAddedChatIds(before, after);
// Keep waiting until a genuinely-new id appears. Set-based, so it is robust
// to an add+delete in the same window (a length compare would miss it), and
// it deliberately keeps waiting through an unrelated deletion (no new id yet)
// until the just-created row actually lands, rather than giving up early.
if (added.size === 0) return; // list not refetched yet — keep waiting
pendingNewChatRef.current = null; // resolved — disarm
if (added.size === 1) {
// single unambiguous new id; >1 = ambiguous → give up
const adopted = [...added][0];
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
}
}, [chats, activeChatId, setActiveChatId]);
// Reconcile the thread identity against the active-chat atom during render when
// they diverge — the React-sanctioned alternative to an effect (re-renders
// before paint, no extra commit, and converges since the next render finds them
// equal). This reconciliation MUST remain: `activeChatId` is the public
// selection and is ALSO set from OUTSIDE this component (e.g. page-history opens
// a referenced chat via setActiveChatId). A divergence here is a genuine SWITCH
// (external atom change OR user switch via selectChat/startNewChat), so
// `reconcile` remounts + reseeds. In-place adoption never reaches this branch:
// it set activeChatId and thread.chatId to the same value.
if (activeChatId !== thread.chatId) {
// A genuine switch makes any pending error-path new-chat fallback moot.
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: activeChatId,
newKey: `new-${generateId()}`,
});
}
// Latch the active chat once its full history has loaded and its thread is
// mounted, so a later background refetch (the post-turn messages invalidation,
// which can transiently flip hasNextPage for a chat whose message count is an
// exact multiple of the server page size) does not tear the live thread down to
// a loader and lose its in-progress useChat state.
if (
activeChatId !== null &&
thread.key === activeChatId &&
!messagesLoading &&
historyLoadedKeyRef.current !== activeChatId
) {
historyLoadedKeyRef.current = activeChatId;
}
// Show the history loader only when freshly OPENING an existing chat (the key
// equals the chat id) whose history has not been fully loaded yet. For a live
// in-place thread that adopted its id, the key is still the "new-…" session
// key, so the live thread keeps rendering; and once a chat's history has loaded,
// a later background refetch no longer tears it down (see the latch above).
const waitingForHistory =
activeChatId !== null &&
messagesLoading &&
thread.key === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
// Explicit disarm for startNewChat/selectChat. The render-phase reconciler only
// disarms when activeChatId actually changes, but "New chat" pressed while the
// user is ALREADY in a new chat is a no-op for the atom (activeChatId stays
// null), so the reconciler never fires — without this an armed fallback could
// adopt the just-failed chat from a late refetch and yank the user out of their
// fresh chat. Stable identity (writes a ref).
const cancelPendingAdoption = useCallback(() => {
pendingNewChatRef.current = null;
}, []);
return {
threadKey: thread.key,
waitingForHistory,
onTurnFinished,
cancelPendingAdoption,
};
}

View File

@@ -53,6 +53,10 @@ export interface IAiRole {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled: boolean;
// Whether picking the role auto-sends a launch message and starts the chat.
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
createdAt?: string;
updatedAt?: string;
}
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/** Admin update payload for a role (partial). */
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/**
@@ -98,6 +106,10 @@ export interface IAiChatMessageRow {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
// Reasoning (thinking) tokens, when the provider reports them. Optional so
// old history rows (recorded before this shipped) stay valid. Included in
// `outputTokens` per the AI SDK usage shape.
reasoningTokens?: number;
};
// Current context size for the turn = final-step (input+output) tokens, i.e.
// how much the conversation occupies in the model's context window after this
@@ -107,6 +119,11 @@ export interface IAiChatMessageRow {
// Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread.
error?: string;
// Terminal outcome of the assistant turn: 'error' (provider/stream error,
// paired with `error`), 'aborted' (client disconnect — a manual Stop or a
// dropped connection), or the SDK's finish reason on a clean turn. The UI
// renders a "stopped" marker on interrupted turns.
finishReason?: string;
} | null;
createdAt: string;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import {
resolveAdoptedChatId,
newlyAddedChatIds,
extractServerChatId,
} from "./adopt-chat-id";
describe("resolveAdoptedChatId", () => {
it("adopts the server id for a brand-new chat (activeChatId null + id)", () => {
expect(resolveAdoptedChatId(null, "chat-1")).toBe("chat-1");
});
it("returns null for an existing chat even with a server id", () => {
expect(resolveAdoptedChatId("chat-existing", "chat-1")).toBeNull();
});
it("returns null for a new chat with no server id", () => {
expect(resolveAdoptedChatId(null, undefined)).toBeNull();
expect(resolveAdoptedChatId(null, null)).toBeNull();
});
});
describe("newlyAddedChatIds", () => {
it("returns the single new id", () => {
expect([...newlyAddedChatIds(["a", "b"], ["a", "b", "c"])]).toEqual(["c"]);
});
it("returns an empty set when nothing was added", () => {
expect(newlyAddedChatIds(["a", "b"], ["b", "a"]).size).toBe(0);
});
it("returns both new ids when two were added", () => {
expect(newlyAddedChatIds(["a"], ["a", "b", "c"])).toEqual(
new Set(["b", "c"]),
);
});
it("keeps only the new id across an add+delete in the same window", () => {
// before [a,b] -> after [b,new]: a was deleted, new was added.
expect([...newlyAddedChatIds(["a", "b"], ["b", "new"])]).toEqual(["new"]);
});
it("dedupes a repeated new id to a single entry", () => {
expect(newlyAddedChatIds(["a"], ["a", "new", "new"])).toEqual(
new Set(["new"]),
);
});
});
describe("extractServerChatId", () => {
it("returns the chatId when present on metadata", () => {
expect(extractServerChatId({ metadata: { chatId: "chat-1" } })).toBe(
"chat-1",
);
});
it("returns undefined when the message has no metadata", () => {
expect(extractServerChatId({})).toBeUndefined();
});
it("returns undefined when metadata lacks chatId", () => {
expect(extractServerChatId({ metadata: { other: 1 } })).toBeUndefined();
});
it("returns undefined for a non-string chatId", () => {
expect(extractServerChatId({ metadata: { chatId: 42 } })).toBeUndefined();
});
it("returns undefined for an undefined message", () => {
expect(extractServerChatId(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,70 @@
/**
* Pure helpers for adopting a brand-new chat's authoritative server id.
*
* ============================ CANONICAL #137 NOTE ============================
* This docblock is the single authoritative explanation of the new-chat id
* adoption design and the #137 two-tab race it fixes. Other call sites
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
* rather than restating it.
*
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
* id yet (`activeChatId === null`). The server creates the row and the client
* must "adopt" that row's real id so the SECOND turn targets the same chat.
*
* The OLD heuristic adopted `items[0]` — the newest chat in the refetched list.
* That races a second tab: if another tab created a chat in the same moment,
* its row could be `items[0]`, so this tab would adopt the SIBLING chat and
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
*
* PRIMARY path: the server streams the real chat id on the assistant message
* metadata's `start` part (see `chatStreamMetadata` server-side);
* `extractServerChatId` reads it off the finished message and
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
* authoritative and immune to the race.
*
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
* chunk, so no metadata id ever reached the client): adopt the single chat that
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
* new). This is unambiguous and does not race a second tab the way the old
* "newest chat in the list" guess did.
* ============================================================================
*/
/**
* Resolve the id to adopt from the server-streamed metadata. Returns
* `serverChatId` only for a brand-new chat (`activeChatId === null`) that
* received a truthy id; otherwise null (existing chat, or no id streamed).
*/
export function resolveAdoptedChatId(
activeChatId: string | null,
serverChatId: string | null | undefined,
): string | null {
return activeChatId === null && serverChatId ? serverChatId : null;
}
/**
* Read the authoritative server chat id off a finished assistant message. The
* server attaches it as `message.metadata.chatId` on the `start` part (see
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
* a missing message, missing metadata, or a non-string `chatId`.
*/
export function extractServerChatId(
message: { metadata?: unknown } | undefined,
): string | undefined {
const m = message?.metadata as { chatId?: string } | undefined;
return typeof m?.chatId === "string" ? m.chatId : undefined;
}
/**
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
* chat must not read as multiple from a duplicate.
*/
export function newlyAddedChatIds(
beforeIds: readonly string[],
afterIds: readonly string[],
): Set<string> {
const before = new Set(beforeIds);
return new Set(afterIds.filter((id) => !before.has(id)));
}

View File

@@ -314,4 +314,178 @@ describe("buildChatMarkdown — token totals", () => {
});
expect(md).toContain("- Total tokens: 99");
});
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
},
}),
],
t,
});
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
});
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
const zero = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
},
}),
],
t,
});
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
expect(zero).not.toContain("reasoning:");
const absent = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(absent).not.toContain("reasoning:");
});
});
describe("buildChatMarkdown — pending / in-progress messages", () => {
it("continues the heading numbering after the persisted rows", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "user",
parts: [{ type: "text", text: "live question" }],
generating: false,
},
{
role: "assistant",
parts: [{ type: "text", text: "live answer" }],
generating: true,
},
],
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. You");
expect(md).toContain("## 3. AI agent");
expect(md).toContain("live question");
expect(md).toContain("live answer");
});
it("flags a generating assistant pending message as still being generated", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "assistant",
parts: [{ type: "text", text: "partial reply" }],
generating: true,
},
],
t,
});
expect(md).toContain("partial reply");
expect(md).toContain("still being generated");
});
it("renders a non-generating user pending message without the note", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "user",
parts: [{ type: "text", text: "my live message" }],
generating: false,
},
],
t,
});
expect(md).toContain("my live message");
expect(md).not.toContain("still being generated");
});
it("includes the pending messages in the metadata message count", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "a" }),
row({ role: "assistant", content: "b" }),
],
pending: [
{
role: "user",
parts: [{ type: "text", text: "c" }],
generating: false,
},
{
role: "assistant",
parts: [{ type: "text", text: "d" }],
generating: true,
},
],
t,
});
// 2 persisted rows + 2 pending = 4.
expect(md).toContain("- Messages: 4");
});
it("emits the heading and note for a generating assistant with empty parts", () => {
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "assistant",
parts: [],
generating: true,
},
],
t,
}),
).not.toThrow();
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "assistant",
parts: [],
generating: true,
},
],
t,
});
expect(md).toContain("## 2. AI agent");
expect(md).toContain("still being generated");
});
});

View File

@@ -26,6 +26,10 @@ interface BuildChatMarkdownArgs {
title: string | null;
chatId: string;
rows: IAiChatMessageRow[];
/** In-progress, not-yet-persisted live messages (the current streaming
* turn) to append after the persisted rows. `generating: true` adds a
* note that the message is still being produced. */
pending?: PendingMessage[];
t: Translate;
}
@@ -35,6 +39,13 @@ interface TextLikePart {
text?: string;
}
/** A live, not-yet-persisted message (current streaming turn) to append. */
interface PendingMessage {
role: "user" | "assistant" | string;
parts: TextLikePart[];
generating: boolean;
}
/**
* Stringify an arbitrary tool input/output value for a fenced block. Strings
* pass through as-is; everything else is pretty-printed JSON, falling back to
@@ -66,18 +77,62 @@ function rowTokens(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
);
}
/** Render one message's UIMessage parts into an array of Markdown blocks
* (text blocks + tool blocks). Mirrors MessageItem's part handling. */
function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
const out: string[] = [];
for (const part of parts) {
if (part.type === "text") {
const text = (part.text ?? "").trim();
// Skip empty/whitespace-only text parts (matches MessageItem).
if (text.length > 0) out.push(text);
continue;
}
const isToolPart =
part.type.startsWith("tool-") || part.type === "dynamic-tool";
if (!isToolPart) continue;
const tp = part as unknown as ToolUiPart;
const name = getToolName(tp);
const { key, values } = toolLabelKey(name);
const label = t(key, values);
const state = toolRunState(tp.state);
const toolLines: string[] = [
`**Tool: ${label}** (\`${name}\`) — ${state}`,
];
if (tp.input !== undefined) {
toolLines.push("Input:");
toolLines.push(fence(stringify(tp.input), "json"));
}
if (tp.output !== undefined) {
toolLines.push("Output:");
toolLines.push(fence(stringify(tp.output), "json"));
}
if (tp.errorText) {
toolLines.push(`**Error:** ${tp.errorText}`);
}
out.push(toolLines.join("\n\n"));
}
return out;
}
/**
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
* export timestamp), so it is straightforward to unit-test.
*/
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const { title, chatId, rows, t } = args;
const { title, chatId, rows, pending, t } = args;
const blocks: string[] = [];
const heading = (title ?? "").trim() || t("Untitled chat");
@@ -91,7 +146,7 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const meta = [
`- Chat ID: \`${chatId}\``,
`- Exported: ${new Date().toISOString()}`,
`- Messages: ${rows.length}`,
`- Messages: ${rows.length + (pending?.length ?? 0)}`,
];
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
blocks.push(meta.join("\n"));
@@ -112,40 +167,7 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
? (row.metadata.parts as TextLikePart[])
: [{ type: "text", text: row.content ?? "" }];
for (const part of parts) {
if (part.type === "text") {
const text = (part.text ?? "").trim();
// Skip empty/whitespace-only text parts (matches MessageItem).
if (text.length > 0) blocks.push(text);
continue;
}
const isToolPart =
part.type.startsWith("tool-") || part.type === "dynamic-tool";
if (!isToolPart) continue;
const tp = part as unknown as ToolUiPart;
const name = getToolName(tp);
const { key, values } = toolLabelKey(name);
const label = t(key, values);
const state = toolRunState(tp.state);
const toolLines: string[] = [
`**Tool: ${label}** (\`${name}\`) — ${state}`,
];
if (tp.input !== undefined) {
toolLines.push("Input:");
toolLines.push(fence(stringify(tp.input), "json"));
}
if (tp.output !== undefined) {
toolLines.push("Output:");
toolLines.push(fence(stringify(tp.output), "json"));
}
if (tp.errorText) {
toolLines.push(`**Error:** ${tp.errorText}`);
}
blocks.push(toolLines.join("\n\n"));
}
blocks.push(...renderMessageParts(parts, t));
if (row.metadata?.error) {
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
@@ -154,8 +176,36 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const usage = row.metadata?.usage;
if (usage) {
const total = usage.totalTokens ?? rowTokens(usage);
// Reasoning (thinking) tokens are shown only when the provider reported a
// positive count; old rows / non-reasoning providers omit it.
const reasoning =
usage.reasoningTokens && usage.reasoningTokens > 0
? `, reasoning: ${usage.reasoningTokens}`
: "";
blocks.push(
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
);
}
});
// Append the in-progress, not-yet-persisted live messages (the current
// streaming turn) after the persisted rows. Heading numbering CONTINUES from
// the persisted rows. A `generating` assistant gets a note that the captured
// response is partial; pending messages carry no usage/token footer yet.
(pending ?? []).forEach((message, p) => {
blocks.push("---");
const num = rows.length + p + 1;
const roleLabel = message.role === "assistant" ? t("AI agent") : t("You");
blocks.push(`## ${num}. ${roleLabel}`);
blocks.push(...renderMessageParts(message.parts, t));
// A generating assistant may have empty/no parts yet — still emit the
// heading (above) and this note so the export shows the in-progress turn.
if (message.generating === true) {
blocks.push(
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
);
}
});

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
/**
* Pure-helper tests for `assistantMessageHasVisibleContent`, the single source of
* truth shared by MessageItem (whether to render the bubble) and
* typingIndicatorShowsName (whether the standalone indicator owns the name). It
* must mirror MessageItem's render decisions exactly so exactly one element owns
* the agent name during the pre-content "thinking" gap.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("assistantMessageHasVisibleContent", () => {
it("is false for an empty text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }]))).toBe(false);
});
it("is false for a whitespace-only text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: " " }]))).toBe(false);
});
it("is true for a non-empty text part", () => {
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "answer" }]))).toBe(true);
});
it("is true for a tool part", () => {
const toolPart = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([toolPart]))).toBe(true);
});
it("is true when metadata.error is set (persisted error banner)", () => {
expect(
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { error: "boom" })),
).toBe(true);
});
it("is true when metadata.finishReason is 'aborted' (persisted stopped notice)", () => {
expect(
assistantMessageHasVisibleContent(msg([], { finishReason: "aborted" })),
).toBe(true);
});
it("is false for a message with no parts and no metadata", () => {
expect(assistantMessageHasVisibleContent(msg([]))).toBe(false);
});
it("is false for an unsupported part kind (reasoning)", () => {
const reasoning = { type: "reasoning", text: "let me think" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([reasoning]))).toBe(false);
});
it("is true for a running tool part (input-available)", () => {
// Tool visibility does not depend on tool state: MessageItem renders a
// ToolCallCard for any tool part, so a still-running tool is visible.
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
expect(assistantMessageHasVisibleContent(msg([runningTool]))).toBe(true);
});
it("is true for an empty leading text part followed by a non-empty one", () => {
// An empty leading text part followed by a non-empty one is still visible
// (mirrors the real streaming sequence where text arrives incrementally).
expect(
assistantMessageHasVisibleContent(
msg([{ type: "text", text: "" }, { type: "text", text: "answer" }]),
),
).toBe(true);
});
it("is false for an empty completed turn (finishReason 'stop')", () => {
// A completed turn with no text/tools and a non-aborted finishReason renders
// nothing — this is intentional (hiding a dangling name-only row), distinct
// from the `aborted`/`error` cases which DO render.
expect(
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { finishReason: "stop" })),
).toBe(false);
});
it("is false for a parts-less message (the `?? []` guard makes it safe)", () => {
// The `?? []` guard makes a parts-less object safe instead of throwing.
expect(
assistantMessageHasVisibleContent({ id: "x", role: "assistant" } as unknown as UIMessage),
).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import type { UIMessage } from "@ai-sdk/react";
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
/**
* Whether an assistant `UIMessage` has anything visible to render in its bubble.
*
* This mirrors MessageItem's render decisions EXACTLY and is the single source of
* truth shared by both MessageItem (to decide whether to render the bubble at all)
* and typingIndicatorShowsName (to decide whether the standalone "Thinking…"
* indicator owns the dimmed agent-name label). Keeping one helper guarantees the
* two stay in lockstep, so exactly one element owns the name during the pre-content
* "thinking" gap and the layout never reflows mid-stream.
*
* An assistant message has visible content iff ANY of:
* - a `text` part whose trimmed length > 0 (non-empty markdown), OR
* - ANY tool part (`isToolPart(part.type)`), OR
* - `metadata.error` is truthy (a persisted error banner renders), OR
* - `metadata.finishReason === "aborted"` (a persisted "response stopped" notice).
* Empty/whitespace-only text parts and unsupported part kinds (reasoning, sources,
* files, step-start) are NOT visible.
*/
export function assistantMessageHasVisibleContent(message: UIMessage): boolean {
const meta = message.metadata as
| { error?: string; finishReason?: string }
| undefined;
// Persisted errored/aborted turns always render their banner/notice.
if (meta?.error) return true;
if (meta?.finishReason === "aborted") return true;
// `parts` may be empty (a nascent streaming message has no parts yet).
// `?? []` also guards a sparse/partial message object (metadata-only, no
// `parts`) so iterating cannot throw — it does not change behavior for any
// current input.
for (const part of message.parts ?? []) {
if (part.type === "text" && part.text.trim().length > 0) return true;
if (isToolPart(part.type)) return true;
}
return false;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
/**
* Pure-helper tests for `reasoningTokensForPart`, the #151 anti-double-count
* rule: the authoritative `usage.reasoningTokens` is the TURN TOTAL, so it may
* only be attributed when the turn has exactly one reasoning part. With multiple
* reasoning parts (or no authoritative usage) every part falls back to its own
* per-part estimate, signalled here by `undefined`.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("reasoningTokensForPart", () => {
it("single reasoning part -> the authoritative turn total", () => {
const m = msg(
[
{ type: "reasoning", text: "thinking…" } as never,
{ type: "text", text: "answer" },
],
{ usage: { reasoningTokens: 42 } },
);
expect(reasoningTokensForPart(m)).toBe(42);
});
it("multiple reasoning parts -> undefined (each estimates on its own)", () => {
const m = msg(
[
{ type: "reasoning", text: "step one" } as never,
{ type: "reasoning", text: "step two" } as never,
{ type: "text", text: "answer" },
],
{ usage: { reasoningTokens: 99 } },
);
// Even with an authoritative total, two reasoning parts must each estimate
// (attributing the total to one would double-count against the other).
expect(reasoningTokensForPart(m)).toBeUndefined();
});
it("no authoritative usage -> undefined even for a single reasoning part", () => {
const m = msg([
{ type: "reasoning", text: "thinking…" } as never,
{ type: "text", text: "answer" },
]);
expect(reasoningTokensForPart(m)).toBeUndefined();
});
});

View File

@@ -0,0 +1,34 @@
import type { UIMessage } from "@ai-sdk/react";
/**
* Decide the authoritative reasoning token count to attribute to a single
* `reasoning` part of an assistant message — or `undefined` when the part should
* fall back to its own per-part estimate.
*
* `usage.reasoningTokens` is the TURN TOTAL, so it may only be attributed to a
* block when the turn has exactly ONE reasoning part (the common one-step turn):
* then that block can show the exact figure. With MULTIPLE reasoning parts (a
* multi-step agent turn) every block must fall back to its own estimate —
* attributing the turn total to one of them would double-count against the
* others' estimates (#151 review anti-double-count rule). When there is no
* authoritative usage at all, every part estimates.
*
* Returns the authoritative `reasoningTokens` only for the single-reasoning-part
* case; `undefined` otherwise (the caller estimates from the part text).
*/
export function reasoningTokensForPart(
message: UIMessage,
): number | undefined {
const reasoningTokens = (
message.metadata as { usage?: { reasoningTokens?: number } } | undefined
)?.usage?.reasoningTokens;
const reasoningPartCount = (message.parts ?? []).reduce(
(acc, p) => (p.type === "reasoning" ? acc + 1 : acc),
0,
);
// Exactly one reasoning part -> attribute the authoritative turn total to it.
// Otherwise (zero or multiple) each part estimates on its own.
return reasoningPartCount === 1 ? reasoningTokens : undefined;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
const DEFAULT = "Take a look at the current document";
// Covers the three-way handleRolePick behavior (issue #149) without mounting the
// chat-thread component — the logic lives in these pure helpers.
describe("roleLaunchMessage", () => {
it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
expect(
roleLaunchMessage(
{ autoStart: true, launchMessage: " Draft a plan " },
DEFAULT,
),
).toBe("Draft a plan");
});
it("autoStart=true + empty launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=true + null launchMessage -> the default fallback", () => {
expect(
roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
).toBe(DEFAULT);
});
it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
expect(
roleLaunchMessage(
{ autoStart: false, launchMessage: "ignored" },
DEFAULT,
),
).toBeNull();
expect(
roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
).toBeNull();
});
});
// Regression guard for #149: the "picked, not sent" flag must reset when the
// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
// was no reset, so the flag stayed stuck and the role cards never returned —
// this is exactly the `true` case below (which the old code never acted on).
describe("shouldResetRolePicked", () => {
it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
// chatId still null, roleId cleared by the parent, flag stuck -> reset.
expect(shouldResetRolePicked(null, null, true)).toBe(true);
expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
});
it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
// Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
});
it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
});
it("is a no-op when the flag is already false", () => {
expect(shouldResetRolePicked(null, null, false)).toBe(false);
});
});

View File

@@ -0,0 +1,34 @@
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Decide what (if anything) to auto-send when an agent role card is picked
* (issue #149). Extracted as a pure function so the three-way behavior is
* unit-testable without mounting the chat-thread component:
* - autoStart=false -> null (bind the role only, send nothing)
* - autoStart=true + message -> the trimmed custom launchMessage
* - autoStart=true + empty/null -> the default fallback text
*/
export function roleLaunchMessage(
role: Pick<IAiRole, "autoStart" | "launchMessage">,
defaultText: string,
): string | null {
if (!role.autoStart) return null;
return role.launchMessage?.trim() || defaultText;
}
/**
* Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
* should reset to false. After an autoStart=false pick the thread shows the
* composer with chatId still null; when the user then starts a fresh chat the
* parent clears the bound role (roleId -> null) but chatId stays null, so the
* thread never remounts and the flag would otherwise stay set — hiding the role
* cards forever. Reset exactly in that state; a still-bound role (roleId set)
* keeps the cards hidden. (Regression guard for #149.)
*/
export function shouldResetRolePicked(
chatId: string | null,
roleId: string | null | undefined,
rolePickedNoSend: boolean,
): boolean {
return chatId === null && roleId == null && rolePickedNoSend;
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
newThread,
switchThread,
adoptThread,
threadSessionReducer,
} from "./thread-identity";
describe("newThread", () => {
it("uses the supplied key and has no chat id yet", () => {
expect(newThread("new-abc")).toEqual({ key: "new-abc", chatId: null });
});
});
describe("switchThread", () => {
it("switches to an existing chat: key becomes the chat id", () => {
expect(switchThread("chat-1")).toEqual({
key: "chat-1",
chatId: "chat-1",
});
});
});
describe("adoptThread", () => {
// Key UNCHANGED (no remount) + chatId moved null->realId. The unchanged key is
// what keeps the live useChat store alive; the matching chatId is what makes the
// window's render-phase reconciler (activeChatId !== thread.chatId) treat the
// adopted thread as already-in-sync rather than a switch.
it("adopts in place for a new chat: keeps the key, sets the chat id", () => {
const prev = newThread("new-abc");
expect(adoptThread(prev, "chat-1")).toEqual({
key: "new-abc",
chatId: "chat-1",
});
});
it("is a no-op for an already-persisted chat", () => {
const prev: { key: string; chatId: string | null } = {
key: "chat-1",
chatId: "chat-1",
};
expect(adoptThread(prev, "chat-2")).toBe(prev);
});
});
describe("threadSessionReducer", () => {
it("reconcile to an existing id switches (key becomes the id)", () => {
const next = threadSessionReducer(newThread("new-abc"), {
type: "reconcile",
chatId: "chat-1",
newKey: "new-xyz",
});
expect(next).toEqual({ key: "chat-1", chatId: "chat-1" });
});
it("reconcile to null starts a fresh new thread with the supplied key", () => {
const next = threadSessionReducer(switchThread("chat-1"), {
type: "reconcile",
chatId: null,
newKey: "new-xyz",
});
expect(next).toEqual({ key: "new-xyz", chatId: null });
});
it("adopt on a new thread keeps the key and sets the id", () => {
const next = threadSessionReducer(newThread("new-abc"), {
type: "adopt",
chatId: "chat-1",
});
expect(next).toEqual({ key: "new-abc", chatId: "chat-1" });
});
it("adopt on a persisted thread is a no-op", () => {
const prev = switchThread("chat-1");
expect(threadSessionReducer(prev, { type: "adopt", chatId: "chat-2" })).toBe(
prev,
);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Pure transitions for the AI-chat thread's identity: the single source of
* truth tying ChatThread's mount key to the chat id that mounted thread holds.
*
* The window keeps exactly ONE of these in state. Consolidating the mount key
* and the live thread's chat id into one atomic value makes the "stale chat id
* vs key" state unrepresentable: every change goes through one of the explicit
* transitions below, so the key and chatId can never silently diverge.
*
* - `newThread`/`switchThread` produce a key that forces a remount (+ reseed):
* `newThread` for a brand-new (id-less) chat, `switchThread` for an existing
* one. The caller picks which based on whether there is a chat id.
* - `adoptThread` keeps the SAME key so a brand-new chat learns its real id
* WITHOUT remounting (the live useChat store, holding the just-finished turn,
* is preserved and the next turn sends the real chatId).
*
* `newThread` takes the session key from the impure `generateId()` at the call
* site so these stay pure and unit-testable.
*/
export type ThreadIdentity = { key: string; chatId: string | null };
/**
* A brand-new chat: a fresh session key and no chat id yet. `newKey` is
* supplied by the caller (generateId() is impure) so this stays pure/testable.
*/
export function newThread(newKey: string): ThreadIdentity {
return { key: newKey, chatId: null };
}
/**
* Switch to an EXISTING chat: the mount key becomes the chat id, forcing a
* remount + reseed from the persisted history. (A switch to a brand-new chat
* goes through `newThread` instead — there is no id to key on.)
*/
export function switchThread(chatId: string): ThreadIdentity {
return { key: chatId, chatId };
}
/**
* In-place adoption: a brand-new chat (`prev.chatId === null`) learns its real
* id WITHOUT remounting — keep the SAME key, set the chat id. If `prev` already
* has a chatId (not a new chat), this is a no-op (returns `prev`): adoption only
* applies to an as-yet-unadopted new thread.
*/
export function adoptThread(prev: ThreadIdentity, chatId: string): ThreadIdentity {
return prev.chatId === null ? { key: prev.key, chatId } : prev;
}
/**
* Thread-identity transitions as a reducer action. See `threadSessionReducer`.
*/
export type ThreadSessionAction =
| { type: "reconcile"; chatId: string | null; newKey: string }
| { type: "adopt"; chatId: string };
/**
* Single source of truth for thread-identity transitions. `reconcile` handles a
* genuine switch (user OR external atom write) -> remount; `adopt` moves a brand-
* new chat to its real id in place (no remount).
*/
export function threadSessionReducer(
state: ThreadIdentity,
action: ThreadSessionAction,
): ThreadIdentity {
switch (action.type) {
case "reconcile":
return action.chatId === null
? newThread(action.newKey)
: switchThread(action.chatId);
case "adopt":
return adoptThread(state, action.chatId);
}
}

View File

@@ -10,9 +10,12 @@ import {
PasswordInput,
Box,
Stack,
Group,
Text,
} from "@mantine/core";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { Link, useParams, useSearchParams } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
}
if (isError) {
return <div>{t("invalid invitation link")}</div>;
// Styled error with a CTA to login, mirroring the password-reset
// error page and the 404 page (issue #133)
return (
<AuthLayout>
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid invitation link")}
</Text>
<Group justify="center">
<Button
component={Link}
to={APP_ROUTE.AUTH.LOGIN}
variant="subtle"
size="md"
>
{t("Go to login page")}
</Button>
</Group>
</Container>
</AuthLayout>
);
}
if (!invitation) {

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// The comment mutation hooks reach out to react-query/network — stub them so the
// component renders in isolation. We only assert the AI-badge rendering branch.
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
}));
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
vi.mock("@/features/comment/components/comment-editor", () => ({
default: () => <div data-testid="comment-editor" />,
}));
import CommentListItem from "./comment-list-item";
const baseComment = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
content: JSON.stringify({ type: "doc", content: [] }),
creatorId: "user-1",
pageId: "page-1",
workspaceId: "ws-1",
createdAt: new Date(),
creator: { id: "user-1", name: "Service Bot", avatarUrl: null } as any,
...over,
}) as IComment;
function renderItem(comment: IComment) {
return render(
<MantineProvider>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
</MantineProvider>,
);
}
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
expect(screen.getByText("Service Bot")).toBeDefined();
});
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
});

View File

@@ -1,4 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core";
import { Group, Text, Box } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@@ -126,9 +127,18 @@ function CommentListItem({
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
)}
</Group>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && (

View File

@@ -17,6 +17,13 @@ export interface IComment {
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
// Agent-edit provenance (returned by the backend via selectAll('comments')).
// createdSource === "agent" marks a comment authored via an AI agent (MCP /
// internal AI chat); aiChatId deep-links to the internal chat when present
// (null for an external MCP agent); resolvedSource marks an AI-resolved thread.
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
yjsSelection?: {
anchor: any;
head: any;

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { encodeWavPcm16 } from "./encode-wav";
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
// produce audio the server cannot decode (silence / garbled transcripts), so we
// assert the canonical 44-byte header layout and the sample quantisation rails.
// Read a DataView back out of a Blob. jsdom's Blob does not implement
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
function readView(blob: Blob): Promise<DataView> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
function readStr(view: DataView, offset: number, length: number): string {
let s = "";
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
describe("encodeWavPcm16", () => {
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(4)));
expect(readStr(view, 0, 4)).toBe("RIFF");
expect(readStr(view, 8, 4)).toBe("WAVE");
expect(readStr(view, 12, 4)).toBe("fmt ");
expect(readStr(view, 36, 4)).toBe("data");
});
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples));
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
expect(view.getUint16(22, true)).toBe(1); // channels = mono
expect(view.getUint16(34, true)).toBe(16); // bits per sample
});
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
const sampleRate = 16000;
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples, sampleRate));
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
});
it("defaults the sample rate to 16000 at offset 24", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(2)));
expect(view.getUint32(24, true)).toBe(16000);
});
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
expect(view8.getUint32(24, true)).toBe(8000);
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
expect(view48.getUint32(24, true)).toBe(48000);
expect(view48.getUint32(28, true)).toBe(48000 * 2);
});
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
// 0 -> 0, and out-of-range values are clamped to the rails first.
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
const view = await readView(encodeWavPcm16(samples));
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
});
it("produces a mono blob of length 44 + samples.length * 2", () => {
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
});
});

View File

@@ -1,23 +1,43 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import { ActionIcon, Loader, Tooltip } from "@mantine/core";
import {
IconDownload,
IconFileText,
IconTrash,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import classes from "../common/toolbar-menu.module.css";
// STT-accepted audio MIME types (mirror of the server whitelist). If the
// fetched blob's type is not one of these, we infer it from the file
// extension so the upload's content-type is something the endpoint accepts.
const RECOGNIZED_AUDIO_MIME = new Set([
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
"audio/wav", "audio/x-wav", "audio/wave", "audio/m4a", "audio/x-m4a",
]);
const AUDIO_MIME_BY_EXT: Record<string, string> = {
mp3: "audio/mpeg", m4a: "audio/mp4", mp4: "audio/mp4",
wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg", webm: "audio/webm",
};
export function AudioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const dictationEnabled = workspace?.settings?.ai?.dictation === true;
const [isTranscribing, setIsTranscribing] = useState(false);
const editorState = useEditorState({
editor,
@@ -68,6 +88,100 @@ export function AudioMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const handleTranscribe = useCallback(async () => {
const src = editorState?.src;
if (!src || isTranscribing) return;
// The bubble menu shows for the selected audio node, so selection.from is
// that node's start position. Capture it now to disambiguate duplicate-src
// blocks after the async transcription completes.
const selectedPos = editor.state.selection.from;
setIsTranscribing(true);
try {
const fileUrl = getFileUrl(src);
// Derive a filename from the internal src for the multipart part name and
// for MIME inference when the fetched blob has no usable type.
const filename = decodeURIComponent(
src.split("?")[0].split("/").pop() || "audio",
);
const res = await fetch(fileUrl, { credentials: "include" });
if (!res.ok) {
throw new Error(`Failed to fetch audio file (HTTP ${res.status})`);
}
const blob = await res.blob();
// Ensure the upload's content-type is one the STT endpoint accepts; the
// server keys off the blob's MIME type.
let uploadBlob = blob;
const baseType = (blob.type || "").split(";")[0].trim().toLowerCase();
if (!RECOGNIZED_AUDIO_MIME.has(baseType)) {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const inferred = AUDIO_MIME_BY_EXT[ext];
if (inferred) {
// Rebuild the blob with an accepted content-type; the server keys off it.
uploadBlob = new Blob([blob], { type: inferred });
}
}
const text = (await transcribeAudio(uploadBlob, filename)).trim();
if (text.length === 0) {
notifications.show({ message: t("No speech detected") });
return;
}
// Re-scan the doc at insert time so a collaborative edit during the async
// transcription can't misplace the text. Among audio nodes with this src
// (the same file may be embedded more than once), pick the occurrence
// closest to the originally-selected block.
let insertPos: number | null = null;
let bestDelta = Infinity;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === "audio" && node.attrs.src === src) {
const delta = Math.abs(pos - selectedPos);
if (delta < bestDelta) {
bestDelta = delta;
insertPos = pos + node.nodeSize; // position just after the audio block
}
}
return true; // visit all nodes to find the closest match
});
const paragraph = { type: "paragraph", content: [{ type: "text", text }] };
try {
if (insertPos !== null) {
editor.chain().focus().insertContentAt(insertPos, paragraph).run();
} else {
editor.chain().focus().insertContent(paragraph).run();
}
} catch (insertErr) {
// A destroyed editor or out-of-bounds position must not throw; log and
// ignore so the transcription itself is not reported as a failure.
console.error("[audio-transcribe] insert failed", insertErr);
}
} catch (err) {
console.error("[audio-transcribe] failed", err);
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
let message: string;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider error, bad
// format, STT not configured) — show it verbatim.
message = serverMsg;
} else if (resp?.status === 503 || resp?.status === 403) {
message = t("Voice dictation is not configured");
} else {
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
}
notifications.show({ color: "red", message });
} finally {
setIsTranscribing(false);
}
}, [editor, editorState?.src, isTranscribing, t]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -95,6 +209,20 @@ export function AudioMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
{dictationEnabled && (
<Tooltip position="top" label={isTranscribing ? t("Transcribing…") : t("Transcribe")} withinPortal={false}>
<ActionIcon
onClick={handleTranscribe}
size="lg"
aria-label={t("Transcribe")}
variant="subtle"
disabled={isTranscribing}
>
{isTranscribing ? <Loader size={18} /> : <IconFileText size={18} />}
</ActionIcon>
</Tooltip>
)}
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}

View File

@@ -47,6 +47,26 @@ export default function CodeBlockView(props: NodeViewProps) {
return (
<NodeViewWrapper className="codeBlock">
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
With the non-editable menu rendered before it, the browser's click
hit-testing snapped the caret up one line. Render content first; the
menu is rendered after it and lifted back above visually via flex
`order: -1` (the `.codeBlock` wrapper is a flex column — see
code-block.module.css). It stays fully in flow as a full-width row
above the code: no overlay/absolute positioning. The second #146
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
<pre
spellCheck="false"
hidden={
((language === "mermaid" && !editor.isEditable) ||
(language === "mermaid" && !isSelected)) &&
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
<Group
justify="flex-end"
contentEditable={false}
@@ -83,18 +103,6 @@ export default function CodeBlockView(props: NodeViewProps) {
</CopyButton>
</Group>
<pre
spellCheck="false"
hidden={
((language === "mermaid" && !editor.isEditable) ||
(language === "mermaid" && !isSelected)) &&
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
{language === "mermaid" && (
<Suspense fallback={null}>
<MermaidView props={props} />

View File

@@ -17,7 +17,14 @@
justify-content: center;
}
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
so the menu still reads as a row above the code, exactly as before, without
sitting in-flow before the contentDOM. */
.menuGroup {
order: -1;
@media print {
display: none;
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
collectScrollAncestors,
reflowAfterPaste,
} from "./editor-paste-handler";
/**
* Unit tests for the #146 post-paste reflow helpers. jsdom does not compute
* styles or layout, so we stub getComputedStyle (per element via a Map) and the
* scroll/overflow geometry properties (per element via Object.defineProperty).
* Element trees are built DETACHED from `document`, so the ancestor walk only
* traverses the elements we create. collectScrollAncestors always appends
* document.scrollingElement, so we assert on specific ancestors with
* toContain/not.toContain rather than exact-array equality.
*/
type Overflow = { overflowX: string; overflowY: string };
const styleMap = new Map<Element, Overflow>();
function makeScrollable(
overflowY: string,
{
sh = 0,
ch = 0,
sw = 0,
cw = 0,
left = 0,
top = 0,
overflowX = "visible",
}: {
sh?: number;
ch?: number;
sw?: number;
cw?: number;
left?: number;
top?: number;
overflowX?: string;
} = {},
) {
const el = document.createElement("div");
Object.defineProperty(el, "scrollHeight", { configurable: true, value: sh });
Object.defineProperty(el, "clientHeight", { configurable: true, value: ch });
Object.defineProperty(el, "scrollWidth", { configurable: true, value: sw });
Object.defineProperty(el, "clientWidth", { configurable: true, value: cw });
Object.defineProperty(el, "scrollLeft", { configurable: true, value: left });
Object.defineProperty(el, "scrollTop", { configurable: true, value: top });
styleMap.set(el, { overflowX, overflowY });
return el;
}
// A leaf node whose parentElement is `parent`. The walk starts from
// node.parentElement, so the parent is the first candidate ancestor.
function makeNodeUnder(parent: HTMLElement) {
const node = document.createElement("div");
parent.appendChild(node);
return node;
}
// Override `document.scrollingElement` as an instance own-property (the native
// implementation is a getter on Document.prototype, which we never touch).
function setScrollingElement(value: Element | null) {
Object.defineProperty(document, "scrollingElement", {
configurable: true,
get: () => value,
});
}
beforeEach(() => {
styleMap.clear();
vi.stubGlobal("getComputedStyle", (el: Element) => {
return styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" };
});
});
afterEach(() => {
vi.unstubAllGlobals();
// Drop the per-test instance override so the native prototype getter shows
// through again (it was never modified, so no further restore is needed).
delete (document as any).scrollingElement;
});
describe("collectScrollAncestors", () => {
it("includes an overflow:overlay ancestor that overflows (macOS case)", () => {
setScrollingElement(null);
const a = makeScrollable("overlay", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("excludes an overflow:auto ancestor that does NOT overflow (gate fails)", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 100, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).not.toContain(a);
});
it("includes an overflow:auto ancestor that overflows", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("excludes a non-scrollable overflow even when it overflows", () => {
setScrollingElement(null);
const a = makeScrollable("hidden", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).not.toContain(a);
});
it("includes an X-axis overflow:scroll ancestor that overflows horizontally", () => {
setScrollingElement(null);
const a = makeScrollable("visible", {
overflowX: "scroll",
sw: 200,
cw: 100,
});
const node = makeNodeUnder(a);
expect(collectScrollAncestors(node)).toContain(a);
});
it("dedups: scrollingElement already in the walk is added exactly once", () => {
const a = makeScrollable("auto", { sh: 200, ch: 100 });
setScrollingElement(a);
const node = makeNodeUnder(a);
const result = collectScrollAncestors(node);
expect(result.filter((x) => x === a).length).toBe(1);
});
it("does not throw and appends nothing when scrollingElement is null", () => {
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100 });
const node = makeNodeUnder(a);
const result = collectScrollAncestors(node);
// Only the qualifying ancestor we built — no trailing scrollingElement.
expect(result).toEqual([a]);
});
});
describe("reflowAfterPaste", () => {
it("runs the double rAF and nudges each ancestor with scrollTo(scrollLeft, scrollTop)", () => {
// Run the double-nested requestAnimationFrame synchronously.
vi.stubGlobal(
"requestAnimationFrame",
(cb: FrameRequestCallback) => {
cb(0);
return 0;
},
);
setScrollingElement(null);
const a = makeScrollable("auto", { sh: 200, ch: 100, left: 5, top: 10 });
const node = makeNodeUnder(a);
(a as any).scrollTo = vi.fn();
reflowAfterPaste({ view: { dom: node } } as any);
expect((a as any).scrollTo).toHaveBeenCalledWith(5, 10);
});
});

View File

@@ -22,12 +22,81 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "overlay"]);
/**
* Collect every scrollable ancestor of the editor DOM whose hit-test layer
* could be stale after a paste, plus the document scrolling element. We nudge
* ALL of them (a zero-delta nudge is harmless) because the real scroll container
* varies — a styled overflow ancestor on most pages, the document itself on
* others — and `overflow: overlay` (common on macOS, where #146 reproduces)
* must count as scrollable too. Called only AFTER the paste has committed, so
* `scrollHeight > clientHeight` reflects the inserted content.
*/
export function collectScrollAncestors(node: HTMLElement): HTMLElement[] {
const targets: HTMLElement[] = [];
// Walk every ancestor (incl. body/html) — on some layouts the scroll lives on
// body rather than the documentElement that scrollingElement points at.
let el: HTMLElement | null = node.parentElement;
while (el) {
const { overflowX, overflowY } = getComputedStyle(el);
const scrollsY =
SCROLLABLE_OVERFLOW.has(overflowY) && el.scrollHeight > el.clientHeight;
const scrollsX =
SCROLLABLE_OVERFLOW.has(overflowX) && el.scrollWidth > el.clientWidth;
if (scrollsY || scrollsX) targets.push(el);
el = el.parentElement;
}
const docEl = document.scrollingElement as HTMLElement | null;
if (docEl && !targets.includes(docEl)) targets.push(docEl);
return targets;
}
/**
* Re-flow the editor's scroll containers after a paste so the browser refreshes
* its click hit-testing geometry (#146). Pasting markdown/code inserts React
* NodeViews that mount ASYNCHRONOUSLY; until the next reflow, ProseMirror's
* posAtCoords/caretRangeFromPoint can map a click to a stale (offset) line —
* which users observed clears itself on any scroll. We reproduce that scroll's
* side effect with a ZERO-delta nudge (re-assign scrollTop/Left to their current
* value), invalidating the hit-test layer WITHOUT moving the viewport. The
* container lookup AND the nudge run across two animation frames so they happen
* AFTER the pasted content + NodeViews commit (only then is the real scroll
* container measurable).
*
* This is the SECOND of two #146 mitigations; the FIRST is the content-first DOM
* order in the NodeViews (code-block-view.tsx, footnotes-list-view.tsx,
* footnote-definition-view.tsx). Editing one, check the other.
*/
export function reflowAfterPaste(editor: Editor) {
const dom = editor.view.dom as HTMLElement;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const el of collectScrollAncestors(dom)) {
// Zero-delta nudge: re-set the scroll position to its current value to
// invalidate the browser's hit-test layer WITHOUT moving the viewport.
// `scrollTo(x, y)` is the repo idiom and avoids a lint-flagged
// self-assignment.
el.scrollTo(el.scrollLeft, el.scrollTop);
}
});
});
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
) => {
// Schedule a post-paste reflow on EVERY paste path — intentionally. handlePaste
// returns BEFORE the markdown/code-insertion plugin runs, so it cannot know here
// whether async NodeViews will be inserted; the nudge is a cheap layout read on
// the next frames and a no-op for the viewport, so scheduling it unconditionally
// is simpler and harmless. Pairs with the content-first DOM order in the
// NodeViews — both address #146 from different angles.
reflowAfterPaste(editor);
const clipboardData = event.clipboardData.getData("text/plain");
if (INTERNAL_LINK_REGEX.test(clipboardData)) {

View File

@@ -73,3 +73,18 @@
display: none !important;
}
}
/* Float image (#145): on narrow screens a floated image would crowd the text to
an unreadable column, so collapse it to full width and drop the float.
`!important` is required because applyAlignment sets `float`/`padding` inline,
which a normal rule cannot override. Keys off the `data-image-align` attribute
the image node view mirrors onto its container. This module is the one actually
imported by the resize node views (node-resize-handles.ts), so the rule loads. */
@media (max-width: 600px) {
.container:global([data-image-align="floatLeft"]),
.container:global([data-image-align="floatRight"]) {
float: none !important;
width: 100% !important;
padding: 0 !important;
}
}

View File

@@ -1,5 +1,7 @@
import { FC, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { MicButton } from "@/features/dictation/components/mic-button";
interface Props {
@@ -9,6 +11,11 @@ interface Props {
}
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
// keeps the stable batch path.
const workspace = useAtomValue(workspaceAtom);
const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true;
// Caret snapshot taken when dictation starts (where the first segment lands).
const rangeRef = useRef<{ from: number; to: number } | null>(null);
// Running insertion point: after each inserted segment we remember the caret
@@ -70,7 +77,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
return (
<MicButton
size="md"
streaming
streaming={streamingDictation}
onStart={handleStart}
onText={handleText}
disabled={!editor.isEditable}

View File

@@ -29,10 +29,19 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{/* #146: contentDOM MUST be the first child — a non-editable marker before
it makes click hit-testing snap the caret above. Content first; the
marker + back-link follow in DOM and are placed left/right via CSS
flex `order`. The second #146 mitigation lives in
editor-paste-handler.tsx (reflowAfterPaste). */}
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.definitionMarker}
contentEditable={false}
aria-hidden="true"
>
{number}.
</span>
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
contentEditable={false}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
*
* Guards ALL THREE editable NodeViews touched by the fix: the two footnote views
* (FootnotesListView, FootnoteDefinitionView) AND the code block (CodeBlockView).
*
* The caret/click-offset fix rests entirely on ONE invariant: in every editable
* NodeView the editable `NodeViewContent` (contentDOM) must come FIRST in the
* wrapper, with no non-editable (`contenteditable="false"`) element before it.
* If a future edit reinserts chrome (separator, heading, marker, back-link,
* language menu) ahead of the content, the macOS hit-testing bug returns
* silently — and the symptom needs a real browser to see. This test pins the
* DOM ORDER (the proxy that IS the fix) in the existing jsdom harness.
*
* We stub `@tiptap/react` so the views render as plain DOM and we can inspect
* the child order our JSX produces — that order is exactly what regresses, and
* it does not depend on a live editor. The stubbed `NodeViewContent` carries the
* real `data-node-view-content` marker tiptap uses, so the assertion mirrors
* production. This test passes on the fixed order and FAILS on the pre-fix order
* (chrome-before-content).
*/
vi.mock("@tiptap/react", () => ({
NodeViewWrapper: ({ children, ...props }: any) => (
<div data-testid="nvw" {...props}>
{children}
</div>
),
// Mirror the real contentDOM marker so the guard matches production output.
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// footnote-definition-view reads a cached number from the numbering plugin;
// stub it so we don't need a live ProseMirror state.
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
}));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
// The Group mock MUST forward contentEditable: React serializes
// contentEditable={false} to the DOM attribute contenteditable="false", which
// the structural guard selects on to identify non-editable chrome.
vi.mock("@mantine/core", () => ({
Group: ({ children, className, contentEditable }: any) => (
<div className={className} contentEditable={contentEditable}>
{children}
</div>
),
Select: () => null,
Tooltip: ({ children }: any) => <>{children}</>,
ActionIcon: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
}));
vi.mock("@/components/common/copy-button", () => ({
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
IconCopy: () => null,
}));
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
default: () => null,
}));
import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView from "./footnote-definition-view";
import CodeBlockView from "../code-block/code-block-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
// editor.state (the latter unused once getFootnoteNumber is mocked).
const props = {
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: { state: {}, isEditable: true, commands: {} },
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
} as any;
// CodeBlockView needs more than the footnote stub: a language attr (non-mermaid
// so MermaidView never renders), an editor with selection/on/off, and an
// extension exposing lowlight.listLanguages.
const codeBlockProps = {
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
editor: {
state: { selection: { from: 0, to: 0 } },
isEditable: true,
commands: {},
on: vi.fn(),
off: vi.fn(),
},
extension: {
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
} as any;
const cases: Array<{ name: string; ui: React.ReactElement }> = [
{ name: "FootnotesListView", ui: <FootnotesListView {...props} /> },
{ name: "FootnoteDefinitionView", ui: <FootnoteDefinitionView {...props} /> },
{ name: "CodeBlockView", ui: <CodeBlockView {...codeBlockProps} /> },
];
describe("#146 editable NodeView contentDOM-first invariant", () => {
it.each(cases)(
"$name renders the editable contentDOM ahead of all non-editable chrome",
({ ui }) => {
const { getByTestId } = render(ui);
const wrapper = getByTestId("nvw");
const content = wrapper.querySelector("[data-node-view-content]");
expect(content).not.toBeNull();
// The contentDOM sits at the FRONT of the wrapper: it is either the
// wrapper's first child (footnote views) or nested in the first child
// (code-block wraps it in <pre>). Either way the first element child
// must contain it. (compareDocumentPosition below is NOT redundant here:
// for code-block the content is not the literal first child, so we keep
// the document-order check to prove no chrome precedes the content.)
const firstEl = wrapper.firstElementChild!;
expect(firstEl === content || firstEl.contains(content!)).toBe(true);
// Chrome exists (separator/heading/marker/back-link/menu)...
const nonEditable = wrapper.querySelectorAll('[contenteditable="false"]');
expect(nonEditable.length).toBeGreaterThan(0);
// ...and every non-editable element comes AFTER the contentDOM, so the
// browser's click hit-testing reaches the editable content first (#146).
for (const el of Array.from(nonEditable)) {
const pos = content!.compareDocumentPosition(el);
expect(pos & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
},
);
});

View File

@@ -57,14 +57,19 @@
word-break: break-word;
}
/* Bottom footnotes container. */
/* Bottom footnotes container. Flex column so the heading (rendered AFTER the
editable NodeViewContent in the DOM for #146) is lifted back above the list
visually via `order`, instead of sitting in-flow before the contentDOM. */
.list {
display: flex;
flex-direction: column;
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
}
.listHeading {
order: -1; /* visually above the list, though it follows it in the DOM (#146) */
font-weight: 600;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
@@ -83,6 +88,7 @@
}
.definitionMarker {
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
flex: 0 0 auto;
min-width: 1.5em;
/* Right-align within the narrow column so the period sits next to the text

View File

@@ -3,18 +3,39 @@ import { useTranslation } from "react-i18next";
import classes from "./footnote.module.css";
/**
* NodeView for the bottom footnotes container. Renders a visual separator and a
* localized heading, then the editable list of definitions via NodeViewContent.
* NodeView for the bottom footnotes container: the editable list of definitions
* (NodeViewContent) plus a visual separator + localized heading.
*
* #146: the editable NodeViewContent MUST be the FIRST child in the DOM. A
* non-editable block rendered before it (the old separator + heading) makes the
* browser's click hit-testing (posAtCoords → caretRangeFromPoint) miss the
* contentDOM and snap the caret to the previous node (several lines above, into
* the body). So content goes first; the heading is rendered AFTER it and lifted
* back above visually with CSS flex `order` (the separator border lives on the
* flex container itself).
*
* The second #146 mitigation lives in editor-paste-handler.tsx (reflowAfterPaste).
*/
export default function FootnotesListView(_props: NodeViewProps) {
const { t } = useTranslation();
return (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
// role/aria-label preserve the section label for AT: the visible heading
// below is now aria-hidden, so without these the "Footnotes" label would be
// lost to a screen reader (WCAG 1.3.2 — DOM order has heading after content).
<NodeViewWrapper
className={classes.list}
role="group"
aria-label={t("Footnotes")}
>
<NodeViewContent />
<div
className={classes.listHeading}
contentEditable={false}
aria-hidden="true"
>
{t("Footnotes")}
</div>
</NodeViewWrapper>
);
}

View File

@@ -13,6 +13,8 @@ import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconDownload,
IconRefresh,
IconTrash,
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
};
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignImageFloatLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("floatLeft")
.run();
}, [editor]);
const alignImageFloatRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("floatRight")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Float left (wrap text)")} withinPortal={false}>
<ActionIcon
onClick={alignImageFloatLeft}
size="lg"
aria-label={t("Float left (wrap text)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isFloatLeft })}
>
<IconFloatLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Float right (wrap text)")} withinPortal={false}>
<ActionIcon
onClick={alignImageFloatRight}
size="lg"
aria-label={t("Float right (wrap text)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isFloatRight })}
>
<IconFloatRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
{altTextButton}

View File

@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getSuggestionItems } from "./menu-items";
// The slash-command `allow` callback (slash-command.ts) keeps the popup active
// only while at least one item matches the current query:
// const groups = getSuggestionItems({ query });
// const hasMatches = Object.values(groups).some((items) => items.length > 0);
// return hasMatches;
// With `allowSpaces: true`, a non-empty query that matches nothing must collapse
// to an empty result so `allow` returns false and the menu closes (instead of
// leaving literal "/todo abc" text behind). These tests pin that contract at the
// `getSuggestionItems` boundary, which is the unit-testable half of `allow`.
const KEY = "currentUser";
function hasMatches(query: string): boolean {
// Mirror the exact predicate used by slash-command.ts `allow`.
const groups = getSuggestionItems({ query });
return Object.values(groups).some((items) => items.length > 0);
}
beforeEach(() => {
// Default workspace state: HTML-embed feature OFF (matches production default).
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
});
afterEach(() => {
localStorage.clear();
});
describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => {
it("keeps the menu allowed for a query that matches items", () => {
expect(hasMatches("h1")).toBe(true);
});
it("keeps the menu allowed for a multi-word matching query", () => {
// "Heading 1" is a multi-word title kept alive by allowSpaces.
expect(hasMatches("Heading 1")).toBe(true);
});
it("closes the menu (no matches) for a non-empty query that matches nothing", () => {
expect(hasMatches("zzzznomatch")).toBe(false);
});
it("closes the menu for a space-bearing non-matching query", () => {
// The exact case the allowSpaces fix targets: "/todo abc" matches nothing.
expect(hasMatches("todo abc")).toBe(false);
});
it("returns an empty result object for a no-match query", () => {
expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({});
});
it("returns a non-empty result for the 'Heading 1' query", () => {
const groups = getSuggestionItems({ query: "Heading 1" });
const titles = Object.values(groups)
.flat()
.map((item) => item.title);
expect(titles).toContain("Heading 1");
});
});

View File

@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Page tree (child pages, recursive)",
description: "Render the full nested tree of all descendant pages",
searchTerms: [
"subpages",
"child",
"children",
"nested",
"hierarchy",
"tree",
"recursive",
"toc",
],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertSubpages({ recursive: true })
.run();
},
},
{
title: "Synced block",
description: "Create a block that stays in sync across pages.",

View File

@@ -1,9 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleRecursive = useCallback(() => {
const current = editor.getAttributes("subpages")?.recursive ?? false;
editor.commands.updateAttributes("subpages", {
recursive: !current,
});
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
.run();
}, [editor]);
// Subscribe to the live `recursive` attribute the standard way (as the
// sibling bubble menus do): useEditorState re-renders only when the selected
// value actually changes, so the mode icon/tooltip stay current after a
// toggle without re-rendering on every keystroke.
const isRecursive = useEditorState({
editor,
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
});
return (
<BaseBubbleMenu
editor={editor}
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
<Group gap={4} wrap="nowrap">
<Tooltip
position="top"
label={
isRecursive
? t("Switch to flat list")
: t("Switch to tree")
}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
<ActionIcon
onClick={toggleRecursive}
variant="default"
size="lg"
aria-label={t("Toggle subpages display mode")}
>
{isRecursive ? (
<IconList size={18} />
) : (
<IconSitemap size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</Group>
</BaseBubbleMenu>
);
}

View File

@@ -1,7 +1,10 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import {
useGetSidebarPagesQuery,
useGetPageTreeQuery,
} from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
@@ -12,16 +15,130 @@ import {
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import {
useSharedPageSubpages,
useSharedPageSubtree,
} from "@/features/share/hooks/use-shared-page-subpages";
import {
SubpageNode,
buildSubtree,
mapSharedNodes,
countNodes,
} from "./subpages-view.utils";
// Threshold above which the recursive tree shows a small count note. We never
// cap the data — this is only an informational hint for very large trees.
const LARGE_TREE_THRESHOLD = 300;
interface TreeNodeProps {
node: SubpageNode;
depth: number;
shareId?: string;
spaceSlug?: string;
// Threaded down from the variant component so a large tree does not create one
// i18n subscription (useTranslation) per rendered node.
t: (key: string) => string;
}
// Recursive renderer for a single node and its descendants. Indents each level
// by depth * 16px and reuses the same link/icon markup as the flat list.
function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) {
return (
<>
<Anchor
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: node.slugId,
pageTitle: node.title,
})
: buildPageUrl(spaceSlug, node.slugId, node.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
style={{ paddingLeft: depth * 16 }}
>
{node?.icon ? (
<span style={{ marginRight: "4px" }}>{node.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{node?.title || t("untitled")}
</span>
</Anchor>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
))}
</>
);
}
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const recursive: boolean = props.node.attrs.recursive ?? false;
//@ts-ignore
const currentPageId = editor.storage.pageId;
if (recursive) {
return (
<RecursiveSubpages
currentPageId={currentPageId}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
);
}
return (
<FlatSubpages
currentPageId={currentPageId}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
);
}
interface SubpagesVariantProps {
currentPageId: string;
shareId?: string;
spaceSlug?: string;
t: (key: string, options?: Record<string, unknown>) => string;
}
function FlatSubpages({
currentPageId,
shareId,
spaceSlug,
t,
}: SubpagesVariantProps) {
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
</NodeViewWrapper>
);
}
function RecursiveSubpages({
currentPageId,
shareId,
spaceSlug,
t,
}: SubpagesVariantProps) {
// In a shared/public context reuse the already-loaded nested shared tree
// instead of issuing a /pages/tree request.
const sharedSubtree = useSharedPageSubtree(currentPageId);
const { data, isLoading, error } = useGetPageTreeQuery(
shareId ? "" : currentPageId,
);
const tree = useMemo<SubpageNode[]>(() => {
if (shareId) {
return mapSharedNodes(sharedSubtree);
}
if (!data) return [];
return buildSubtree(data, currentPageId);
}, [data, shareId, sharedSubtree, currentPageId]);
const total = useMemo(() => countNodes(tree), [tree]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (tree.length === 0) {
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5}>
{tree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
shareId={shareId}
spaceSlug={spaceSlug}
t={t}
/>
))}
</Stack>
{total > LARGE_TREE_THRESHOLD && (
<Text c="dimmed" size="xs" pt="xs">
{t("Showing {{count}} subpages", { count: total })}
</Text>
)}
</div>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import {
buildSubtree,
countNodes,
mapSharedNodes,
SubpageNode,
} from "./subpages-view.utils";
import { IPage } from "@/features/page/types/page.types";
// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
const page = (p: Partial<IPage> & { id: string }): IPage =>
({
slugId: `slug-${p.id}`,
title: `Title ${p.id}`,
icon: undefined,
position: "a0",
parentPageId: null,
...p,
}) as IPage;
const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
describe("buildSubtree", () => {
it("nests children under the root and excludes the root itself", () => {
const pages = [
page({ id: "root" }),
page({ id: "a", parentPageId: "root", position: "a0" }),
page({ id: "b", parentPageId: "root", position: "a1" }),
page({ id: "a1", parentPageId: "a", position: "a0" }),
];
const tree = buildSubtree(pages, "root");
// Root is not rendered; only its descendants.
expect(ids(tree)).toEqual(["a", "b"]);
expect(ids(tree[0].children)).toEqual(["a1"]);
expect(tree[1].children).toEqual([]);
});
it("sorts each level by position", () => {
const pages = [
page({ id: "root" }),
page({ id: "z", parentPageId: "root", position: "a2" }),
page({ id: "x", parentPageId: "root", position: "a0" }),
page({ id: "y", parentPageId: "root", position: "a1" }),
];
expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
});
it("returns [] when the root is absent from the page set", () => {
const pages = [page({ id: "a", parentPageId: "missing-root" })];
expect(buildSubtree(pages, "missing-root")).toEqual([]);
});
it("silently drops a node whose parent is absent (unreachable parent)", () => {
const pages = [
page({ id: "root" }),
page({ id: "ok", parentPageId: "root" }),
page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
];
expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
});
it("guards against self-parenting / attaching the root", () => {
const pages = [
// A (defensive) self-parented root must not attach to itself.
page({ id: "root", parentPageId: "root" }),
page({ id: "a", parentPageId: "root" }),
];
const tree = buildSubtree(pages, "root");
expect(ids(tree)).toEqual(["a"]);
});
it("returns [] for empty input", () => {
expect(buildSubtree([], "root")).toEqual([]);
});
});
describe("countNodes", () => {
it("counts every descendant across all levels", () => {
const tree: SubpageNode[] = [
{
id: "a",
slugId: "s",
title: "A",
children: [
{ id: "a1", slugId: "s", title: "A1", children: [] },
{ id: "a2", slugId: "s", title: "A2", children: [] },
],
},
{ id: "b", slugId: "s", title: "B", children: [] },
];
expect(countNodes(tree)).toBe(4);
expect(countNodes([])).toBe(0);
});
});
describe("mapSharedNodes", () => {
it("remaps value->id / name->title and keeps nested children", () => {
const shared = [
{
value: "p1",
slugId: "s1",
name: "Parent",
icon: "📁",
children: [
{ value: "c1", slugId: "sc1", name: "Child", children: [] },
],
},
] as any;
const mapped = mapSharedNodes(shared);
expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
});
});

View File

@@ -0,0 +1,83 @@
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { IPage } from "@/features/page/types/page.types";
import { SharedPageTreeNode } from "@/features/share/utils";
// Normalized node shared by the flat and recursive subpages renderers so the
// same link/icon markup works for both API pages and shared-tree nodes.
export interface SubpageNode {
id: string;
slugId: string;
title: string;
icon?: string;
children: SubpageNode[];
}
// Subpage node carrying `position` so each level can be sorted in place.
export type SubpageNodeWithPos = SubpageNode & {
position: string;
children: SubpageNodeWithPos[];
};
/**
* Build a nested subtree (the current page's descendants) from the flat `IPage[]`
* the `/pages/tree` endpoint returns. Attaches each node to its parent by
* `parentPageId`, drops the root itself, and sorts every level by `position`.
*
* Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
* NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
* server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
* produces this list would itself loop before reaching the client, so the flat
* input is acyclic by construction. A node whose `parentPageId` points outside
* the result set (an unreachable parent) is silently dropped — it is, by
* definition, not a descendant of the root being rendered.
*/
export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
const byId = new Map<string, SubpageNodeWithPos>(
pages.map((p) => [
p.id,
{
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
children: [],
},
]),
);
for (const p of pages) {
const node = byId.get(p.id);
const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
if (node && parent && p.id !== rootId) {
parent.children.push(node);
}
}
const sortRecursive = (
nodes: SubpageNodeWithPos[],
): SubpageNodeWithPos[] => {
const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
sorted.forEach((n) => sortRecursive(n.children));
return sorted;
};
const root = byId.get(rootId);
return root ? sortRecursive(root.children) : [];
}
// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
return nodes.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
children: node.children ? mapSharedNodes(node.children) : [],
}));
}
// Count every descendant in a normalized subtree.
export function countNodes(nodes: SubpageNode[]): number {
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
}

View File

@@ -14,6 +14,10 @@ const Command = Extension.create({
return {
suggestion: {
char: '/',
// Keep the query alive through spaces so multi-word item labels
// (e.g. "Heading 1", "Math block") match instead of terminating the
// query and leaving literal "/Heading 1" text in the document.
allowSpaces: true,
command: ({ editor, range, props }) => {
props.command({ editor, range, props });
},
@@ -23,7 +27,22 @@ const Command = Extension.create({
if ($from.parent.type.name === 'codeBlock') {
return false;
}
return true;
// With `allowSpaces: true` a query that contains a space no longer
// terminates the suggestion on its own, so a space-bearing query that
// matches nothing (e.g. "/todo abc") would otherwise keep an empty
// popup logically active and leave the literal "/todo abc" text in the
// document, only dismissable via Escape. Deactivate the suggestion when
// no item matches the current query: returning false here removes the
// decoration, fires the popup's `onExit`, and lets subsequent keystrokes
// pass through normally — restoring the pre-`allowSpaces` behavior for
// non-matching queries while keeping multi-word matches (e.g.
// "/Heading 1") working.
const query = state.doc.textBetween(range.from + 1, range.to);
const groups = getSuggestionItems({ query });
const hasMatches = Object.values(groups).some(
(items) => items.length > 0,
);
return hasMatches;
},
} as Partial<SuggestionOptions>,
};

View File

@@ -0,0 +1,316 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { getDefaultStore } from "jotai";
import { WebSocketStatus } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core";
import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
getSpaceById,
getSpaces,
} from "@/features/space/services/space-service.ts";
import {
createPage,
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import {
GitmostBridge,
GitmostCreatePagePayload,
GitmostCreatePageResult,
GitmostListPagesPayload,
GitmostListPagesResult,
GitmostListSpacesResult,
gitmostDecodePayloadToFile,
gitmostUploadFileToEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
// How long to wait for a freshly-navigated page's editor to mount, become
// editable, and connect its Yjs provider before giving up.
const GITMOST_EDITOR_READY_TIMEOUT_MS = 20000;
const GITMOST_EDITOR_POLL_INTERVAL_MS = 120;
// Poll the (default) jotai store until the editor for `pageId` is mounted,
// editable and its Yjs provider is connected. Resolves the live editor, or null
// on timeout. Reuses pageEditorAtom + yjsConnectionStatusAtom — the same signals
// PageEditor maintains. The storage.pageId check guards against matching a stale
// editor left over from the previously-open page.
function gitmostWaitForEditor(
pageId: string,
timeoutMs: number,
): Promise<Editor | null> {
const store = getDefaultStore();
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const check = () => {
const editor = store.get(pageEditorAtom) as Editor | null;
const yjsStatus = store.get(yjsConnectionStatusAtom);
// `storage.pageId` is a custom field PageEditor.onCreate sets; it is not
// part of Tiptap's Storage type, so read it through an indexed cast.
const editorPageId = (
editor?.storage as unknown as Record<string, unknown> | undefined
)?.pageId;
const ready =
!!editor &&
!editor.isDestroyed &&
editor.isEditable &&
editorPageId === pageId &&
yjsStatus === WebSocketStatus.Connected;
if (ready) {
resolve(editor);
return;
}
if (Date.now() >= deadline) {
resolve(null);
return;
}
setTimeout(check, GITMOST_EDITOR_POLL_INTERVAL_MS);
};
check();
});
}
// Registers the global gitmost bridge methods that work WITHOUT an open page
// (listSpaces / listPages / createPageWithRecording). Mounted once at the
// app-shell level so the react-router navigate fn and the api-client are
// available even when no page editor is mounted. insertRecording stays in
// PageEditor (tied to the live editable editor). Renders nothing.
export default function GitmostGlobalBridge() {
const navigate = useNavigate();
// The effect registers the bridge once; reading the latest navigate via a ref
// avoids a stale closure if react-router hands back a new function identity.
const navigateRef = useRef(navigate);
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
useEffect(() => {
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
w.gitmost = w.gitmost || {};
// Advertise the bridge version even before any page editor mounts; do not
// clobber a value already set by an active PageEditor.
if (typeof w.gitmost.version !== "number") w.gitmost.version = 1;
const listSpaces = async (): Promise<GitmostListSpacesResult> => {
try {
const res = await getSpaces({ limit: 100 });
const spaces = (res?.items ?? []).map((s) => ({
id: s.id,
name: s.name,
}));
// v1 returns only the first page; flag truncation so the host knows
// more spaces exist.
const truncated = Boolean(res?.meta?.hasNextPage);
return { ok: true, spaces, truncated };
} catch (err: any) {
console.error("[gitmost] listSpaces failed", err);
return {
ok: false,
error: "list-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to list spaces",
};
}
};
const listPages = async (
payload: GitmostListPagesPayload,
): Promise<GitmostListPagesResult> => {
try {
const spaceId = payload?.spaceId;
if (!spaceId) {
return {
ok: false,
error: "bad-args",
message: "spaceId is required",
};
}
const res = await getSidebarPages({
spaceId,
pageId: payload?.parentPageId,
limit: 100,
});
const pages = (res?.items ?? []).map((p) => ({
id: p.id,
title: p.title,
hasChildren: Boolean(p.hasChildren),
}));
// v1 returns only the first page of children; flag truncation so the
// host knows more exist.
const truncated = Boolean(res?.meta?.hasNextPage);
return { ok: true, pages, truncated };
} catch (err: any) {
console.error("[gitmost] listPages failed", err);
return {
ok: false,
error: "list-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to list pages",
};
}
};
const createPageWithRecording = async (
payload: GitmostCreatePagePayload,
): Promise<GitmostCreatePageResult> => {
try {
const { spaceId, parentPageId, title, base64, filename, mimeType } =
payload || ({} as GitmostCreatePagePayload);
if (!spaceId) {
return {
ok: false,
error: "no-space",
message: "spaceId is required",
};
}
// Validate/decode the recording BEFORE creating the page so a bad
// payload never leaves an empty junk page behind. Per the createPage
// error contract, any decode failure collapses to "insert-failed" (the
// real reason is kept in `message`).
const decoded = gitmostDecodePayloadToFile({
base64,
filename,
mimeType,
});
if ("error" in decoded) {
return {
ok: false,
error: "insert-failed",
message: decoded.error.message ?? "Invalid recording payload",
};
}
// Resolve the space slug (needed for router navigation); also a
// permission/existence probe -> no-space on failure.
let spaceSlug: string | undefined;
try {
const space = await getSpaceById(spaceId);
spaceSlug = space?.slug;
} catch (err: any) {
console.error("[gitmost] resolve space failed", err);
return {
ok: false,
error: "no-space",
message:
err?.response?.data?.message ??
err?.message ??
"Space not found or no access",
};
}
if (!spaceSlug) {
return {
ok: false,
error: "no-space",
message: "Space not found or no access",
};
}
// Create the page (REST). Default title when none is provided.
const defaultTitle = `Recording ${new Date().toLocaleString()}`;
let page;
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared IPage type; cast to satisfy the createPage signature.
page = await createPage({
spaceId,
parentPageId: parentPageId ?? undefined,
title: title ?? defaultTitle,
} as any);
} catch (err: any) {
console.error("[gitmost] createPage failed", err);
return {
ok: false,
error: "create-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to create page",
};
}
if (!page?.id || !page?.slugId) {
return {
ok: false,
error: "create-failed",
message: "Failed to create page",
};
}
// Reset the shared Yjs status before navigating. The atom is global and
// is NOT reset when a PageEditor unmounts, so it can still hold
// "connected" from a previously-open page; clearing it ensures the
// readiness gate below waits for the NEW page's provider to connect.
getDefaultStore().set(yjsConnectionStatusAtom, "");
// Navigate via the router (no full reload).
navigateRef.current(buildPageUrl(spaceSlug, page.slugId, page.title));
// Wait for the new page's editor: mounted, editable, Yjs connected.
const editor = await gitmostWaitForEditor(
page.id,
GITMOST_EDITOR_READY_TIMEOUT_MS,
);
if (!editor) {
return {
ok: false,
error: "editor-timeout",
message: "Editor was not ready in time",
// Return pageId so the host can still surface the created page.
pageId: page.id,
};
}
// Same insert path as insertRecording.
const result = await gitmostUploadFileToEditor(
editor,
page.id,
decoded.file,
);
if (!result.ok) {
return {
ok: false,
error: "insert-failed",
message: result.message ?? "Failed to insert recording",
pageId: page.id,
};
}
return { ok: true, pageId: page.id };
} catch (err: any) {
console.error("[gitmost] createPageWithRecording failed", err);
return {
ok: false,
error: "insert-failed",
message:
err?.response?.data?.message ??
err?.message ??
"Failed to create page with recording",
};
}
};
w.gitmost.listSpaces = listSpaces;
w.gitmost.listPages = listPages;
w.gitmost.createPageWithRecording = createPageWithRecording;
return () => {
// Only remove our own registrations (defensive against a future second
// mount having replaced them).
if (w.gitmost) {
if (w.gitmost.listSpaces === listSpaces) delete w.gitmost.listSpaces;
if (w.gitmost.listPages === listPages) delete w.gitmost.listPages;
if (w.gitmost.createPageWithRecording === createPageWithRecording) {
delete w.gitmost.createPageWithRecording;
}
}
};
}, []);
return null;
}

View File

@@ -0,0 +1,263 @@
import { Editor } from "@tiptap/core";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
// --- gitmost native bridge: shared types & helpers ------------------------
// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
// WKWebView). This module holds the parts shared between the open-page bridge
// (insertRecording, in page-editor.tsx) and the global bridge (gitmost-global-
// bridge.tsx): payload decoding/validation and the audio-insert pipeline, so
// both apply identical rules without depending on editor internals.
export interface GitmostInsertRecordingPayload {
base64: string; // raw file bytes, base64 (no data: prefix)
filename: string;
mimeType: string; // must be an audio/* type
}
export interface GitmostInsertRecordingResult {
ok: boolean;
attachmentId?: string;
// Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
error?: string;
message?: string; // human-readable, may be surfaced by the host
}
export interface GitmostSpaceSummary {
id: string;
name: string;
}
export interface GitmostListSpacesResult {
ok: boolean;
spaces?: GitmostSpaceSummary[];
// v1 lists only the first page of spaces; true when more exist server-side.
truncated?: boolean;
error?: string;
message?: string;
}
export interface GitmostListPagesPayload {
spaceId: string;
parentPageId?: string;
}
export interface GitmostPageSummary {
id: string;
title: string;
hasChildren: boolean;
}
export interface GitmostListPagesResult {
ok: boolean;
pages?: GitmostPageSummary[];
// v1 lists only the first page of children; true when more exist server-side.
truncated?: boolean;
error?: string;
message?: string;
}
export interface GitmostCreatePagePayload {
spaceId: string;
parentPageId?: string; // omit/null = space root
title?: string; // default "Recording <timestamp>"
base64: string;
filename: string;
mimeType: string;
}
export interface GitmostCreatePageResult {
ok: boolean;
pageId?: string;
// Machine-readable code: "no-space" | "create-failed" | "editor-timeout" | "insert-failed"
error?: string;
message?: string;
}
// Full bridge surface exposed on `window.gitmost`. Writers attach a subset
// (Partial), so readonly/share pages and no-page states are valid.
export interface GitmostBridge {
ready: boolean;
version: number;
insertRecording: (
payload: GitmostInsertRecordingPayload,
) => Promise<GitmostInsertRecordingResult>;
listSpaces: () => Promise<GitmostListSpacesResult>;
listPages: (payload: GitmostListPagesPayload) => Promise<GitmostListPagesResult>;
createPageWithRecording: (
payload: GitmostCreatePagePayload,
) => Promise<GitmostCreatePageResult>;
}
// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
// oversized payload can be rejected before the buffer is allocated.
export function gitmostEstimateBase64Bytes(base64: string): number {
const len = base64.length;
if (len === 0) return 0;
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.floor((len * 3) / 4) - padding;
}
// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
// with no embedded whitespace (per the native-host contract). Throws
// InvalidCharacterError on malformed input.
export function gitmostBase64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
const parts: Uint8Array[] = [];
let total = 0;
for (let i = 0; i < base64.length; i += CHUNK) {
const binary = atob(base64.slice(i, i + CHUNK));
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) {
bytes[j] = binary.charCodeAt(j);
}
parts.push(bytes);
total += bytes.length;
}
// Back the result with an explicit ArrayBuffer so the view is typed
// Uint8Array<ArrayBuffer> (not ArrayBufferLike), which `new File([...])`
// accepts as a BlobPart under the lib.dom typings.
const out = new Uint8Array(new ArrayBuffer(total));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
// Decode + validate a recording payload into a File, or return an error result.
// Shared so insertRecording (open page) and createPageWithRecording (no page
// open) apply identical validation. Error codes: "bad-type" | "too-large" |
// "insert-failed".
export function gitmostDecodePayloadToFile(
payload: GitmostInsertRecordingPayload,
): { file: File } | { error: GitmostInsertRecordingResult } {
const { filename, mimeType } =
payload || ({} as GitmostInsertRecordingPayload);
let base64 = payload?.base64;
if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
return {
error: { ok: false, error: "bad-type", message: "Not an audio file" },
};
}
if (typeof base64 !== "string" || base64.length === 0) {
return {
error: { ok: false, error: "insert-failed", message: "Empty payload" },
};
}
// Defensively strip an accidental data:*;base64, prefix.
const marker = base64.indexOf("base64,");
if (base64.startsWith("data:") && marker !== -1) {
base64 = base64.slice(marker + "base64,".length);
}
const sizeLimit = getFileUploadSizeLimit();
// Reject oversized payloads before allocating the decode buffer.
if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
return {
error: {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
},
};
}
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = gitmostBase64ToBytes(base64);
} catch (decodeErr: any) {
return {
error: {
ok: false,
error: "insert-failed",
message: decodeErr?.message ?? "Invalid base64 payload",
},
};
}
const file = new File([bytes], filename || "recording", { type: mimeType });
// Exact size check (the pre-decode estimate is approximate).
if (file.size > sizeLimit) {
return {
error: {
ok: false,
error: "too-large",
message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
},
};
}
return { file };
}
// Insert an already-decoded recording File into a live editor via the existing
// audio pipeline (placeholder -> POST /api/files/upload -> `audio` node,
// Yjs-synced). Returns the attachment id on success.
export async function gitmostUploadFileToEditor(
editor: Editor,
pageId: string,
file: File,
): Promise<GitmostInsertRecordingResult> {
try {
// Insert at the cursor, falling back to the end of the document.
const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
// uploadAudioAction returns the attachment on success and undefined when
// the upload failed (the pipeline swallows the upload error and shows its
// own notification).
const attachment = (await (uploadAudioAction(
file,
editor,
pos,
pageId,
) as unknown as Promise<{ id?: string } | undefined>));
if (attachment?.id) {
return { ok: true, attachmentId: attachment.id };
}
return { ok: false, error: "insert-failed", message: "Upload failed" };
} catch (err: any) {
// Never swallow: log the raw error and surface the real reason.
console.error("[gitmost] audio upload into editor failed", err);
return {
ok: false,
error: "insert-failed",
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
};
}
}
// Full insert path used by the open-page bridge (insertRecording): guard the
// editor, validate/decode the payload, then upload. Never throws — resolves to
// a result code.
export async function gitmostInsertRecordingIntoEditor(
editor: Editor | null,
pageId: string,
payload: GitmostInsertRecordingPayload,
): Promise<GitmostInsertRecordingResult> {
try {
// Only a live, editable editor may receive a recording.
if (!editor || editor.isDestroyed || !editor.isEditable) {
return { ok: false, error: "no-editor", message: "No editable page open" };
}
const decoded = gitmostDecodePayloadToFile(payload);
if ("error" in decoded) return decoded.error;
return await gitmostUploadFileToEditor(editor, pageId, decoded.file);
} catch (err: any) {
// The bridge must never throw — surface any unexpected failure as a code.
console.error("[gitmost] insertRecording failed", err);
return {
ok: false,
error: "insert-failed",
message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
};
}
}

View File

@@ -49,6 +49,7 @@ import { TableHandlesLayer } from "@/features/editor/components/table/handle/tab
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import AudioMenu from "@/features/editor/components/audio/audio-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
@@ -65,6 +66,12 @@ import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import {
GitmostBridge,
GitmostInsertRecordingPayload,
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
@@ -113,6 +120,13 @@ export default function PageEditor({
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// Always holds the latest collab token. The provider effect below runs once
// per pageId, so a handler created inside it would otherwise close over a
// stale `collabQuery`. Reading the ref gives the current token instead.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
@@ -167,20 +181,33 @@ export default function PageEditor({
}
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
// Read the latest token via the ref (the closure-captured `collabQuery`
// may be stale). Guard the decode: a missing or unparseable token must
// not throw "Invalid token specified" and should trigger a refresh so
// the editor reconnects even when the initial token fetch failed.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
// A token that decodes but lacks a numeric `exp` must be treated as
// expired (`Date.now()/1000 >= undefined` is `false`, which would
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
const exp = jwtDecode<{ exp?: number }>(token).exp;
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
} catch {
needsRefresh = true;
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
@@ -333,6 +360,39 @@ export default function PageEditor({
},
});
// Expose the gitmost native bridge only while an editable page editor is
// mounted. Registering/tearing down here ties `ready` + `insertRecording`
// to the lifetime of the current editable editor: readonly/share pages and
// page switches re-run this effect (deps: live editable flag + pageId),
// recreating the closure over the active editor/pageId so a recording always
// targets whatever page is active at call time.
useEffect(() => {
if (!editor || !editor.isEditable) return;
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
w.gitmost = w.gitmost || {};
w.gitmost.version = 1;
w.gitmost.ready = true;
const insertRecording = (
payload: GitmostInsertRecordingPayload,
): Promise<GitmostInsertRecordingResult> =>
gitmostInsertRecordingIntoEditor(editor, pageId, payload);
w.gitmost.insertRecording = insertRecording;
return () => {
// Only tear down if our registration is still the active one. With
// React's mount-before-unmount ordering, a newer PageEditor instance may
// have already replaced the bridge; clearing it here would disable the
// live editor's bridge.
if (w.gitmost && w.gitmost.insertRecording === insertRecording) {
w.gitmost.ready = false;
delete w.gitmost.insertRecording;
}
};
}, [editor, pageId, editorIsEditable]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@@ -441,6 +501,7 @@ export default function PageEditor({
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<AudioMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />

View File

@@ -1,5 +1,9 @@
.ProseMirror {
.codeBlock {
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
editable contentDOM is first) is lifted back above the code via `order`. */
display: flex;
flex-direction: column;
padding: 4px;
border-radius: var(--mantine-radius-default);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));

View File

@@ -13,6 +13,8 @@ import {
ToggleFavoriteParams,
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
export function useAddFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data),
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Added to favorites") });
},
});
}
export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data),
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Removed from favorites") });
},
});
}

View File

@@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t
import { useTranslation } from "react-i18next";
import { zod4Resolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
type FormValues = {
name: string;
description: string;
};
export function CreateGroupForm() {
const { t } = useTranslation();
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
// Build the schema with friendly, translated validation messages (issue #130)
const formSchema = z.object({
name: z
.string()
.trim()
.min(2, t("Group name must be at least 2 characters"))
.max(100, t("Group name must be 100 characters or fewer")),
description: z
.string()
.max(500, t("Description must be 500 characters or fewer")),
});
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {

View File

@@ -41,7 +41,7 @@ export default function GroupMembersList() {
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { canCreatePage } from "./can-create-page.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
// Unit tests for `canCreatePage` (new-note-button.tsx). The home screen has no
// active space, so the "New note" button resolves its target from the user's
// writable spaces. This predicate mirrors the server space-ability mapping
// (ADMIN/WRITER can manage pages, READER is read-only). The /spaces list endpoint
// only returns membership.role (not CASL permissions), so a regression here would
// either hide the button for legitimate writers or offer it to read-only members.
function spaceWithRole(role?: SpaceRole): ISpace {
// Only `membership.role` is consulted by the predicate; the rest is filler.
return {
membership: role ? ({ role } as any) : undefined,
} as ISpace;
}
describe("canCreatePage", () => {
it("is true for ADMIN and WRITER roles", () => {
expect(canCreatePage(spaceWithRole(SpaceRole.ADMIN))).toBe(true);
expect(canCreatePage(spaceWithRole(SpaceRole.WRITER))).toBe(true);
});
it("is false for the READER role", () => {
expect(canCreatePage(spaceWithRole(SpaceRole.READER))).toBe(false);
});
it("is false when membership / role is missing", () => {
expect(canCreatePage(spaceWithRole(undefined))).toBe(false);
expect(canCreatePage({} as ISpace)).toBe(false);
});
it("filters an empty space list down to nothing writable", () => {
const spaces: ISpace[] = [
spaceWithRole(SpaceRole.READER),
spaceWithRole(undefined),
];
expect(spaces.filter(canCreatePage)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,15 @@
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
// The /spaces list endpoint returns membership.role but NOT membership.permissions
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
// for the current user when their role is ADMIN or WRITER.
//
// Extracted from new-note-button.tsx into this pure sibling module so it can be
// unit-tested without importing the component (whose dependency chain pulls in
// main.tsx and renders the whole app at import time).
export function canCreatePage(space: ISpace): boolean {
const role = space.membership?.role;
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
}

View File

@@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) {
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -84,6 +84,7 @@ export default function FavoritesPages({ spaceId }: Props) {
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -6,18 +6,9 @@ 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;
}
import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's

View File

@@ -78,6 +78,8 @@ export function useAddLabelsMutation(pageId: string | undefined) {
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Label added") });
},
onError: (error: any) => {
notifications.show({
@@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) {
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Label removed") });
},
onError: () => {
notifications.show({

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { Schema } from "@tiptap/pm/model";
import { computeHistoryDiff } from "./history-diff.ts";
// Unit tests for `computeHistoryDiff` (history-diff.ts) — the pure core extracted
// from history-editor.tsx. Given the editor schema plus old/new ProseMirror
// document JSON it produces {decorationSet, added, deleted, total}: inline
// decorations for text edits, whole-node decorations for added block nodes
// (image/table), widget "ghosts" for deleted block nodes (callout), and an empty
// diff for the first version or malformed JSON.
//
// We drive it with a hand-built ProseMirror schema rather than the real
// `mainExtensions` because importing the editor extensions pulls in the whole app
// (main.tsx) at module load. The schema below mirrors the relevant shape: a doc of
// block content, an `image` block atom and a `table` block treated as whole-node
// diffs, and a `callout` block treated as a deletable whole node.
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: {
group: "block",
content: "inline*",
toDOM: () => ["p", 0],
},
callout: {
group: "block",
content: "inline*",
toDOM: () => ["div", { class: "callout" }, 0],
},
image: {
group: "block",
atom: true,
attrs: { src: { default: "" } },
toDOM: (node) => ["img", { src: node.attrs.src }],
},
table: {
group: "block",
content: "paragraph+",
toDOM: () => ["table", ["tbody", 0]],
},
text: { group: "inline" },
},
});
const para = (text: string) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
});
const docOf = (...blocks: any[]) => ({ type: "doc", content: blocks });
describe("computeHistoryDiff", () => {
it("returns an empty diff (counts 0) when there is no previous version", () => {
const diff = computeHistoryDiff(schema, docOf(para("hello")), undefined);
expect(diff.added).toBe(0);
expect(diff.deleted).toBe(0);
expect(diff.total).toBe(0);
expect(diff.decorationSet.find()).toHaveLength(0);
});
it("returns an empty diff when content is missing", () => {
const diff = computeHistoryDiff(schema, undefined, docOf(para("x")));
expect(diff.total).toBe(0);
});
it("emits inline decorations and counts for a text edit", () => {
const prev = docOf(para("hello world"));
const next = docOf(para("hello brave world"));
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
const decos = diff.decorationSet.find();
expect(decos.length).toBeGreaterThan(0);
// An inline text addition is rendered with the inline-added class.
const classes = decos.map((d) => (d.spec as any)?.class ?? (d as any).type?.attrs?.class);
const hasInline = JSON.stringify(decos).includes("history-diff-added") ||
classes.some((c) => c === "history-diff-added");
expect(hasInline).toBe(true);
});
it("treats an added image as a whole-node addition", () => {
const prev = docOf(para("text"));
const next = docOf(para("text"), { type: "image", attrs: { src: "a.png" } });
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
expect(JSON.stringify(diff.decorationSet.find())).toContain(
"history-diff-node-added",
);
});
it("treats an added table as a whole-node addition", () => {
const prev = docOf(para("text"));
const next = docOf(para("text"), {
type: "table",
content: [para("cell")],
});
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
expect(JSON.stringify(diff.decorationSet.find())).toContain(
"history-diff-node-added",
);
});
it("renders a widget ghost for a deleted callout", () => {
const prev = docOf(para("text"), {
type: "callout",
content: [{ type: "text", text: "warning" }],
});
const next = docOf(para("text"));
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.deleted).toBeGreaterThan(0);
// The deleted whole node produces a widget decoration (toDOM callback).
const decos = diff.decorationSet.find();
expect(decos.some((d) => (d as any).type?.toDOM || (d as any).type?.widget)).toBe(
true,
);
});
it("falls back to an empty diff (no throw) on malformed version JSON", () => {
const malformed = { type: "doc", content: [{ type: "nonexistent-node" }] };
expect(() =>
computeHistoryDiff(schema, malformed, docOf(para("x"))),
).not.toThrow();
const diff = computeHistoryDiff(schema, malformed, docOf(para("x")));
expect(diff.total).toBe(0);
expect(diff.decorationSet.find()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,168 @@
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { DOMSerializer, Node, Schema } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { recreateTransform } from "@docmost/editor-ext";
export interface HistoryDiff {
decorationSet: DecorationSet;
added: number;
deleted: number;
total: number;
}
// Block-level nodes that are diffed as a whole ("this image/table/callout was
// added/removed") instead of by inline character ranges.
const SPECIAL_NODE_TYPES = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
// Pure core of the history diff (extracted from history-editor.tsx, behaviour
// preserving): given the editor schema and two ProseMirror document JSONs, return
// the decoration set plus added/deleted/total counts. The widget decorations carry
// lazy DOM-building callbacks (only run by ProseMirror at render time), so this
// function itself does no DOM work and needs no live editor instance.
//
// `previousContent` undefined -> first version, so there is nothing to diff
// (empty decorations, all counts 0). Malformed JSON that throws while building
// nodes falls back to the same empty diff so the caller can still render plain
// content without crashing.
export function computeHistoryDiff(
schema: Schema,
content: any,
previousContent?: any,
): HistoryDiff {
const empty: HistoryDiff = {
decorationSet: DecorationSet.empty,
added: 0,
deleted: 0,
total: 0,
};
if (!content || !previousContent) {
return empty;
}
try {
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
const decorations: Decoration[] = [];
let addedCount = 0;
let deletedCount = 0;
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const special = foundSpecialNode as { node: Node; pos: number };
const nodeEnd = special.pos + special.node.nodeSize;
decorations.push(
Decoration.node(special.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
const deletedNode = foundDeletedNode as { node: Node; pos: number };
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(deletedNode.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
const decorationSet = DecorationSet.create(newContent, decorations);
const total = addedCount + deletedCount;
return { decorationSet, added: addedCount, deleted: deletedCount, total };
} catch (e) {
// Malformed version JSON: fall back to a plain (no-diff) render.
console.error("History diff failed:", e);
return empty;
}
}

View File

@@ -3,11 +3,9 @@ import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { computeHistoryDiff } from "./history-diff.ts";
import { useAtom } from "jotai";
import {
diffCountsAtom,
@@ -36,142 +34,18 @@ export function HistoryEditor({
useEffect(() => {
if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
// Pure diff computation lives in history-diff.ts; the component keeps the
// editor side-effects (rendering the new content + wiring decorations).
const { decorationSet, added, deleted, total } = computeHistoryDiff(
editor.schema,
content,
previousContent,
);
if (previousContent) {
try {
const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
editor.commands.setContent(content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const nodeEnd =
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
decorations.push(
Decoration.node(foundSpecialNode.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(foundDeletedNode!.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content);
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
setDiffCounts({ added, deleted, total });
editor.setOptions({
editorProps: {

View File

@@ -1,18 +1,12 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip, Badge } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
const MAX_VISIBLE_AVATARS = 5;
@@ -26,87 +20,6 @@ interface HistoryItemProps {
isActive: boolean;
}
/**
* Badge marking a version written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. When the
* version carries an `aiChatId`, clicking the badge deep-links into that chat:
* it sets the active-chat atom, opens the floating AI-chat window, and closes
* the history modal. The click is contained (stopPropagation) so it does not
* also trigger the row's version-select.
*/
function AiAgentBadge({
authorName,
aiChatId,
}: {
authorName?: string;
aiChatId?: string | null;
}) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
setHistoryModalOpen(false);
},
[
aiChatId,
setActiveChatId,
setDraft,
setAiChatWindowOpen,
setHistoryModalOpen,
],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside the history row's
// UnstyledButton; expose it as an accessible button via role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
const HistoryItem = memo(function HistoryItem({
historyItem,
index,
@@ -115,6 +28,8 @@ const HistoryItem = memo(function HistoryItem({
onHoverEnd,
isActive,
}: HistoryItemProps) {
const setHistoryModalOpen = useSetAtom(historyAtoms);
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
@@ -188,6 +103,9 @@ const HistoryItem = memo(function HistoryItem({
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
</Group>

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconArrowRight,
IconArrowsHorizontal,
@@ -10,7 +10,6 @@ import {
IconLink,
IconList,
IconMarkdown,
IconMessage,
IconPrinter,
IconStar,
IconStarFilled,
@@ -102,18 +101,21 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && <PageEditModeToggle size="xs" />}
{!workspaceSharingDisabled && <ShareModal readOnly={readOnly ?? false} />}
{/* Hide the Share entry point for readers; the toggle inside is inert
without edit permission, so gate it like other edit-only actions
(issue #133) */}
{!readOnly && !workspaceSharingDisabled && (
<ShareModal readOnly={false} />
)}
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Button
variant="subtle"
color="dark"
size="compact-sm"
{...commentsTriggerProps}
>
{t("Comments")}
</Button>
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
@@ -286,7 +288,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconArrowRight size={16} />}
onClick={openMovePageModal}
>
{t("Move")}
{t("Move to space")}
</Menu.Item>
)}

View File

@@ -21,6 +21,7 @@ import {
getAllSidebarPages,
getDeletedPages,
restorePage,
getSpaceTree,
} from "@/features/page/services/page-service";
import {
IMovePage,
@@ -303,6 +304,15 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
});
}
export function useGetPageTreeQuery(pageId: string) {
return useQuery({
queryKey: ["page-tree", pageId],
queryFn: () => getSpaceTree({ pageId }),
enabled: !!pageId,
staleTime: 30 * 1000,
});
}
export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
@@ -363,7 +373,18 @@ export function useDeletedPagesQuery(
});
}
/**
* Invalidate every cached page-subtree (the recursive `subpages` node, issue
* #150). Called from each tree-structure cache helper below so a create / move /
* rename / delete (local OR websocket-echoed) refreshes any open recursive tree.
* Keyed loosely (`["page-tree"]` prefix) so all subtrees are caught.
*/
function invalidatePageTree() {
queryClient.invalidateQueries({ queryKey: ["page-tree"] });
}
export function invalidateOnCreatePage(data: Partial<IPage>) {
invalidatePageTree();
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
hasChildren: data.hasChildren,
@@ -478,6 +499,7 @@ export function invalidateOnUpdatePage(
title: string,
icon: string,
) {
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
@@ -516,6 +538,7 @@ export function updateCacheOnMovePage(
newParentId: string | null,
pageData: Partial<IPage>,
) {
invalidatePageTree();
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
@@ -633,6 +656,7 @@ export function updateCacheOnMovePage(
}
export function invalidateOnDeletePage(pageId: string) {
invalidatePageTree();
//update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>

View File

@@ -93,7 +93,7 @@ export async function getAllSidebarPages(
}
export async function getSpaceTree(params: {
spaceId: string;
spaceId?: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);

View File

@@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
@@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openExportModal();
}}
>
{t("Export page")}
{t("Export")}
</Menu.Item>
{canEdit && (
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openMovePageModal();
}}
>
{t("Move")}
{t("Move to space")}
</Menu.Item>
<Menu.Item

View File

@@ -170,7 +170,7 @@ export function SpaceTreeRow({
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
<span className={classes.text}>{node.name || t("Untitled")}</span>
{node.isTemplate === true && (
<Tooltip label={t("Template")} withArrow>
@@ -297,7 +297,7 @@ function CreateNode({
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();

View File

@@ -282,7 +282,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
[],
);
const getDragLabel = useCallback(
(n: SpaceTreeNode) => n.name || t("untitled"),
(n: SpaceTreeNode) => n.name || t("Untitled"),
[t],
);

View File

@@ -51,7 +51,7 @@ export function findBreadcrumbPath(
): SpaceTreeNode[] | null {
for (const node of tree) {
if (!node.name || node.name.trim() === "") {
node.name = "untitled";
node.name = "Untitled";
}
if (node.id === pageId) {

View File

@@ -107,48 +107,55 @@ export function SearchSpotlightFilters({
</Button>
</SpaceFilterMenu>
<Menu
shadow="md"
width={220}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconFileDescription size={16} />}
className={classes.filterButton}
fw={500}
>
{contentType
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
: t("Type")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
contentType !== option.value &&
handleFilterChange("contentType", option.value)
}
{/* Only render the content-type dropdown when there is more than one
option to choose from. With a single option ("Pages") it is a no-op
control, so we hide it instead of showing a dead filter. */}
{contentTypeOptions.length > 1 && (
<Menu
shadow="md"
width={220}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconFileDescription size={16} />}
className={classes.filterButton}
fw={500}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
</div>
{contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
{contentType
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
: t("Type")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
contentType !== option.value &&
handleFilterChange("contentType", option.value)
}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
</div>
{contentType === option.value && (
<IconCheck size={20} aria-hidden />
)}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</div>
);
}

View File

@@ -90,7 +90,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
{query.length > 0 && !isLoading
? resultItems.length === 0
? t("No results found")
: t("{{count}} results found", { count: resultItems.length })
: // Singular/plural handling so 1 result is not announced as
// "1 results found".
t("{{count}} result found", { count: resultItems.length })
: ""}
</VisuallyHidden>

View File

@@ -192,7 +192,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{getPageIcon(share.sharedPage.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
{share.sharedPage.title || t("Untitled")}
</Text>
</div>
</Group>

View File

@@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) {
return findSubpages(treeData);
}, [treeData, pageId]);
}
// Recursive variant for the subpages node in a shared/public context. The shared
// tree (`sharedTreeDataAtom`) is ALREADY fully nested, so a page's `children`
// each carry their own nested `children` — exactly what the recursive renderer
// needs. The data is therefore identical to the flat hook; only the rendering
// differs (the recursive view walks `children` instead of showing one level).
// Thin alias to avoid duplicating the lookup. No `/pages/tree` request here.
export const useSharedPageSubtree = useSharedPageSubpages;

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
// React Query forbids `undefined` as resolved query data ("Query data cannot be
// undefined"). The backend resolves to `undefined` when a page has no share, so
// `useShareForPageQuery` normalizes that absence to `null`:
// queryFn: async () => (await getShareForPage(pageId)) ?? null
// These tests pin that contract: the hook must resolve to `null` (never
// `undefined`) when there is no share, and pass a real share through untouched.
// Mock the service module so the queryFn calls our stub instead of the network.
vi.mock("@/features/share/services/share-service.ts", () => ({
getShareForPage: vi.fn(),
// Other named exports referenced by share-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createShare: vi.fn(),
deleteShare: vi.fn(),
getSharedPageTree: vi.fn(),
getShareInfo: vi.fn(),
getSharePageInfo: vi.fn(),
getShares: vi.fn(),
updateShare: vi.fn(),
}));
import { getShareForPage } from "@/features/share/services/share-service.ts";
import { useShareForPageQuery } from "@/features/share/queries/share-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe("useShareForPageQuery — null normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("normalizes an absent share (undefined) to null", async () => {
vi.mocked(getShareForPage).mockResolvedValue(undefined as any);
const { result } = renderHook(() => useShareForPageQuery("page-1"), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// The key assertion: null, never undefined.
expect(result.current.data).toBeNull();
expect(result.current.data).not.toBeUndefined();
});
it("normalizes an absent share (null) to null", async () => {
vi.mocked(getShareForPage).mockResolvedValue(null as any);
const { result } = renderHook(() => useShareForPageQuery("page-2"), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBeNull();
});
it("passes an existing share through unchanged", async () => {
const share = { id: "share-1", pageId: "page-3" } as any;
vi.mocked(getShareForPage).mockResolvedValue(share);
const { result } = renderHook(() => useShareForPageQuery("page-3"), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(share);
});
});

View File

@@ -65,10 +65,13 @@ export function useSharePageQuery(
export function useShareForPageQuery(
pageId: string,
): UseQueryResult<IShareForPage, Error> {
): UseQueryResult<IShareForPage | null, Error> {
const query = useQuery({
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
// React Query forbids `undefined` as resolved data ("Query data cannot be
// undefined"). When no share exists for the page the endpoint resolves to
// undefined, so normalize the absence to `null`.
queryFn: async () => (await getShareForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,

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