Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
34995ca85c fix(ai-mcp): address PR #208 review for inline Test button (#170)
- CHANGELOG: add an [Unreleased]/Added bullet for the per-row "Test" button
  (idle Test -> OK · N / Failed, tooltip, isolated per-row state).
- ai-mcp-server-row: show a red "Failed" when the request itself rejects
  (401/403/500/network), reading testMutation.isError — previously only a
  server-reported {ok:false} was surfaced and a real reject silently reverted
  to "Test". Tooltip uses error.response?.data?.message or the i18n fallback.
- ai-mcp-server-row: clarify the reset-effect comment (hasHeaders is a
  presence flag, so value-only token rotation is intentionally not reset).
- ai-mcp-server-row: drop the redundant disabled={isPending} (Mantine already
  disables a loading button).
- ru-RU: add the "No tools available" translation.
- tests: cover the request-reject failure, the empty tool list (OK · 0), and
  the reset-on-change effect (url / transport / hasHeaders) via rerender.

Note: kept the `"error" in result` guard instead of the suggested bare
`else if (result)` — optional chaining on `result?.ok` doesn't narrow the
discriminated union in the else branch, so the bare form fails tsc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:31 +03:00
claude code agent 227
c28d8cc648 feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row "Test" button to the external MCP servers list so admins can
check a server's connection straight from the list, without opening the edit
modal.

- Extract each list row into its own AiMcpServerRow component, each owning a
  dedicated useTestAiMcpServerMutation instance. This isolates loading/result
  state per row — a single list-level mutation would make every row's spinner
  and colour jump on any test.
- Button reflects the outcome with both colour AND label (a11y / colour-blind
  safe): idle "Test", loading, green "OK · {n}" (tool count), red "Failed".
  Fixed miw so the row does not jump as the label changes. A tooltip surfaces
  the tools list (success) or the sanitized error (failure).
- Reset the mutation when url/transport/hasHeaders change so a stale result
  does not stick on the non-remounting (keyed-by-id) row.
- Reuse the existing /workspace/ai-mcp-servers/test endpoint and mutation;
  backend/service/query unchanged.
- i18n: add "Failed" and "OK · {{count}}" (en + ru); add the missing "Test"
  key to ru-RU.
- Add a vitest suite covering idle/success/failure states and per-row
  isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:57:20 +03:00
13 changed files with 421 additions and 595 deletions

View File

@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Inline "Test" button per external MCP server.** Each server row in admin AI
settings now has its own "Test" button that runs an isolated connection check:
idle `Test` → green `OK · N` (with a tooltip listing the discovered tools, or
"No tools available") on success, or red `Failed` (tooltip with the sanitized
error) on a connection problem. State is per-row, so testing one server never
spins or recolours the others. (#170)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -78,13 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
per-turn work grew quadratically and saturated the main thread. The stream is now
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
markdown part / reasoning block is memoized, so a long turn no longer re-parses
already-finished content. (#182)
- **Editor: caret/selection landed on the wrong line when clicking inside code
blocks and footnotes.** The affected NodeViews rendered their non-editable
chrome (language menu, footnotes heading, footnote number marker) before the

View File

@@ -713,6 +713,8 @@
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
"Test": "Test",
"Failed": "Failed",
"OK · {{count}}": "OK · {{count}}",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Created successfully": "Created successfully",

View File

@@ -1169,5 +1169,9 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
"OpenAI (official)": "OpenAI (официальный)",
"Test": "Тест",
"Failed": "Ошибка",
"OK · {{count}}": "OK · {{count}}",
"No tools available": "Нет доступных инструментов"
}

View File

@@ -29,14 +29,6 @@ import {
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
// Throttle how often the streamed `messages` state triggers a re-render. Without
// it, useChat updates state on EVERY token, so the whole transcript's markdown
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
// into a quadratic CPU storm that pins the main thread and freezes the UI.
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
// from the token rate.
const STREAM_THROTTLE_MS = 50;
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -254,8 +246,6 @@ export default function ChatThread({
id: chatStoreId,
messages: initialMessages,
transport,
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
experimental_throttle: STREAM_THROTTLE_MS,
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them

View File

@@ -1,81 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
// OTHER named export of markdown.ts intact via `importActual`, and override only
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
// still renders. This is the seam that proves the MarkdownPart memo works: a
// finalized text part must NOT be re-parsed on a later streamed delta.
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
import MessageItem from "./message-item";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} />
</MantineProvider>,
);
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
const callsFor = (text: string) =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
describe("MessageItem markdown memoization", () => {
it("does not re-parse finalized text parts when only a tail part grows", () => {
renderChatMarkdownSpy.mockClear();
// Two finalized text parts.
const first = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
const { rerender } = renderRow(first);
// Both finalized parts parsed exactly once on the initial render.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
// A streamed delta: a NEW message object where only a third tail part grows;
// the first two parts' text is byte-identical.
const next = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
{ type: "text", text: "gamm" },
]);
rerender(
<MantineProvider>
<MessageItem message={next} />
</MantineProvider>,
);
// The finalized parts hit the MarkdownPart memo: still parsed at most once
// each across BOTH renders (the resilient invariant). The only new parse is
// for the changed/added tail part.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
});

View File

@@ -1,73 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next: importing the component module pulls in `useTranslation`,
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
// minimal `t` that echoes the key is enough. Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { arePropsEqual } from "./message-item";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
...over,
});
describe("arePropsEqual", () => {
it("returns false when showCitations differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { showCitations: false })),
).toBe(false);
});
it("returns false when neutralizeInternalLinks differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
).toBe(false);
});
it("returns false when assistantName differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
).toBe(false);
});
it("returns true on the identity fast path (same message object, equal props)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]);
expect(a).not.toBe(b);
expect(arePropsEqual(props(a), props(b))).toBe(true);
});
it("returns false when content changed in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
});

