Compare commits

...

14 Commits

Author SHA1 Message Date
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
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
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
47 changed files with 2163 additions and 61 deletions

View File

@@ -157,6 +157,19 @@ below.
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI | | `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | The original Docmost — **never push** | | `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 # Architecture and codebase

View File

@@ -1147,6 +1147,12 @@
"Ask a question about this documentation.": "Ask a question about this documentation.", "Ask a question about this documentation.": "Ask a question about this documentation.",
"Ask a question…": "Ask a question…", "Ask a question…": "Ask a question…",
"Thinking…": "Thinking…", "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.", "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant", "Public share assistant": "Public share assistant",
"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.", "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.",
@@ -1158,6 +1164,7 @@
"Built-in assistant persona": "Built-in assistant persona", "Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize", "Minimize": "Minimize",
"Current context size": "Current context size", "Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"AI agent": "AI agent", "AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document", "Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…", "AI agent is typing…": "AI agent is typing…",
@@ -1266,6 +1273,10 @@
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini", "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.", "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", "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.", "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", "No roles configured": "No roles configured",
@@ -1287,5 +1298,14 @@
"Analytics / tracker": "Analytics / tracker", "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", "Go to login page": "Go to login page",
"Move to space": "Move to space" "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

@@ -677,9 +677,21 @@
"Ask AI": "Спросить ИИ", "Ask AI": "Спросить ИИ",
"AI agent": "AI-агент", "AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ", "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-агент печатает…", "AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…", "{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…", "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": "Роль агента", "Agent role": "Роль агента",
"AI chat": "AI-чат", "AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.", "AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
@@ -690,6 +702,7 @@
"Copy chat": "Копировать чат", "Copy chat": "Копировать чат",
"Created successfully": "Успешно создано", "Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста", "Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"Delete this chat?": "Удалить этот чат?", "Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено", "Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}", "Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
@@ -1137,5 +1150,15 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}", "Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки", "Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение", "Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью." "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"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

@@ -156,6 +156,12 @@ export default function AiChatWindow() {
isStreaming: false, 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 // The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full // parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page" // pathname against the authenticated page route instead so "the current page"
@@ -485,11 +491,19 @@ export default function AiChatWindow() {
)} )}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}> <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> <Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span> <span className={classes.badge}>{formatTokens(contextTokens)}</span>
</Tooltip> </Tooltip>
)} ) : null}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -608,6 +622,7 @@ export default function AiChatWindow() {
assistantName={currentRole?.name} assistantName={currentRole?.name}
onTurnFinished={onTurnFinished} onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef} liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens}
/> />
)} )}
</div> </div>

View File

