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 |
| `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

View File

@@ -1147,6 +1147,12 @@
"Ask a question about this documentation.": "Ask a question about this documentation.",
"Ask a question…": "Ask a question…",
"Thinking…": "Thinking…",
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant",
"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",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
@@ -1266,6 +1273,10 @@
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
"Start automatically": "Start automatically",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
"Launch message": "Launch message",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
"Agent roles": "Agent roles",
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
"No roles configured": "No roles configured",
@@ -1287,5 +1298,14 @@
"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.",
"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": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"Start automatically": "Запускать автоматически",
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
"Launch message": "Стартовое сообщение",
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"Thinking…": "Думаю…",
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
@@ -690,6 +702,7 @@
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
@@ -1137,5 +1150,15 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
}

View File

@@ -156,6 +156,12 @@ export default function AiChatWindow() {
isStreaming: false,
});
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -485,11 +491,19 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span>
</Tooltip>
) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
</Tooltip>
)}
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -608,6 +622,7 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

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

View File

@@ -21,8 +21,13 @@ import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import {
roleLaunchMessage,
shouldResetRolePicked,
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import {
dequeue,
enqueueMessage,
@@ -69,6 +74,12 @@ interface ChatThreadProps {
* assistant message. A ref (not state) avoids re-rendering the parent on
* every streamed delta. */
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
}
/**
@@ -113,6 +124,7 @@ export default function ChatThread({
assistantName,
onTurnFinished,
liveStateRef,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -310,21 +322,98 @@ export default function ChatThread({
};
}, [liveStateRef, messages, isStreaming]);
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while
// streaming, authoritative once a step reports usage) is the live figure. When
// the turn ends we emit a final exact value, then `null` so the parent reverts
// the badge to the persisted context size.
const lastEmitRef = useRef(0);
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!onLiveTurnTokens) return;
if (!isStreaming) {
// Turn ended (or never started): clear any pending throttle and revert.
if (emitTimerRef.current) {
clearTimeout(emitTimerRef.current);
emitTimerRef.current = null;
}
lastEmitRef.current = 0;
onLiveTurnTokens(null);
return;
}
const tail = messages[messages.length - 1];
const live =
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0;
const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz)
const elapsed = now - lastEmitRef.current;
if (elapsed >= MIN_INTERVAL) {
lastEmitRef.current = now;
onLiveTurnTokens(total);
} else if (!emitTimerRef.current) {
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
emitTimerRef.current = setTimeout(() => {
emitTimerRef.current = null;
lastEmitRef.current = Date.now();
onLiveTurnTokens(total);
}, MIN_INTERVAL - elapsed);
}
}, [messages, isStreaming, onLiveTurnTokens]);
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
// trailing emit can't fire into a torn-down thread's parent.
useEffect(() => {
return () => {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
};
}, []);
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)
// so the user can type the first message themselves. roleIdRef is already set,
// so that first manual message carries the roleId.
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
// Clicking a role card always binds the role to THIS new chat. Whether it also
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
// synchronously here because the parent's selectedRoleId state update would
// only reach roleIdRef on the next render — after this synchronous sendMessage
// has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
const launch = roleLaunchMessage(
role,
t("Take a look at the current document"),
);
if (launch !== null) {
sendMessage({ text: launch });
} else {
// autoStart=false -> bind only: hide the cards, show the composer.
setRolePickedNoSend(true);
}
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
// chatId null, so the thread never remounts and the flag would stay set, hiding
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
// change"): one-shot — it re-renders with the flag false and the guard no longer
// matches, so it cannot loop. (Review of #149.)
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
setRolePickedNoSend(false);
}
const showRoleCards =
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;

View File

@@ -2,12 +2,14 @@ import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -77,12 +79,31 @@ export default function MessageItem({
// return won't fire for them.
if (!assistantMessageHasVisibleContent(message)) return null;
// Authoritative reasoning token count to attribute to a reasoning block, or
// undefined when the block must estimate on its own. See reasoningTokensForPart
// for the #151 anti-double-count rule (only a single reasoning part may carry
// the turn total). The authoritative turn total is still surfaced live in the
// header badge regardless.
const reasoningTokens = reasoningTokensForPart(message);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{resolveAssistantName(assistantName) ?? t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "reasoning") {
// Reasoning ("thinking") -> a collapsible block with its own token
// count. Empty/whitespace reasoning with no authoritative count carries
// nothing to show, so skip it (avoids an empty 0-token block).
const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
return (
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
);
}
if (part.type === "text") {
// Skip empty/whitespace-only text parts (a streaming message often
// starts with an empty text part before the first token arrives); the

View File

@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -94,6 +95,19 @@ export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
return !assistantMessageHasVisibleContent(last);
}
/**
* The live thinking-token count to show on the standalone typing indicator. It
* is the reasoning split of the tail assistant message (estimate while streaming,
* authoritative once the server attaches usage at a step/turn boundary). Returns
* 0 when the turn has produced no reasoning yet — the indicator then shows the
* plain "Thinking…" line.
*/
export function tailThinkingTokens(messages: UIMessage[]): number {
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return 0;
return liveTurnTokens(last).reasoning;
}
/**
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
* but only while the user is pinned to the bottom — if they scrolled up to read
@@ -190,7 +204,13 @@ export default function MessageList({
assistantName={assistantName}
/>
))}
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />}
{typing && (
<TypingIndicator
assistantName={assistantName}
showName={typingIndicatorShowsName(messages)}
thinkingTokens={tailThinkingTokens(messages)}
/>
)}
</Stack>
</ScrollArea>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,12 @@ interface TypingIndicatorProps {
* assistant row above already shows the same name, to avoid a duplicate label.
*/
showName?: boolean;
/**
* Live thinking/reasoning token count for the in-flight turn. When > 0 the
* typing line becomes `Thinking… · {count} tokens` (like Claude Code). Omitted
* / 0 keeps the plain `Thinking…` line.
*/
thinkingTokens?: number;
}
/**
@@ -30,9 +36,14 @@ interface TypingIndicatorProps {
* typing line is always the generic "Thinking…" (it never includes the
* role/identity name).
*/
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
const { t } = useTranslation();
const name = resolveAssistantName(assistantName);
// Show the running thinking-token count only once there is something to count.
const thinkingLine =
thinkingTokens && thinkingTokens > 0
? t("Thinking… · {{count}} tokens", { count: thinkingTokens })
: t("Thinking…");
return (
<Box className={classes.messageRow}>
@@ -48,7 +59,7 @@ export default function TypingIndicator({ assistantName, showName = true }: Typi
<span />
</span>
<Text size="sm" c="dimmed">
{t("Thinking…")}
{thinkingLine}
</Text>
</Group>
</Box>

View File

@@ -53,6 +53,10 @@ export interface IAiRole {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled: boolean;
// Whether picking the role auto-sends a launch message and starts the chat.
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
createdAt?: string;
updatedAt?: string;
}
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/** Admin update payload for a role (partial). */
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/**
@@ -98,6 +106,10 @@ export interface IAiChatMessageRow {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
// Reasoning (thinking) tokens, when the provider reports them. Optional so
// old history rows (recorded before this shipped) stay valid. Included in
// `outputTokens` per the AI SDK usage shape.
reasoningTokens?: number;
};
// Current context size for the turn = final-step (input+output) tokens, i.e.
// how much the conversation occupies in the model's context window after this

View File

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

View File

@@ -314,6 +314,57 @@ describe("buildChatMarkdown — token totals", () => {
});
expect(md).toContain("- Total tokens: 99");
});
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
},
}),
],
t,
});
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
});
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
const zero = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
},
}),
],
t,
});
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
expect(zero).not.toContain("reasoning:");
const absent = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(absent).not.toContain("reasoning:");
});
});
describe("buildChatMarkdown — pending / in-progress messages", () => {

View File

@@ -77,6 +77,7 @@ function rowTokens(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
@@ -175,8 +176,14 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const usage = row.metadata?.usage;
if (usage) {
const total = usage.totalTokens ?? rowTokens(usage);
// Reasoning (thinking) tokens are shown only when the provider reported a
// positive count; old rows / non-reasoning providers omit it.
const reasoning =
usage.reasoningTokens && usage.reasoningTokens > 0
? `, reasoning: ${usage.reasoningTokens}`
: "";
blocks.push(
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
);
}
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,8 @@ const formSchema = z.object({
driver: z.enum(["", ...AI_DRIVER_VALUES]),
chatModel: z.string(),
enabled: z.boolean(),
autoStart: z.boolean(),
launchMessage: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
@@ -83,6 +85,8 @@ export default function AiAgentRoleForm({
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "",
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"],
chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true,
autoStart: role?.autoStart ?? true,
launchMessage: role?.launchMessage ?? "",
});
form.resetDirty();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -122,6 +128,8 @@ export default function AiAgentRoleForm({
instructions: values.instructions,
modelConfig,
enabled: values.enabled,
autoStart: values.autoStart,
launchMessage: values.launchMessage,
};
await updateMutation.mutateAsync(payload);
} else {
@@ -132,6 +140,10 @@ export default function AiAgentRoleForm({
instructions: values.instructions,
modelConfig,
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);
}
@@ -195,6 +207,28 @@ export default function AiAgentRoleForm({
)}
</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
label={t("Enabled")}
checked={form.values.enabled}

View File

@@ -5,7 +5,8 @@ import {
rowToUiMessage,
prepareAgentStep,
buildPartialAssistantRecord,
chatStreamStartMetadata,
chatStreamMetadata,
accumulateStepUsage,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
@@ -298,18 +299,135 @@ describe('buildPartialAssistantRecord', () => {
});
/**
* chatStreamStartMetadata: attach the authoritative chatId to the streamed
* assistant UI message ONLY on the `start` part (so the client adopts the real
* created chat id at the first chunk — see #137). Any non-start part adds none.
* chatStreamMetadata: attach metadata to the streamed assistant UI message per
* part type — `chatId` on `start` (so the client adopts the real created chat id
* 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', () => {
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({
expect(chatStreamMetadata({ type: 'start' }, 'chat-1')).toEqual({
chatId: 'chat-1',
});
});
it('returns undefined for a finish part (any non-start part)', () => {
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined();
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
// 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),
metadata: {
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
// call (full history + tool results) plus the answer it just generated.
// 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.
// Scrub the SDK's hop-by-hop Connection header before it writes the head (Safari/HTTP2).
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, {
headers: { 'X-Accel-Buffering': 'no' },
// 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 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
// `start` part so it reaches the client (as message.metadata.chatId) at the
// very first chunk — before any second tab can race a newer chat into the
// list. This fixes the two-tab "adoption race" (#137) where a new chat in
// tab A could adopt tab B's id and leak its turns into the wrong row.
messageMetadata: ({ part }) => chatStreamStartMetadata(part, chatId),
// on the `start`, `finish-step` and `finish` stream parts (ai@6 — note the
// `finish-step` trigger relies on it being delivered as its own
// message-metadata chunk); we attach `chatId` on the `start` part so it
// reaches the client (as message.metadata.chatId) at the very first chunk —
// before any second tab can race a newer chat into the list. This fixes the
// 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) => {
// Reuse the shared formatter so provider error formatting stays
// 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`
* part (as `message.metadata.chatId`) so the client can adopt the real id for a
* new chat. See the client's adopt-chat-id.ts for the full #137 design.
* Pure metadata builder for the streamed assistant UI message. The AI SDK calls
* `messageMetadata` on the `start`, `finish-step` and `finish` stream parts; we
* 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(
part: { type: string },
export function chatStreamMetadata(
part: StreamMetadataPart,
chatId: string,
): { chatId: string } | undefined {
return part.type === 'start' ? { chatId } : undefined;
cumulativeStepUsage?: ChatStreamUsage,
): { 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. */

View File

@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'be a researcher',
modelConfig: null,
enabled: true,
autoStart: true,
launchMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false,
autoStart: true,
launchMessage: null,
createdAt,
updatedAt,
});
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
expect(patch2.emoji).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', () => {
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
} as CreateAgentRoleDto),
).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)', () => {
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
const list = await service.list('ws-1', false);
expect(list).toHaveLength(1);
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({
id: 'r1',
name: 'Researcher',
emoji: '🔬',
description: 'finds things',
enabled: true,
autoStart: true,
launchMessage: null,
});
// ...and the admin-only fields are absent (not just undefined).
expect('instructions' in item).toBe(false);
expect('modelConfig' in item).toBe(false);
expect('createdAt' 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 () => {

View File

@@ -22,6 +22,8 @@ export interface AgentRoleView {
instructions: string;
modelConfig: RoleModelConfig | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -31,6 +33,11 @@ export interface AgentRoleView {
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
* creator or timestamps, so non-admins never receive the admin-authored prompt
* 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 {
id: string;
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
emoji: string | null;
description: string | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
}
/**
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
instructions,
modelConfig: modelConfig as Record<string, unknown> | null,
enabled: dto.enabled ?? true,
autoStart: dto.autoStart ?? true,
// Empty/whitespace-only => null (client default launch message).
launchMessage: emptyToNull(dto.launchMessage),
});
return this.toView(row);
} catch (err) {
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
| Record<string, unknown>
| null),
enabled: dto.enabled,
autoStart: dto.autoStart,
// undefined => unchanged; '' => clear to null.
launchMessage:
dto.launchMessage === undefined
? undefined
: emptyToNull(dto.launchMessage),
});
} catch (err) {
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
instructions: row.instructions,
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
createdAt: row.createdAt,
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 {
return {
id: row.id,
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
emoji: row.emoji ?? null,
description: row.description ?? null,
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);
});
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()
@IsBoolean()
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). */
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
@IsOptional()
@IsBoolean()
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();
});
});
/**
* 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;
modelConfig?: ModelConfigValue;
enabled?: boolean;
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig),
enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || null,
})
.returningAll()
.executeTakeFirst();
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
// undefined => unchanged; null => clear; object => set.
modelConfig?: ModelConfigValue;
enabled?: boolean;
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
},
trx?: KyselyTransaction,
): Promise<void> {
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
set.modelConfig = jsonbObject(patch.modelConfig);
}
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
.updateTable('aiAgentRoles')
.set(set)

View File

@@ -601,6 +601,11 @@ export interface AiAgentRoles {
// { chatModel } | { driver, chatModel } | null. null => workspace default.
modelConfig: Json | null;
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>;
updatedAt: Generated<Timestamp>;
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: (
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageAlign: (
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
};
@@ -374,8 +376,27 @@ export const TiptapImage = Image.extend<ImageOptions>({
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
export function applyAlignment(container: HTMLElement, align: string) {
// 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";
} else if (align === "right") {
container.style.justifyContent = "flex-end";

View File

@@ -6,7 +6,9 @@ export interface SubpagesOptions {
view: any;
}
export interface SubpagesAttributes {}
export interface SubpagesAttributes {
recursive?: boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -31,6 +33,18 @@ export const Subpages = Node.create<SubpagesOptions>({
draggable: 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() {
return [
{