View File

@@ -1,4 +1,3 @@
import { memo } from "react";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -11,7 +10,6 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
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 { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -36,39 +34,6 @@ interface MessageItemProps {
assistantName?: string;
}
/**
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
* primitives, so React.memo's default shallow compare is exactly right (the
* `text` string is compared by value).
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
<div
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{text}
</Text>
);
});
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.
@@ -76,13 +41,12 @@ const MarkdownPart = memo(function MarkdownPart({
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
* User messages render their text as a right-aligned plain bubble.
*
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
* per-message content signature: the streaming TAIL message's signature changes
* on each delta so it still re-renders and streams in, while finalized rows are
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
* long turn no longer re-parses the whole transcript on every token.
* This component is intentionally NOT memoized: `useChat` replaces the streaming
* assistant message with a freshly cloned object on every streamed delta, so the
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
*/
function MessageItem({
export default function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
@@ -145,12 +109,24 @@ function MessageItem({
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
key={index}
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{part.text}
</Text>
);
}
@@ -201,26 +177,3 @@ function MessageItem({
</Box>
);
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useState } from "react";
import { useState } from "react";
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -27,23 +27,19 @@ interface ReasoningBlockProps {
* 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.
*/
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
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();
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
// Collapse the blank-line gaps the model emits between every list item /
// paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — see collapseBlankLines. ONLY here, not in the normal answer.
const html = trimmed
? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "";
return (
<Box className={classes.reasoningBlock} mb={6}>
@@ -91,8 +87,3 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
</Box>
);
}
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
// shallow compare), so a parent re-render during streaming of OTHER content does
// not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock);

View File

@@ -1,241 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Pure-helper tests for `messageSignature`, the cheap per-message content
* signature that drives MessageItem's memo (a streaming row's signature must
* change on every delta so it re-renders, while a finalized row's stays stable
* so it is skipped). Each test exercises ONE change signal and asserts it flips
* the signature; a content-identical clone must keep an EQUAL signature.
*
* The signature embeds `message.id` and `message.role`, so the `msg` factory
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
* identical content would get different signatures and the negative case would
* be impossible to express.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: "m1",
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("messageSignature", () => {
it("changes when a text part grows", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([{ type: "text", text: "alpha beta" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a new part is appended", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part's state flips", () => {
const before = msg([
{ type: "tool-getPage", state: "input-streaming" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a tool part gains an output", () => {
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { ok: true },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part gains an errorText", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "boom",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
// The specifically-commented edge case: the authoritative turn total lands on
// the final finish-step AFTER the reasoning text length and state are frozen.
// Only the token count appears between these two snapshots, so the signature
// MUST still flip — otherwise the "Thinking · N tokens" header would never
// snap from the live estimate to the exact figure.
const before = msg([
{ type: "reasoning", text: "thinking", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "thinking", state: "done" } as never],
{ usage: { reasoningTokens: 42 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.error appears", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
// A finalized row that is re-created as a fresh object (different parts array
// by reference, same parts by value) must keep an EQUAL signature, so the
// memo skips re-rendering it.
const a = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
const b = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
expect(a).not.toBe(b);
expect(messageSignature(a)).toBe(messageSignature(b));
});
});
/**
* Per-part-kind coupling guard for the load-bearing invariant documented at the
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
* MessageItem render body draws, or the memo freezes a stale row. This is an
* executable lock for the part kinds rendered TODAY — read alongside
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
* helper (message-content.ts), which "mirrors MessageItem's render decisions
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
* signature. If a new visible field is rendered without being added here AND to
* the signature, the corresponding assertion below should fail — that is the
* guard. (This intentionally stops short of the render-descriptor refactor:
* adding a part kind or a visible field still requires a human to extend both
* the signature and this block.)
*/
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
it("flips when the visible text changes", () => {
// Streaming is append-only, so the visible text only grows; the signature
// samples its length, so the growth is the change signal.
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer extended" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
it("flips when the visible reasoning text changes", () => {
const before = msg([
{ type: "reasoning", text: "think", state: "streaming" } as never,
]);
const after = msg([
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
// finish-step after text length and state are frozen.
const before = msg([
{ type: "reasoning", text: "think", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "think", state: "done" } as never],
{ usage: { reasoningTokens: 99 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
it("flips when the run state changes (running ↔ done icon + label)", () => {
// toolRunState(part.state) selects the spinner/check/error icon.
const before = msg([
{ type: "tool-getPage", state: "input-available" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when output arrives (drives the rendered citation links)", () => {
// toolCitations reads part.output to render the "/p/{id}" anchors.
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { id: "page-1", title: "Doc" },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when errorText appears (the visible red error detail line)", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "permission denied",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("metadata banners — render draws error / aborted notices", () => {
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
});

View File

@@ -1,44 +0,0 @@
import type { UIMessage } from "@ai-sdk/react";
/** Cheap content signature for one message: changes iff something VISIBLE in the
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
* appended, a tool/text part flips state once), so a per-part [type, text
* length, state, error/output presence] tuple + the persisted metadata
* (error/finishReason) is a sufficient change signal without comparing full
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
* text length, state, error/output presence] (e.g. a tool that streams
* `preliminary` output, or a client-side regenerate that edits a finalized
* row in place), extend this signature or the memo will freeze a stale row. */
export function messageSignature(message: UIMessage): string {
const parts = message.parts
.map((p) => {
const any = p as {
type: string;
text?: string;
state?: string;
errorText?: string;
output?: unknown;
};
return [
any.type,
any.text?.length ?? 0,
any.state ?? "",
any.errorText ? 1 : 0,
any.output !== undefined ? 1 : 0,
].join(":");
})
.join("|");
const meta = message.metadata as
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
| undefined;
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
// state are already frozen. Without it in the signature the row's signature would be
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
// to the exact figure.
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
meta?.finishReason ?? ""
}#${meta?.usage?.reasoningTokens ?? ""}`;
}

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps assertions on the row's OWN label logic, mirroring 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,
}),
}));
// Mock only the network call. The REAL useTestAiMcpServerMutation runs on a real
// QueryClient so each row gets a genuinely independent mutation instance — this
// is exactly the isolation the feature relies on (#170).
const testAiMcpServer = vi.fn();
vi.mock("@/features/workspace/services/ai-mcp-server-service.ts", () => ({
testAiMcpServer: (id: string) => testAiMcpServer(id),
}));
import AiMcpServerRow from "./ai-mcp-server-row.tsx";
const baseServer = (over?: Partial<IAiMcpServer>): IAiMcpServer => ({
id: "srv-1",
name: "Search",
transport: "http",
url: "https://example.com/mcp",
enabled: true,
toolAllowlist: null,
hasHeaders: false,
instructions: null,
...over,
});
function tree(server: IAiMcpServer, testid: string) {
return (
<div data-testid={testid}>
<AiMcpServerRow
server={server}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</div>
);
}
function renderRow(server: IAiMcpServer, testid: string) {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
const utils = render(
<QueryClientProvider client={client}>
<MantineProvider>{tree(server, testid)}</MantineProvider>
</QueryClientProvider>,
);
// A rerender helper that swaps only the server prop (same QueryClient, so the
// row keeps its mutation state and the reset-on-change effect is exercised).
const rerenderWith = (next: IAiMcpServer) =>
utils.rerender(
<QueryClientProvider client={client}>
<MantineProvider>{tree(next, testid)}</MantineProvider>
</QueryClientProvider>,
);
return { ...utils, rerenderWith };
}
describe("AiMcpServerRow — inline Test button", () => {
beforeEach(() => {
testAiMcpServer.mockReset();
});
it("starts in the idle state with a plain 'Test' label", () => {
renderRow(baseServer(), "row");
const row = screen.getByTestId("row");
expect(within(row).getByRole("button", { name: "Test" })).toBeDefined();
});
it("shows a green 'OK · N' label with the tool count on success", async () => {
testAiMcpServer.mockResolvedValue({ ok: true, tools: ["a", "b", "c"] });
renderRow(baseServer(), "row");
const row = screen.getByTestId("row");
fireEvent.click(within(row).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row).getByRole("button", { name: /OK · 3/ })).toBeDefined(),
);
});
it("shows 'Failed' on a connection error", async () => {
testAiMcpServer.mockResolvedValue({ ok: false, error: "boom" });
renderRow(baseServer(), "row");
const row = screen.getByTestId("row");
fireEvent.click(within(row).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row).getByRole("button", { name: "Failed" })).toBeDefined(),
);
});
it("shows 'Failed' when the request itself rejects (401/403/500/network)", async () => {
// A real reject yields no { ok:false } payload — the row must read isError,
// not just mutation.data, or it would spin then silently revert to "Test".
testAiMcpServer.mockRejectedValue(new Error("Request failed"));
renderRow(baseServer(), "row");
const row = screen.getByTestId("row");
fireEvent.click(within(row).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row).getByRole("button", { name: "Failed" })).toBeDefined(),
);
});
it("shows 'OK · 0' and a 'No tools available' tooltip for an empty tool list", async () => {
testAiMcpServer.mockResolvedValue({ ok: true, tools: [] });
renderRow(baseServer(), "row");
const row = screen.getByTestId("row");
fireEvent.click(within(row).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row).getByRole("button", { name: /OK · 0/ })).toBeDefined(),
);
});
it("resets a stale result when url / transport / hasHeaders change", async () => {
testAiMcpServer.mockResolvedValue({ ok: true, tools: ["a", "b", "c"] });
const { rerenderWith } = renderRow(baseServer(), "row");
const row = () => screen.getByTestId("row");
fireEvent.click(within(row()).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(),
);
// Changing the URL must drop the stale green result back to idle "Test".
rerenderWith(baseServer({ url: "https://changed.example.com/mcp" }));
await waitFor(() =>
expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(),
);
// Same for the transport.
fireEvent.click(within(row()).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(),
);
rerenderWith(
baseServer({ url: "https://changed.example.com/mcp", transport: "sse" }),
);
await waitFor(() =>
expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(),
);
// And for the presence of auth headers.
fireEvent.click(within(row()).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(),
);
rerenderWith(
baseServer({
url: "https://changed.example.com/mcp",
transport: "sse",
hasHeaders: true,
}),
);
await waitFor(() =>
expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(),
);
});
it("keeps each row's result isolated (testing one does not affect another)", async () => {
// Resolve based on id so the two rows get different outcomes.
testAiMcpServer.mockImplementation(async (id: string) =>
id === "ok-1"
? { ok: true, tools: ["x", "y"] }
: { ok: false, error: "down" },
);
renderRow(baseServer({ id: "ok-1", name: "Good" }), "row-ok");
renderRow(baseServer({ id: "fail-1", name: "Bad" }), "row-fail");
const okRow = screen.getByTestId("row-ok");
fireEvent.click(within(okRow).getByRole("button", { name: "Test" }));
await waitFor(() =>
expect(within(okRow).getByRole("button", { name: /OK · 2/ })).toBeDefined(),
);
// The untouched row must still be idle — no shared/global pending state.
const failRow = screen.getByTestId("row-fail");
expect(within(failRow).getByRole("button", { name: "Test" })).toBeDefined();
});
});

View File

@@ -0,0 +1,161 @@
import { useEffect } from "react";
import { ActionIcon, Badge, Button, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import {
IconCheck,
IconPencil,
IconPlugConnected,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTestAiMcpServerMutation } from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
interface AiMcpServerRowProps {
server: IAiMcpServer;
onEdit: (server: IAiMcpServer) => void;
onDelete: (server: IAiMcpServer) => void;
onToggleEnabled: (server: IAiMcpServer, enabled: boolean) => void;
}
/**
* A single external MCP server row with an inline "Test" button. Each row owns
* its OWN test mutation instance so the loading/result state is isolated per
* row — a list-level mutation would make every row's spinner and colour jump on
* any single test (#170).
*/
export default function AiMcpServerRow({
server,
onEdit,
onDelete,
onToggleEnabled,
}: AiMcpServerRowProps) {
const { t } = useTranslation();
const testMutation = useTestAiMcpServerMutation();
// The result colour/label reflects the connection params at the time of the
// test. The row is keyed by id and never remounts, so a stale "OK"/"Failed"
// would otherwise stick after the connection params change. Reset on those.
// Note: `hasHeaders` is a presence flag only (header values are write-only and
// never returned), so this resets on adding/removing auth headers, NOT on
// rotating a token's value — that value-only change is invisible to the client.
useEffect(() => {
testMutation.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.url, server.transport, server.hasHeaders]);
const result = testMutation.data;
// Derive the button's appearance from the test outcome. Colour is never the
// only signal — the label changes too (a11y / colour-blind friendly).
let label = t("Test");
let color: string | undefined;
let variant = "default";
let icon = <IconPlugConnected size={16} />;
let tooltip: string | undefined;
if (result?.ok) {
label = t("OK · {{count}}", { count: result.tools.length });
color = "green";
variant = "light";
icon = <IconCheck size={16} />;
tooltip =
result.tools.length > 0
? result.tools.join(", ")
: t("No tools available");
} else if (result && "error" in result) {
// Server-reported failure ({ ok: false, error }, HTTP 200). The error string
// is already sanitized server-side (no secrets). The `"error" in result`
// guard is required: `result?.ok` optional-chaining doesn't narrow the union
// in the else branch, so a bare `else if (result)` fails to type-check.
label = t("Failed");
color = "red";
variant = "light";
icon = <IconX size={16} />;
tooltip = result.error;
} else if (testMutation.isError) {
// The request itself rejected (401/403/500/network) — there is no result
// payload, so without this the row would silently revert to "Test".
label = t("Failed");
color = "red";
variant = "light";
icon = <IconX size={16} />;
tooltip =
testMutation.error?.["response"]?.data?.message ??
t("Failed to update data");
}
const testButton = (
<Button
size="xs"
variant={variant}
color={color}
// Fixed min-width so the row does not jump as the label changes
// (Test -> OK · 5 -> Failed).
miw={88}
leftSection={icon}
// Mantine disables the button automatically while loading.
loading={testMutation.isPending}
onClick={() => testMutation.mutate(server.id)}
>
{label}
</Button>
);
return (
<Group justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{/* Show the tooltip (tools list / error) only once there is a result. */}
{tooltip ? (
<Tooltip label={tooltip} multiline maw={320} withArrow>
{testButton}
</Tooltip>
) : (
testButton
)}
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
onToggleEnabled(server, event.currentTarget.checked)
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => onEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => onDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -1,6 +1,5 @@
import { useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
@@ -8,12 +7,11 @@ import {
Modal,
Paper,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
@@ -23,6 +21,7 @@ import {
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
import AiMcpServerRow from "./ai-mcp-server-row.tsx";
/**
* Admin section: list / add / edit / delete external MCP servers the agent may
@@ -112,55 +111,16 @@ export default function AiMcpServers() {
<Stack gap="xs" mt="sm">
{servers?.map((server) => (
<Group key={server.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: server.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
// Keyed by id (never remounts) so each row keeps its own test state.
<AiMcpServerRow
key={server.id}
server={server}
onEdit={openEdit}
onDelete={confirmDelete}
onToggleEnabled={(s, enabled) =>
updateMutation.mutate({ id: s.id, enabled })
}
/>
))}
</Stack>