@@ -111,6 +111,24 @@
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); 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 { .inputWrapper {
flex: 0 0 auto; flex: 0 0 auto;
padding-top: var(--mantine-spacing-xs); padding-top: var(--mantine-spacing-xs);

View File

@@ -21,8 +21,13 @@ import {
IAiChatMessageRow, IAiChatMessageRow,
IAiRole, IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } 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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts"; import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { import {
dequeue, dequeue,
enqueueMessage, enqueueMessage,
@@ -69,6 +74,12 @@ interface ChatThreadProps {
* assistant message. A ref (not state) avoids re-rendering the parent on * assistant message. A ref (not state) avoids re-rendering the parent on
* every streamed delta. */ * every streamed delta. */
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>; 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;
} }
/** /**
@@ -113,6 +124,7 @@ export default function ChatThread({
assistantName, assistantName,
onTurnFinished, onTurnFinished,
liveStateRef, liveStateRef,
onLiveTurnTokens,
}: ChatThreadProps) { }: ChatThreadProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -310,21 +322,98 @@ export default function ChatThread({
}; };
}, [liveStateRef, messages, isStreaming]); }, [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 // Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead // (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong". // of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null; const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately // A role was picked with autoStart=false: the role is bound but NOTHING was
// starts the conversation. roleIdRef is set synchronously here because the // sent, so chatId stays null and the empty state would keep showing the cards.
// parent's selectedRoleId state update would only reach roleIdRef on the next // This flag hides the cards and reveals the composer (with the role indicated)
// render — after this synchronous sendMessage has already read it. // 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 => { const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id; roleIdRef.current = role.id;
onRolePicked?.(role); 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 ? ( const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} /> <RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined; ) : undefined;

View File

@@ -2,12 +2,14 @@ import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx"; import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx"; import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx"; import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts"; import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -77,12 +79,31 @@ export default function MessageItem({
// return won't fire for them. // return won't fire for them.
if (!assistantMessageHasVisibleContent(message)) return null; 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 ( return (
<Box className={classes.messageRow}> <Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}> <Text size="xs" c="dimmed" mb={4}>
{resolveAssistantName(assistantName) ?? t("AI agent")} {resolveAssistantName(assistantName) ?? t("AI agent")}
</Text> </Text>
{message.parts.map((part, index) => { {message.parts.map((part, index) => {
if (part.type === "reasoning") {
// 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") { if (part.type === "text") {
// Skip empty/whitespace-only text parts (a streaming message often // Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the // starts with an empty text part before the first token arrives); the

View File

@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx"; import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts"; 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"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps { interface MessageListProps {
@@ -94,6 +95,19 @@ export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
return !assistantMessageHasVisibleContent(last); 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, * 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 * but only while the user is pinned to the bottom — if they scrolled up to read
@@ -190,7 +204,13 @@ export default function MessageList({
assistantName={assistantName} assistantName={assistantName}
/> />
))} ))}
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />} {typing && (
<TypingIndicator
assistantName={assistantName}
showName={typingIndicatorShowsName(messages)}
thinkingTokens={tailThinkingTokens(messages)}
/>
)}
</Stack> </Stack>
</ScrollArea> </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

@@ -13,6 +13,8 @@ const roles: IAiRole[] = [
emoji: "🏴‍☠️", emoji: "🏴‍☠️",
description: "Talks like a pirate", description: "Talks like a pirate",
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
}, },
{ {
id: "r2", id: "r2",
@@ -20,6 +22,8 @@ const roles: IAiRole[] = [
emoji: null, emoji: null,
description: null, description: null,
enabled: true, 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

@@ -16,6 +16,12 @@ interface TypingIndicatorProps {
* assistant row above already shows the same name, to avoid a duplicate label. * assistant row above already shows the same name, to avoid a duplicate label.
*/ */
showName?: boolean; 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;
} }
/** /**
@@ -30,9 +36,14 @@ interface TypingIndicatorProps {
* typing line is always the generic "Thinking…" (it never includes the * typing line is always the generic "Thinking…" (it never includes the
* role/identity name). * role/identity name).
*/ */
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) { export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const name = resolveAssistantName(assistantName); 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 ( return (
<Box className={classes.messageRow}> <Box className={classes.messageRow}>
@@ -48,7 +59,7 @@ export default function TypingIndicator({ assistantName, showName = true }: Typi
<span /> <span />
</span> </span>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Thinking…")} {thinkingLine}
</Text> </Text>
</Group> </Group>
</Box> </Box>

View File

@@ -53,6 +53,10 @@ export interface IAiRole {
instructions?: string; instructions?: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled: boolean; 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; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
instructions: string; instructions: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
} }
/** Admin update payload for a role (partial). */ /** Admin update payload for a role (partial). */
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
instructions?: string; instructions?: string;
modelConfig?: IAiRoleModelConfig | null; modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
} }
/** /**
@@ -98,6 +106,10 @@ export interface IAiChatMessageRow {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
totalTokens?: 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. // 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 // how much the conversation occupies in the model's context window after this

View File

@@ -4,7 +4,7 @@
* ============================ CANONICAL #137 NOTE ============================ * ============================ CANONICAL #137 NOTE ============================
* This docblock is the single authoritative explanation of the new-chat id * 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 * adoption design and the #137 two-tab race it fixes. Other call sites
* (use-chat-session.ts, the server's `chatStreamStartMetadata`) reference here * (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
* rather than restating it. * rather than restating it.
* *
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat * When a user sends the first turn of a BRAND-NEW chat, the client has no chat
@@ -17,7 +17,7 @@
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways: * 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 * PRIMARY path: the server streams the real chat id on the assistant message
* metadata's `start` part (see `chatStreamStartMetadata` server-side); * metadata's `start` part (see `chatStreamMetadata` server-side);
* `extractServerChatId` reads it off the finished message and * `extractServerChatId` reads it off the finished message and
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is * `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
* authoritative and immune to the race. * authoritative and immune to the race.
@@ -46,7 +46,7 @@ export function resolveAdoptedChatId(
/** /**
* Read the authoritative server chat id off a finished assistant message. The * Read the authoritative server chat id off a finished assistant message. The
* server attaches it as `message.metadata.chatId` on the `start` part (see * server attaches it as `message.metadata.chatId` on the `start` part (see
* `chatStreamStartMetadata`). Returns it only when it is a string; undefined for * `chatStreamMetadata`). Returns it only when it is a string; undefined for
* a missing message, missing metadata, or a non-string `chatId`. * a missing message, missing metadata, or a non-string `chatId`.
*/ */
export function extractServerChatId( export function extractServerChatId(

View File

@@ -314,6 +314,57 @@ describe("buildChatMarkdown — token totals", () => {
}); });
expect(md).toContain("- Total tokens: 99"); 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", () => { describe("buildChatMarkdown — pending / in-progress messages", () => {

View File

@@ -77,6 +77,7 @@ function rowTokens(usage: {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
totalTokens?: number; totalTokens?: number;
reasoningTokens?: number;
}): number { }): number {
return ( return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
@@ -175,8 +176,14 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const usage = row.metadata?.usage; const usage = row.metadata?.usage;
if (usage) { if (usage) {
const total = usage.totalTokens ?? rowTokens(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( blocks.push(
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`, `_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
); );
} }
}); });

View File

@@ -0,0 +1,119 @@
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 });
});
});

View File

