Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
96a1bda8d0 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:32:11 +03:00
claude code agent 227
2a07255ffb 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 06:13:09 +03:00
38 changed files with 42 additions and 1631 deletions

View File

@@ -157,19 +157,6 @@ 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,12 +1147,6 @@
"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.",
@@ -1164,7 +1158,6 @@
"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…",
@@ -1273,10 +1266,6 @@
"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",
@@ -1299,8 +1288,6 @@
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",

View File

@@ -677,21 +677,9 @@
"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-чат отключён для этого рабочего пространства.",
@@ -702,7 +690,6 @@
"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}}",
@@ -1151,8 +1138,6 @@
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
"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": "Переключить режим отображения подстраниц",

View File

@@ -156,12 +156,6 @@ 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"
@@ -491,19 +485,11 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span>
</Tooltip>
) : contextTokens > 0 ? (
{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 }}>
@@ -622,7 +608,6 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

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

View File

@@ -21,13 +21,8 @@ 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,
@@ -74,12 +69,6 @@ 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;
}
/**
@@ -124,7 +113,6 @@ export default function ChatThread({
assistantName,
onTurnFinished,
liveStateRef,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -322,98 +310,21 @@ 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;
// 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.
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
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);
}
sendMessage({ text: t("Take a look at the current document") });
};
// 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 showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;

View File

@@ -2,14 +2,12 @@ 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";
@@ -79,31 +77,12 @@ 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,7 +6,6 @@ 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 {
@@ -95,19 +94,6 @@ 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
@@ -204,13 +190,7 @@ export default function MessageList({
assistantName={assistantName}
/>
))}
{typing && (
<TypingIndicator
assistantName={assistantName}
showName={typingIndicatorShowsName(messages)}
thinkingTokens={tailThinkingTokens(messages)}
/>
)}
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />}
</Stack>
</ScrollArea>
);

View File

@@ -1,65 +0,0 @@
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

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

View File

@@ -1,50 +0,0 @@
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,12 +16,6 @@ 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;
}
/**
@@ -36,14 +30,9 @@ interface TypingIndicatorProps {
* typing line is always the generic "Thinking…" (it never includes the
* role/identity name).
*/
export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
export default function TypingIndicator({ assistantName, showName = true }: 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}>
@@ -59,7 +48,7 @@ export default function TypingIndicator({ assistantName, showName = true, thinki
<span />
</span>
<Text size="sm" c="dimmed">
{thinkingLine}
{t("Thinking…")}
</Text>
</Group>
</Box>

View File

@@ -53,10 +53,6 @@ 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;
}
@@ -69,8 +65,6 @@ export interface IAiRoleCreate {
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/** Admin update payload for a role (partial). */
@@ -82,8 +76,6 @@ export interface IAiRoleUpdate {
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
autoStart?: boolean;
launchMessage?: string;
}
/**
@@ -106,10 +98,6 @@ 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 `chatStreamMetadata`) reference here
* (use-chat-session.ts, the server's `chatStreamStartMetadata`) 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 `chatStreamMetadata` server-side);
* metadata's `start` part (see `chatStreamStartMetadata` 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
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
* `chatStreamStartMetadata`). 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,57 +314,6 @@ 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,7 +77,6 @@ function rowTokens(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
@@ -176,14 +175,8 @@ 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 ?? "?"}${reasoning}, total: ${total}_`,
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
);
}
});

View File

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

@@ -1,94 +0,0 @@
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

@@ -1,56 +0,0 @@
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

@@ -1,34 +0,0 @@
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

@@ -1,72 +0,0 @@
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

@@ -1,34 +0,0 @@
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,18 +73,3 @@
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,8 +13,6 @@ import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconDownload,
IconRefresh,
IconTrash,
@@ -43,8 +41,6 @@ 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 || "",
};
@@ -108,22 +104,6 @@ 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);
@@ -221,30 +201,6 @@ 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

@@ -53,8 +53,6 @@ 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>;
@@ -85,8 +83,6 @@ 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 ?? "",
},
});
@@ -100,8 +96,6 @@ 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
@@ -128,8 +122,6 @@ export default function AiAgentRoleForm({
instructions: values.instructions,
modelConfig,
enabled: values.enabled,
autoStart: values.autoStart,
launchMessage: values.launchMessage,
};
await updateMutation.mutateAsync(payload);
} else {
@@ -140,10 +132,6 @@ 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);
}
@@ -207,28 +195,6 @@ 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,8 +5,7 @@ import {
rowToUiMessage,
prepareAgentStep,
buildPartialAssistantRecord,
chatStreamMetadata,
accumulateStepUsage,
chatStreamStartMetadata,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
@@ -299,135 +298,18 @@ describe('buildPartialAssistantRecord', () => {
});
/**
* 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.
* 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.
*/
describe('chatStreamMetadata', () => {
describe('chatStreamStartMetadata', () => {
it('returns { chatId } for the start part', () => {
expect(chatStreamMetadata({ type: 'start' }, 'chat-1')).toEqual({
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({
chatId: 'chat-1',
});
});
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,
});
it('returns undefined for a finish part (any non-start part)', () => {
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined();
});
});

View File

@@ -420,11 +420,7 @@ export class AiChatService {
toolCalls: serializeSteps(steps),
metadata: {
finishReason,
// 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,
usage: 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,
@@ -516,42 +512,17 @@ 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`, `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,
// 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),
onError: (error: unknown) => {
// Reuse the shared formatter so provider error formatting stays
// unified between the log line and the streamed error message.
@@ -602,97 +573,16 @@ 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),
};
}
/**
* 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.
* 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.
*/
export function chatStreamMetadata(
part: StreamMetadataPart,
export function chatStreamStartMetadata(
part: { type: string },
chatId: string,
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;
): { chatId: string } | undefined {
return part.type === 'start' ? { chatId } : undefined;
}
/** The last message with role 'user' from a useChat payload, if any. */

View File

@@ -25,8 +25,6 @@ describe('AiAgentRolesService guards', () => {
instructions: 'be a researcher',
modelConfig: null,
enabled: true,
autoStart: true,
launchMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
@@ -161,8 +159,6 @@ describe('AiAgentRolesService guards', () => {
instructions: 'updated instructions',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
enabled: false,
autoStart: true,
launchMessage: null,
createdAt,
updatedAt,
});
@@ -190,35 +186,6 @@ 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', () => {
@@ -352,40 +319,6 @@ 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)', () => {
@@ -416,25 +349,19 @@ 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 — INCLUDING the auto-start fields, which
// the client needs to decide whether/what to auto-send on role pick.
// The picker fields ARE present...
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,8 +22,6 @@ export interface AgentRoleView {
instructions: string;
modelConfig: RoleModelConfig | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -33,11 +31,6 @@ 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;
@@ -45,8 +38,6 @@ export interface AgentRolePickerView {
emoji: string | null;
description: string | null;
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
}
/**
@@ -96,9 +87,6 @@ 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) {
@@ -140,12 +128,6 @@ 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);
@@ -174,18 +156,12 @@ 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 plus the auto-start
* fields the client needs to decide whether/what to send on role pick. Still
* WITHOUT instructions/modelConfig (admin-only).
*/
/** Non-admin picker view: id/name/emoji/description/enabled only. */
private toPickerView(row: AiAgentRole): AgentRolePickerView {
return {
id: row.id,
@@ -193,8 +169,6 @@ export class AiAgentRolesService {
emoji: row.emoji ?? null,
description: row.description ?? null,
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
};
}
}

View File

@@ -78,32 +78,4 @@ 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,22 +65,6 @@ 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). */
@@ -114,19 +98,4 @@ 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

@@ -1,29 +0,0 @@
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,81 +49,3 @@ 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,9 +76,6 @@ 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> {
@@ -94,9 +91,6 @@ 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();
@@ -114,9 +108,6 @@ 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> {
@@ -130,11 +121,6 @@ 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,11 +601,6 @@ 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

@@ -1,67 +0,0 @@
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,9 +51,7 @@ declare module "@tiptap/core" {
setImageAt: (
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
};
@@ -376,27 +374,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
},
});
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") {
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";