@@ -0,0 +1,94 @@
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.
*
* Prefers AUTHORITATIVE `metadata.usage` when the server has attached it (at a
* step/turn boundary, incl. `reasoningTokens`) — so the live counter snaps to the
* provider's exact figures. Until then it returns a running ESTIMATE summed over
* the message parts: `reasoning` parts feed the reasoning estimate, `text` parts
* feed the output estimate. Multi-part / multi-step turns accumulate naturally
* because every part of the turn is summed.
*
* Providers that don't stream reasoning text still surface a reasoning count once
* the authoritative usage arrives (`usage.reasoningTokens`); on the pure estimate
* path such a turn simply shows `reasoning: 0` until then.
*/
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
if (!message) return { reasoning: 0, output: 0, authoritative: false };
const usage = metadataUsage(message);
if (usage) {
// Authoritative branch: outputTokens already INCLUDES reasoning tokens in the
// AI SDK usage shape, so subtract reasoning out for the "answer" figure (never
// go negative if a provider reports them inconsistently).
const reasoning = usage.reasoningTokens ?? 0;
const totalOutput = usage.outputTokens ?? 0;
const output = Math.max(0, totalOutput - reasoning);
return { reasoning, output, authoritative: true };
}
let reasoning = 0;
let output = 0;
for (const part of message.parts ?? []) {
if (part.type === "reasoning") {
reasoning += estimateTokens((part as { text?: string }).text ?? "");
} else if (part.type === "text") {
output += estimateTokens((part as { text?: string }).text ?? "");
}
}
return { reasoning, output, authoritative: 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

@@ -73,3 +73,18 @@
display: none !important; 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

@@ -13,6 +13,8 @@ import {
IconLayoutAlignCenter, IconLayoutAlignCenter,
IconLayoutAlignLeft, IconLayoutAlignLeft,
IconLayoutAlignRight, IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconDownload, IconDownload,
IconRefresh, IconRefresh,
IconTrash, IconTrash,
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }), isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }), 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, src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "", alt: imageAttrs?.alt || "",
}; };
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run(); .run();
}, [editor]); }, [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(() => { const handleDownload = useCallback(() => {
if (!editorState?.src) return; if (!editorState?.src) return;
const url = getFileUrl(editorState.src); const url = getFileUrl(editorState.src);
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon> </ActionIcon>
</Tooltip> </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} /> <div className={classes.divider} />
{altTextButton} {altTextButton}

View File

@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).insertSubpages().run(); 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", title: "Synced block",
description: "Create a block that stays in sync across pages.", 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 { 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 { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]); }, [editor]);
const toggleRecursive = useCallback(() => {
const current = editor.getAttributes("subpages")?.recursive ?? false;
editor.commands.updateAttributes("subpages", {
recursive: !current,
});
}, [editor]);
const deleteNode = useCallback(() => { const deleteNode = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
editor editor
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
.run(); .run();
}, [editor]); }, [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 ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
updateDelay={0} updateDelay={0}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Group gap={4} wrap="nowrap">
<ActionIcon <Tooltip
onClick={deleteNode} position="top"
variant="default" label={
size="lg" isRecursive
color="red" ? t("Switch to flat list")
aria-label={t("Delete")} : t("Switch to tree")
}
> >
<IconTrash size={18} /> <ActionIcon
</ActionIcon> onClick={toggleRecursive}
</Tooltip> 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> </BaseBubbleMenu>
); );
} }

View File

@@ -1,7 +1,10 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core"; import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; 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 { useMemo } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css"; import classes from "./subpages.module.css";
@@ -12,16 +15,130 @@ import {
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils"; 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) { export default function SubpagesView(props: NodeViewProps) {
const { editor } = props; const { editor } = props;
const { spaceSlug, shareId } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const recursive: boolean = props.node.attrs.recursive ?? false;
//@ts-ignore //@ts-ignore
const currentPageId = editor.storage.pageId; 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 // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
</NodeViewWrapper> </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

@@ -21,6 +21,7 @@ import {
getAllSidebarPages, getAllSidebarPages,
getDeletedPages, getDeletedPages,
restorePage, restorePage,
getSpaceTree,
} from "@/features/page/services/page-service"; } from "@/features/page/services/page-service";
import { import {
IMovePage, 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( export function usePageBreadcrumbsQuery(
pageId: string, pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> { ): 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>) { export function invalidateOnCreatePage(data: Partial<IPage>) {
invalidatePageTree();
const newPage: Partial<IPage> = { const newPage: Partial<IPage> = {
creatorId: data.creatorId, creatorId: data.creatorId,
hasChildren: data.hasChildren, hasChildren: data.hasChildren,
@@ -478,6 +499,7 @@ export function invalidateOnUpdatePage(
title: string, title: string,
icon: string, icon: string,
) { ) {
invalidatePageTree();
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (parentPageId === null) { if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId]; queryKey = ["root-sidebar-pages", spaceId];
@@ -516,6 +538,7 @@ export function updateCacheOnMovePage(
newParentId: string | null, newParentId: string | null,
pageData: Partial<IPage>, pageData: Partial<IPage>,
) { ) {
invalidatePageTree();
// Remove page from old parent's cache // Remove page from old parent's cache
const oldQueryKey = const oldQueryKey =
oldParentId === null oldParentId === null
@@ -633,6 +656,7 @@ export function updateCacheOnMovePage(
} }
export function invalidateOnDeletePage(pageId: string) { export function invalidateOnDeletePage(pageId: string) {
invalidatePageTree();
//update all sidebar pages //update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({ const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) => predicate: (query) =>

View File

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

View File

@@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) {
return findSubpages(treeData); return findSubpages(treeData);
}, [treeData, pageId]); }, [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

@@ -53,6 +53,8 @@ const formSchema = z.object({
driver: z.enum(["", ...AI_DRIVER_VALUES]), driver: z.enum(["", ...AI_DRIVER_VALUES]),
chatModel: z.string(), chatModel: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
autoStart: z.boolean(),
launchMessage: z.string(),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -83,6 +85,8 @@ export default function AiAgentRoleForm({
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "", chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true, enabled: role?.enabled ?? true,
autoStart: role?.autoStart ?? true,
launchMessage: role?.launchMessage ?? "",
}, },
}); });
@@ -96,6 +100,8 @@ export default function AiAgentRoleForm({
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"], driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "", chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true, enabled: role?.enabled ?? true,
autoStart: role?.autoStart ?? true,
launchMessage: role?.launchMessage ?? "",
}); });
form.resetDirty(); form.resetDirty();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -122,6 +128,8 @@ export default function AiAgentRoleForm({
instructions: values.instructions, instructions: values.instructions,
modelConfig, modelConfig,
enabled: values.enabled, enabled: values.enabled,
autoStart: values.autoStart,
launchMessage: values.launchMessage,
}; };
await updateMutation.mutateAsync(payload); await updateMutation.mutateAsync(payload);
} else { } else {
@@ -132,6 +140,10 @@ export default function AiAgentRoleForm({
instructions: values.instructions, instructions: values.instructions,
modelConfig, modelConfig,
enabled: values.enabled, enabled: values.enabled,
autoStart: values.autoStart,
// Send the raw (trimmed) value like the update path; the server
// normalizes an empty string to null (emptyToNull). Symmetric.
launchMessage: values.launchMessage,
}; };
await createMutation.mutateAsync(payload); await createMutation.mutateAsync(payload);
} }
@@ -195,6 +207,28 @@ export default function AiAgentRoleForm({
)} )}
</Text> </Text>
<Switch
label={t("Start automatically")}
description={t(
"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.",
)}
checked={form.values.autoStart}
onChange={(event) =>
form.setFieldValue("autoStart", event.currentTarget.checked)
}
/>
<Textarea
label={t("Launch message")}
description={t(
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
)}
autosize
minRows={2}
maxRows={6}
{...form.getInputProps("launchMessage")}
/>
<Switch <Switch
label={t("Enabled")} label={t("Enabled")}
checked={form.values.enabled} checked={form.values.enabled}

View File

@@ -5,7 +5,8 @@ import {
rowToUiMessage, rowToUiMessage,
prepareAgentStep, prepareAgentStep,
buildPartialAssistantRecord, buildPartialAssistantRecord,
chatStreamStartMetadata, chatStreamMetadata,
accumulateStepUsage,
MAX_AGENT_STEPS, MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION, FINAL_STEP_INSTRUCTION,
} from './ai-chat.service'; } from './ai-chat.service';
@@ -298,18 +299,135 @@ describe('buildPartialAssistantRecord', () => {
}); });
/** /**
* chatStreamStartMetadata: attach the authoritative chatId to the streamed * chatStreamMetadata: attach metadata to the streamed assistant UI message per
* assistant UI message ONLY on the `start` part (so the client adopts the real * part type — `chatId` on `start` (so the client adopts the real created chat id
* created chat id at the first chunk — see #137). Any non-start part adds none. * at the first chunk — see #137), and AUTHORITATIVE usage (incl. reasoning
* tokens) on `finish-step` and `finish` so the client's live token counter snaps
* to exact at each step/turn boundary.
*/ */
describe('chatStreamStartMetadata', () => { describe('chatStreamMetadata', () => {
it('returns { chatId } for the start part', () => { it('returns { chatId } for the start part', () => {
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({ expect(chatStreamMetadata({ type: 'start' }, 'chat-1')).toEqual({
chatId: 'chat-1', chatId: 'chat-1',
}); });
}); });
it('returns undefined for a finish part (any non-start part)', () => { it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined(); // finish-step usage is per-step in v6; the caller accumulates and passes the
// running sum, which this just wraps.
expect(
chatStreamMetadata(
{ type: 'finish-step', usage: { outputTokens: 100 } },
'chat-1',
{ inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
),
).toEqual({
usage: { inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
});
});
it('returns turn usage for the finish part (reasoning from deprecated top-level field)', () => {
expect(
chatStreamMetadata(
{
type: 'finish',
totalUsage: {
inputTokens: 1000,
outputTokens: 250,
totalTokens: 1250,
reasoningTokens: 50,
},
},
'chat-1',
),
).toEqual({
usage: {
inputTokens: 1000,
outputTokens: 250,
totalTokens: 1250,
reasoningTokens: 50,
},
});
});
it('prefers outputTokenDetails.reasoningTokens over the deprecated field (finish)', () => {
expect(
chatStreamMetadata(
{
type: 'finish',
totalUsage: {
outputTokens: 100,
reasoningTokens: 5,
outputTokenDetails: { reasoningTokens: 30 },
},
},
'chat-1',
),
).toEqual({
usage: {
inputTokens: undefined,
outputTokens: 100,
totalTokens: undefined,
reasoningTokens: 30,
},
});
});
it('returns undefined for a finish-step with no accumulated usage', () => {
expect(
chatStreamMetadata({ type: 'finish-step' }, 'chat-1'),
).toBeUndefined();
});
it('returns undefined for an unrelated part (e.g. text-delta)', () => {
expect(
chatStreamMetadata({ type: 'text-delta' }, 'chat-1'),
).toBeUndefined();
});
});
/**
* accumulateStepUsage: sums per-step usage into a running cumulative total so the
* client never sees the live counter jump DOWN on a multi-step agent turn (#151).
*/
describe('accumulateStepUsage', () => {
it('sums every field across two steps', () => {
expect(
accumulateStepUsage(
{ inputTokens: 500, outputTokens: 100, totalTokens: 600, reasoningTokens: 30 },
{ inputTokens: 520, outputTokens: 80, totalTokens: 600, reasoningTokens: 10 },
),
).toEqual({
inputTokens: 1020,
outputTokens: 180,
totalTokens: 1200,
reasoningTokens: 40,
});
});
it('returns the step as-is when there is no accumulator yet', () => {
expect(accumulateStepUsage(undefined, { outputTokens: 10 })).toEqual({
outputTokens: 10,
});
});
it('returns the accumulator unchanged when the step usage is absent', () => {
const acc = { outputTokens: 10 };
expect(accumulateStepUsage(acc, undefined)).toBe(acc);
});
it('returns undefined when both sides are absent', () => {
expect(accumulateStepUsage(undefined, undefined)).toBeUndefined();
});
it('keeps a field undefined only when neither side has it', () => {
expect(
accumulateStepUsage({ outputTokens: 5 }, { outputTokens: 7 }),
).toEqual({
inputTokens: undefined,
outputTokens: 12,
totalTokens: undefined,
reasoningTokens: undefined,
});
}); });
}); });

View File

@@ -420,7 +420,11 @@ export class AiChatService {
toolCalls: serializeSteps(steps), toolCalls: serializeSteps(steps),
metadata: { metadata: {
finishReason, finishReason,
usage: totalUsage, // Persist the turn's cumulative usage WITH reasoning tokens resolved
// from either the new `outputTokenDetails` or the deprecated top-level
// field, so reopened history / the Markdown export show the thinking
// token cost too.
usage: normalizeStreamUsage(totalUsage as StreamUsage) ?? totalUsage,
// Final-step usage = the context actually fed to the model on the last LLM // Final-step usage = the context actually fed to the model on the last LLM
// call (full history + tool results) plus the answer it just generated. // call (full history + tool results) plus the answer it just generated.
// input+output of the FINAL step ≈ the conversation's CURRENT context size, // input+output of the FINAL step ≈ the conversation's CURRENT context size,
@@ -512,17 +516,42 @@ export class AiChatService {
// does not buffer responses by default. // does not buffer responses by default.
// Scrub the SDK's hop-by-hop Connection header before it writes the head (Safari/HTTP2). // Scrub the SDK's hop-by-hop Connection header before it writes the head (Safari/HTTP2).
stripStreamingHopByHopHeaders(res.raw); stripStreamingHopByHopHeaders(res.raw);
// Running sum of per-step usage (v6 `finish-step.usage` is per-step). Sent
// as the cumulative authoritative usage so the client never jumps DOWN.
let cumulativeStepUsage: ChatStreamUsage | undefined;
result.pipeUIMessageStreamToResponse(res.raw, { result.pipeUIMessageStreamToResponse(res.raw, {
headers: { 'X-Accel-Buffering': 'no' }, headers: { 'X-Accel-Buffering': 'no' },
// Surface the authoritative chatId on the streamed assistant UI message so // Surface the authoritative chatId on the streamed assistant UI message so
// the client adopts the REAL id of the row we created, instead of guessing // the client adopts the REAL id of the row we created, instead of guessing
// the newest chat in its list. `messageMetadata` is invoked by the AI SDK // the newest chat in its list. `messageMetadata` is invoked by the AI SDK
// on the `start` and `finish` stream parts (ai@6); we attach `chatId` on the // on the `start`, `finish-step` and `finish` stream parts (ai@6 — note the
// `start` part so it reaches the client (as message.metadata.chatId) at the // `finish-step` trigger relies on it being delivered as its own
// very first chunk — before any second tab can race a newer chat into the // message-metadata chunk); we attach `chatId` on the `start` part so it
// list. This fixes the two-tab "adoption race" (#137) where a new chat in // reaches the client (as message.metadata.chatId) at the very first chunk —
// tab A could adopt tab B's id and leak its turns into the wrong row. // before any second tab can race a newer chat into the list. This fixes the
messageMetadata: ({ part }) => chatStreamStartMetadata(part, chatId), // two-tab "adoption race" (#137).
//
// `finish-step.usage` is PER-STEP (not cumulative) in v6, and the client
// merges each metadata.usage by replacement — so on a multi-step agent turn
// (up to MAX_AGENT_STEPS) the naive per-step value would make the live
// counter jump DOWN at each boundary. We keep a running sum here and send
// the CUMULATIVE usage, which converges to `finish.totalUsage` (#151).
messageMetadata: ({ part }) => {
const p = part as StreamMetadataPart;
if (p.type === 'finish-step') {
cumulativeStepUsage = accumulateStepUsage(
cumulativeStepUsage,
normalizeStreamUsage(p.usage),
);
}
return chatStreamMetadata(p, chatId, cumulativeStepUsage);
},
// Stream reasoning (thinking) parts to the client so the live counter can
// estimate reasoning tokens from streamed text. v6 default is already
// true; set explicitly so the intent survives any future SDK default
// change. Providers that don't emit reasoning text still surface the
// count via the authoritative `usage.reasoningTokens` on finish-step.
sendReasoning: true,
onError: (error: unknown) => { onError: (error: unknown) => {
// Reuse the shared formatter so provider error formatting stays // Reuse the shared formatter so provider error formatting stays
// unified between the log line and the streamed error message. // unified between the log line and the streamed error message.
@@ -573,16 +602,97 @@ export class AiChatService {
} }
} }
/** Shape of the AI SDK v6 LanguageModelUsage we forward to the client. The SDK
* exposes `reasoningTokens` both as a (deprecated) top-level field and under
* `outputTokenDetails.reasoningTokens`; we normalize to a single field so the
* client gets one stable usage shape regardless of provider/SDK version. */
interface StreamUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
outputTokenDetails?: { reasoningTokens?: number };
}
/** A streamed part the messageMetadata callback can receive (only the fields we read). */
interface StreamMetadataPart {
type: string;
usage?: StreamUsage;
totalUsage?: StreamUsage;
}
/** Authoritative usage we attach to a streamed assistant message's metadata. */
export interface ChatStreamUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** Normalize an AI SDK usage object to our flat client-facing shape, resolving
* reasoning tokens from either the new `outputTokenDetails` or the deprecated
* top-level field. Returns undefined for a missing usage object. */
function normalizeStreamUsage(
usage: StreamUsage | undefined,
): ChatStreamUsage | undefined {
if (!usage) return undefined;
const reasoningTokens =
usage.outputTokenDetails?.reasoningTokens ?? usage.reasoningTokens;
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
reasoningTokens,
};
}
/** Sum a (normalized) per-step usage into a running cumulative usage. v6's
* `finish-step.usage` is PER-STEP, so the caller accumulates across steps; the
* cumulative sum converges to the turn's `totalUsage` (no down-jump on the
* client). Returns undefined only when both sides are absent. Pure. */
export function accumulateStepUsage(
acc: ChatStreamUsage | undefined,
step: ChatStreamUsage | undefined,
): ChatStreamUsage | undefined {
if (!acc) return step;
if (!step) return acc;
const add = (a?: number, b?: number): number | undefined =>
a == null && b == null ? undefined : (a ?? 0) + (b ?? 0);
return {
inputTokens: add(acc.inputTokens, step.inputTokens),
outputTokens: add(acc.outputTokens, step.outputTokens),
totalTokens: add(acc.totalTokens, step.totalTokens),
reasoningTokens: add(acc.reasoningTokens, step.reasoningTokens),
};
}
/** /**
* Attach the authoritative `chatId` to the streamed assistant message's `start` * Pure metadata builder for the streamed assistant UI message. The AI SDK calls
* part (as `message.metadata.chatId`) so the client can adopt the real id for a * `messageMetadata` on the `start`, `finish-step` and `finish` stream parts; we
* new chat. See the client's adopt-chat-id.ts for the full #137 design. * attach (as `message.metadata`):
* - `start` -> `{ chatId }` so the client adopts the real created chat id
* at the first chunk (see adopt-chat-id.ts / #137).
* - `finish-step` -> `{ usage }` the CUMULATIVE authoritative usage so far
* (incl. reasoning tokens) — the caller passes the running
* sum (`cumulativeStepUsage`), since v6 per-step usage is not
* cumulative; the client snaps to exact without jumping down.
* - `finish` -> `{ usage }` from the turn's `totalUsage` (final reconcile).
* Any other part type contributes no metadata. Pure + unit-testable.
*/ */
export function chatStreamStartMetadata( export function chatStreamMetadata(
part: { type: string }, part: StreamMetadataPart,
chatId: string, chatId: string,
): { chatId: string } | undefined { cumulativeStepUsage?: ChatStreamUsage,
return part.type === 'start' ? { chatId } : undefined; ): { chatId: string } | { usage: ChatStreamUsage } | undefined {
if (part.type === 'start') return { chatId };
if (part.type === 'finish-step') {
return cumulativeStepUsage ? { usage: cumulativeStepUsage } : undefined;
}
if (part.type === 'finish') {
const usage = normalizeStreamUsage(part.totalUsage);
return usage ? { usage } : undefined;
}
return undefined;
} }
/** The last message with role 'user' from a useChat payload, if any. */ /** The last message with role 'user' from a useChat payload, if any. */

View File

@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'be a researcher', instructions: 'be a researcher',
modelConfig: null, modelConfig: null,
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
...over, ...over,
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'updated instructions', instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' }, modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false, enabled: false,
autoStart: true,
launchMessage: null,
createdAt, createdAt,
updatedAt, updatedAt,
}); });
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
expect(patch2.emoji).toBeUndefined(); expect(patch2.emoji).toBeUndefined();
expect(patch2.description).toBeUndefined(); expect(patch2.description).toBeUndefined();
}); });
it('autoStart/launchMessage thread through; launchMessage:"" clears to null', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
autoStart: false,
launchMessage: ' custom ',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBe(false);
expect(patch.launchMessage).toBe('custom');
repo.update.mockClear();
// Explicit empty => clear to null.
await service.update('ws-1', 'r1', {
launchMessage: ' ',
} as UpdateAgentRoleDto);
expect(repo.update.mock.calls[0][2].launchMessage).toBeNull();
});
it('autoStart/launchMessage omitted => undefined (unchanged) in the patch', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await service.update('ws-1', 'r1', {
name: 'Renamed',
} as UpdateAgentRoleDto);
const patch = repo.update.mock.calls[0][2];
expect(patch.autoStart).toBeUndefined();
expect(patch.launchMessage).toBeUndefined();
});
}); });
describe('remove', () => { describe('remove', () => {
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
} as CreateAgentRoleDto), } as CreateAgentRoleDto),
).rejects.toBe(other); ).rejects.toBe(other);
}); });
it('autoStart omitted => defaults to true; launchMessage omitted => null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(true);
expect(values.launchMessage).toBeNull();
});
it('autoStart:false + launchMessage round-trip (trimmed) to the repo', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: ' do the thing ',
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.autoStart).toBe(false);
expect(values.launchMessage).toBe('do the thing');
});
it('empty/whitespace launchMessage normalizes to null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
launchMessage: ' ',
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].launchMessage).toBeNull();
});
}); });
describe('list view (security: non-admin must not see instructions/modelConfig)', () => { describe('list view (security: non-admin must not see instructions/modelConfig)', () => {
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
const list = await service.list('ws-1', false); const list = await service.list('ws-1', false);
expect(list).toHaveLength(1); expect(list).toHaveLength(1);
const item = list[0] as unknown as Record<string, unknown>; const item = list[0] as unknown as Record<string, unknown>;
// The picker fields ARE present... // The picker fields ARE present — INCLUDING the auto-start fields, which
// the client needs to decide whether/what to auto-send on role pick.
expect(item).toEqual({ expect(item).toEqual({
id: 'r1', id: 'r1',
name: 'Researcher', name: 'Researcher',
emoji: '🔬', emoji: '🔬',
description: 'finds things', description: 'finds things',
enabled: true, enabled: true,
autoStart: true,
launchMessage: null,
}); });
// ...and the admin-only fields are absent (not just undefined). // ...and the admin-only fields are absent (not just undefined).
expect('instructions' in item).toBe(false); expect('instructions' in item).toBe(false);
expect('modelConfig' in item).toBe(false); expect('modelConfig' in item).toBe(false);
expect('createdAt' in item).toBe(false); expect('createdAt' in item).toBe(false);
expect('updatedAt' in item).toBe(false); expect('updatedAt' in item).toBe(false);
// autoStart/launchMessage are deliberately NOT admin-only — present here.
expect('autoStart' in item).toBe(true);
expect('launchMessage' in item).toBe(true);
}); });
it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => { it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => {

View File

@@ -22,6 +22,8 @@ export interface AgentRoleView {
instructions: string; instructions: string;
modelConfig: RoleModelConfig | null; modelConfig: RoleModelConfig | null;
enabled: boolean; enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -31,6 +33,11 @@ export interface AgentRoleView {
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`, * role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
* creator or timestamps, so non-admins never receive the admin-authored prompt * creator or timestamps, so non-admins never receive the admin-authored prompt
* or the model override. * or the model override.
*
* `autoStart` / `launchMessage` ARE included (unlike instructions/modelConfig):
* the client needs them to decide whether and what to auto-send when a role card
* is picked. `launchMessage` is sent verbatim as a normal user message — it is
* not a secret, so exposing it to members is intentional.
*/ */
export interface AgentRolePickerView { export interface AgentRolePickerView {
id: string; id: string;
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
emoji: string | null; emoji: string | null;
description: string | null; description: string | null;
enabled: boolean; enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
} }
/** /**
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
instructions, instructions,
modelConfig: modelConfig as Record<string, unknown> | null, modelConfig: modelConfig as Record<string, unknown> | null,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
autoStart: dto.autoStart ?? true,
// Empty/whitespace-only => null (client default launch message).
launchMessage: emptyToNull(dto.launchMessage),
}); });
return this.toView(row); return this.toView(row);
} catch (err) { } catch (err) {
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
| Record<string, unknown> | Record<string, unknown>
| null), | null),
enabled: dto.enabled, enabled: dto.enabled,
autoStart: dto.autoStart,
// undefined => unchanged; '' => clear to null.
launchMessage:
dto.launchMessage === undefined
? undefined
: emptyToNull(dto.launchMessage),
}); });
} catch (err) { } catch (err) {
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name); throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
instructions: row.instructions, instructions: row.instructions,
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null, modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
enabled: row.enabled, enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
} }
/** Non-admin picker view: id/name/emoji/description/enabled only. */ /**
* Non-admin picker view: id/name/emoji/description/enabled plus the auto-start
* fields the client needs to decide whether/what to send on role pick. Still
* WITHOUT instructions/modelConfig (admin-only).
*/
private toPickerView(row: AiAgentRole): AgentRolePickerView { private toPickerView(row: AiAgentRole): AgentRolePickerView {
return { return {
id: row.id, id: row.id,
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
emoji: row.emoji ?? null, emoji: row.emoji ?? null,
description: row.description ?? null, description: row.description ?? null,
enabled: row.enabled, enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
}; };
} }
} }

View File

@@ -78,4 +78,32 @@ describe('CreateAgentRoleDto with nested modelConfig', () => {
}); });
expect(errors.length).toBeGreaterThan(0); expect(errors.length).toBeGreaterThan(0);
}); });
it('accepts autoStart:false + a launchMessage', () => {
expect(
validateCreate({ ...base, autoStart: false, launchMessage: 'Go' }),
).toHaveLength(0);
});
it('rejects a non-boolean autoStart', () => {
const errors = validateCreate({ ...base, autoStart: 'yes' });
expect(errors.some((e) => e.property === 'autoStart')).toBe(true);
});
it('rejects a launchMessage longer than 2000 chars', () => {
const errors = validateCreate({
...base,
launchMessage: 'a'.repeat(2001),
});
expect(errors.some((e) => e.property === 'launchMessage')).toBe(true);
});
it('trims surrounding whitespace from launchMessage', () => {
const dto = plainToInstance(CreateAgentRoleDto, {
...base,
launchMessage: ' Look here ',
});
expect(validateSync(dto as object)).toHaveLength(0);
expect(dto.launchMessage).toBe('Look here');
});
}); });

View File

@@ -65,6 +65,22 @@ export class CreateAgentRoleDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
// Omitted => default true (preserves the previous always-auto-start behavior).
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
} }
/** Admin update payload for an agent role (all fields optional). */ /** Admin update payload for an agent role (all fields optional). */
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
// Whether picking this role auto-sends a launch message and starts the chat.
@IsOptional()
@IsBoolean()
autoStart?: boolean;
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
// empty/whitespace-only => the client falls back to its default launch message.
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MaxLength(2000)
launchMessage?: string;
} }

View File

@@ -0,0 +1,29 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-role control over the new-chat auto-start behavior. Previously picking a
// role card ALWAYS sent a hardcoded launch message and started the dialog.
// These two columns make that configurable per role.
await db.schema
.alterTable('ai_agent_roles')
// When true (default), picking the role auto-sends a launch message and
// starts the conversation; when false the client only binds the role and
// reveals the composer (nothing is sent). Default true => existing roles
// keep their previous behavior.
.addColumn('auto_start', 'boolean', (col) => col.notNull().defaultTo(true))
// Optional custom text sent on auto-start instead of the built-in default.
// NULL/empty => the client falls back to its default launch message.
.addColumn('launch_message', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('launch_message')
.execute();
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('auto_start')
.execute();
}

View File

@@ -49,3 +49,81 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined(); expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined();
}); });
}); });
/**
* Column-threading tests for the auto-start feature: insert defaults autoStart to
* true and stores an empty launchMessage as null; update only sets a column when
* the patch field is present, and clears launchMessage to null on empty string.
*/
describe('AiAgentRoleRepo insert/update auto-start columns', () => {
function makeInsertRepo() {
const values = jest.fn();
const builder = {
values: jest.fn((v: unknown) => {
values(v);
return builder;
}),
returningAll: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({}),
};
const db = {
insertInto: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), values };
}
function makeUpdateRepo() {
const set = jest.fn();
const builder = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn(() => builder),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = {
updateTable: jest.fn(() => builder),
} as unknown as KyselyDB;
return { repo: new AiAgentRoleRepo(db), set };
}
it('insert defaults autoStart to true and stores empty launchMessage as null', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
launchMessage: '',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(true);
expect(v.launchMessage).toBeNull();
});
it('insert threads autoStart:false and a launchMessage', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
autoStart: false,
launchMessage: 'Go',
});
const v = values.mock.calls[0][0];
expect(v.autoStart).toBe(false);
expect(v.launchMessage).toBe('Go');
});
it('update omits unchanged columns; clears launchMessage to null on empty', async () => {
const { repo, set } = makeUpdateRepo();
await repo.update('r-1', 'ws-1', { autoStart: false });
expect(set.mock.calls[0][0].autoStart).toBe(false);
expect('launchMessage' in set.mock.calls[0][0]).toBe(false);
const { repo: repo2, set: set2 } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { launchMessage: '' });
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
});

View File

@@ -76,6 +76,9 @@ export class AiAgentRoleRepo {
instructions: string; instructions: string;
modelConfig?: ModelConfigValue; modelConfig?: ModelConfigValue;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<AiAgentRole> { ): Promise<AiAgentRole> {
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
instructions: values.instructions, instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig), modelConfig: jsonbObject(values.modelConfig),
enabled: values.enabled ?? true, enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || null,
}) })
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
// undefined => unchanged; null => clear; object => set. // undefined => unchanged; null => clear; object => set.
modelConfig?: ModelConfigValue; modelConfig?: ModelConfigValue;
enabled?: boolean; enabled?: boolean;
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
}, },
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<void> { ): Promise<void> {
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
set.modelConfig = jsonbObject(patch.modelConfig); set.modelConfig = jsonbObject(patch.modelConfig);
} }
if (patch.enabled !== undefined) set.enabled = patch.enabled; if (patch.enabled !== undefined) set.enabled = patch.enabled;
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
if (patch.launchMessage !== undefined) {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage || null;
}
await db await db
.updateTable('aiAgentRoles') .updateTable('aiAgentRoles')
.set(set) .set(set)

View File

@@ -601,6 +601,11 @@ export interface AiAgentRoles {
// { chatModel } | { driver, chatModel } | null. null => workspace default. // { chatModel } | { driver, chatModel } | null. null => workspace default.
modelConfig: Json | null; modelConfig: Json | null;
enabled: Generated<boolean>; enabled: Generated<boolean>;
// When true (default), picking the role auto-sends a launch message and starts
// the new chat; when false the client only binds the role and shows the composer.
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from "vitest";
import { applyAlignment } from "./image";
// applyAlignment is a pure DOM mutation: it sets the float / padding /
// justify-content / data-image-align on an image node-view container per the
// resolved `align`. Tested directly (issue #145 review) since the five-way
// branch, the reset-then-apply guard, and the data-image-align mirror (which the
// responsive @media rule keys off) are otherwise uncovered.
describe("applyAlignment", () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement("div");
});
it("floatLeft -> float:left + right padding, mirrored on data-image-align", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
expect(el.style.padding).toBe("0px 10px 0px 0px");
expect(el.dataset.imageAlign).toBe("floatLeft");
expect(el.style.justifyContent).toBe("flex-start");
});
it("floatRight -> float:right + left padding", () => {
applyAlignment(el, "floatRight");
expect(el.style.cssFloat).toBe("right");
expect(el.style.padding).toBe("0px 0px 0px 10px");
expect(el.dataset.imageAlign).toBe("floatRight");
expect(el.style.justifyContent).toBe("flex-end");
});
it("left -> justify flex-start, no float", () => {
applyAlignment(el, "left");
expect(el.style.justifyContent).toBe("flex-start");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("left");
});
it("right -> justify flex-end, no float", () => {
applyAlignment(el, "right");
expect(el.style.justifyContent).toBe("flex-end");
expect(el.style.cssFloat).toBe("");
expect(el.dataset.imageAlign).toBe("right");
});
it("center (default) -> justify center, no float", () => {
applyAlignment(el, "center");
expect(el.style.justifyContent).toBe("center");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("center");
});
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
expect(el.style.padding).toBe("0px 10px 0px 0px");
// Switching to a block alignment must drop the float and its padding, not
// leak them (the bug the reset guard prevents).
applyAlignment(el, "left");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("left");
expect(el.style.justifyContent).toBe("flex-start");
});
});

View File

@@ -51,7 +51,9 @@ declare module "@tiptap/core" {
setImageAt: ( setImageAt: (
attributes: ImageAttributes & { pos: number | Range }, attributes: ImageAttributes & { pos: number | Range },
) => ReturnType; ) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageAlign: (
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
) => ReturnType;
setImageWidth: (width: number) => ReturnType; setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType; setImageSize: (width: number, height: number) => ReturnType;
}; };
@@ -374,8 +376,27 @@ export const TiptapImage = Image.extend<ImageOptions>({
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) { export function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") { // Reset the float-mode styles first so toggling between any two modes is clean
// (a previous float must not leak into a later left/center/right).
container.style.cssFloat = "";
container.style.padding = "";
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
// responsive stylesheet can neutralize the float on small screens (an inline
// `float` can only be overridden by `!important`, which keys off this attr).
container.dataset.imageAlign = align;
if (align === "floatLeft") {
// Real text wrap: the (shrink-to-fit) container floats left, text flows on
// its right. The inner <img> already carries max-width:100%.
container.style.cssFloat = "left";
container.style.padding = "0 10px 0 0";
container.style.justifyContent = "flex-start";
} else if (align === "floatRight") {
container.style.cssFloat = "right";
container.style.padding = "0 0 0 10px";
container.style.justifyContent = "flex-end";
} else if (align === "left") {
container.style.justifyContent = "flex-start"; container.style.justifyContent = "flex-start";
} else if (align === "right") { } else if (align === "right") {
container.style.justifyContent = "flex-end"; container.style.justifyContent = "flex-end";

View File

@@ -6,7 +6,9 @@ export interface SubpagesOptions {
view: any; view: any;
} }
export interface SubpagesAttributes {} export interface SubpagesAttributes {
recursive?: boolean;
}
declare module "@tiptap/core" { declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
@@ -31,6 +33,18 @@ export const Subpages = Node.create<SubpagesOptions>({
draggable: true, draggable: true,
isolating: true, isolating: true,
addAttributes() {
return {
recursive: {
// Existing nodes stay flat -> backward compatible.
default: false,
parseHTML: (el) => el.getAttribute("data-recursive") === "true",
renderHTML: (attrs) =>
attrs.recursive ? { "data-recursive": "true" } : {},
},
};
},
parseHTML() { parseHTML() {
return [ return [
{ {