diff --git a/frontend/src/components/assistant/AssistantMessage.tsx b/frontend/src/components/assistant/AssistantMessage.tsx
new file mode 100644
index 00000000..cce08257
--- /dev/null
+++ b/frontend/src/components/assistant/AssistantMessage.tsx
@@ -0,0 +1,186 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle, Info, OctagonX } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { AnswerChecks } from "@/types/api";
+import { AssistantMarkdown } from "./AssistantMarkdown";
+import { AssistantSources } from "./AssistantSources";
+import { cleanChecks, cleanSources, cleanToolsUsed, sourceAnchorId } from "./message-shape";
+import type { AssistantMessage as AssistantMessageData, AssistantMessageError } from "./useAssistantChat";
+
+const KNOWN_TOOLS = new Set([
+ "search_docs",
+ "list_my_projects",
+ "get_project",
+ "get_project_result",
+ "get_project_opinions",
+]);
+
+interface FailureBlockProps {
+ readonly cut: boolean;
+ /** Whether some of the answer is on screen above the block. */
+ readonly hasText: boolean;
+ readonly error?: AssistantMessageError;
+ readonly onRetry?: () => void;
+}
+
+/**
+ * The red block of a failed or interrupted turn. The text is chosen by the status code
+ * from fixed copy: `error.detail` is whatever the server or the network said, and is
+ * never shown.
+ */
+function FailureBlock({ cut, hasText, error, onRetry }: FailureBlockProps) {
+ const { t } = useTranslation("assistant");
+ let title = t("message.errors.generic");
+ let hint: string | null = null;
+
+ if (cut) {
+ title = t("message.errors.cut");
+ hint = hasText ? t("message.errors.cutHint") : null;
+ } else if (error?.code === 429) {
+ const seconds = error.retryAfter;
+ title =
+ seconds !== undefined && Number.isFinite(seconds) && seconds > 0
+ ? t("message.errors.rateLimited", { minutes: Math.ceil(seconds / 60) })
+ : t("message.errors.rateLimitedGeneric");
+ } else if (error?.code === 503) {
+ title = t("message.errors.unavailable");
+ hint = t("message.errors.unavailableHint");
+ } else if (error?.code === 404) {
+ title = t("message.errors.notFound");
+ hint = t("message.errors.notFoundHint");
+ }
+
+ return (
+
+
+
+
{title}
+ {hint &&
{hint}
}
+ {onRetry && (
+
+ {t("message.retry")}
+
+ )}
+
+
+ );
+}
+
+function ChecksLines({ checks }: { readonly checks: AnswerChecks }) {
+ const { t } = useTranslation("assistant");
+ const lines: string[] = [];
+ if (!checks.numbers_grounded) {
+ lines.push(
+ checks.ungrounded_numbers.length > 0
+ ? t("message.numbersNotConfirmed", { numbers: checks.ungrounded_numbers.join(", ") })
+ : t("message.numbersNotConfirmedGeneric"),
+ );
+ }
+ if (!checks.citations_valid) {
+ lines.push(t("message.citationInvalid"));
+ }
+
+ return lines.map((line) => (
+
+
+ {line}
+
+ ));
+}
+
+function ToolsLine({ tools }: { readonly tools: readonly string[] }) {
+ const { t } = useTranslation("assistant");
+ const labels = tools.map((tool) => (KNOWN_TOOLS.has(tool) ? t(`message.tools.${tool}`) : tool));
+
+ return (
+
+
+ {t("message.read", { tools: labels.join(", ") })}
+
+ );
+}
+
+export interface AssistantMessageProps {
+ readonly message: AssistantMessageData;
+ readonly onRetry?: () => void;
+}
+
+function AssistantAnswer({ message, onRetry }: AssistantMessageProps) {
+ const { t } = useTranslation("assistant");
+ const [openN, setOpenN] = useState(null);
+ // A fresh object per citation click, so citing the row that is already open scrolls again.
+ const [scrollRequest, setScrollRequest] = useState<{ readonly n: number } | null>(null);
+ const sources = useMemo(() => cleanSources(message.sources), [message.sources]);
+ const sourceNumbers = useMemo(() => sources.map((source) => source.n), [sources]);
+ const tools = cleanToolsUsed(message.toolsUsed);
+ const checks = cleanChecks(message.checks);
+
+ const { status } = message;
+ const partial = status === "error" || status === "cut" || status === "cancelled";
+ const hasText = message.content.trim() !== "";
+
+ const messageId = message.id;
+ const handleCite = useCallback((n: number) => {
+ setOpenN(n);
+ setScrollRequest({ n });
+ }, []);
+
+ // After the render that opened the row, so its detail exists when the scroll measures it.
+ useEffect(() => {
+ if (scrollRequest === null) return;
+ document.getElementById(sourceAnchorId(messageId, scrollRequest.n))?.scrollIntoView?.({ block: "nearest" });
+ }, [scrollRequest, messageId]);
+
+ function handleToggle(n: number) {
+ setOpenN((current) => (current === n ? null : n));
+ }
+
+ return (
+
+ {hasText && (
+
+ )}
+ {status === "cancelled" && (
+
+
+ {t("message.stopped")}
+ {hasText && · {t("message.stoppedHint")} }
+
+ )}
+ {(status === "error" || status === "cut") && (
+
+ )}
+ {checks &&
}
+ {sources.length > 0 && (
+
+ )}
+ {tools.length > 0 &&
}
+
+ );
+}
+
+/** One turn of the conversation. The user's text is never read as markdown. */
+export function AssistantMessage({ message, onRetry }: AssistantMessageProps) {
+ if (message.role === "user") {
+ return (
+
+ {message.content}
+
+ );
+ }
+ return ;
+}
diff --git a/frontend/src/components/assistant/AssistantSources.tsx b/frontend/src/components/assistant/AssistantSources.tsx
new file mode 100644
index 00000000..f63ee463
--- /dev/null
+++ b/frontend/src/components/assistant/AssistantSources.tsx
@@ -0,0 +1,95 @@
+import { useTranslation } from "react-i18next";
+import type { SourceRef } from "@/types/api";
+import { sourceAnchorId } from "./message-shape";
+import { isAllowedAssistantUrl } from "./url-allowlist";
+
+interface SourceRowProps {
+ readonly messageId: string;
+ readonly source: SourceRef;
+ readonly open: boolean;
+ readonly onToggle: (n: number) => void;
+}
+
+function SourceRow({ messageId, source, open, onToggle }: SourceRowProps) {
+ const { t } = useTranslation("assistant");
+ const rowId = sourceAnchorId(messageId, source.n);
+ const detailId = `${rowId}-detail`;
+ const isLocal = source.layer === "local";
+ // A local source never links, whatever its url says.
+ const href = !isLocal && isAllowedAssistantUrl(source.url) ? source.url : null;
+
+ return (
+
+
+ onToggle(source.n)}
+ className="flex min-w-0 flex-1 items-baseline gap-2 rounded text-left"
+ >
+ [{source.n}]
+
+ {source.title}
+ {source.section !== "" && · {source.section} }
+
+
+ {isLocal && (
+
+ {t("message.localCopy")}
+
+ )}
+
+ {open && (
+
+ {source.snippet !== "" && (
+
+ {source.snippet}
+
+ )}
+ {isLocal ?
{t("message.localHint")}
:
{t("message.publicLayer")}
}
+ {href && (
+
+ {t("message.openSource")}
+
+ )}
+
+ )}
+
+ );
+}
+
+export interface AssistantSourcesProps {
+ readonly messageId: string;
+ readonly sources: readonly SourceRef[];
+ readonly openN: number | null;
+ readonly onToggle: (n: number) => void;
+}
+
+/** Compact source rows; one row at a time shows its quote and, for a public page, a link. */
+export function AssistantSources({ messageId, sources, openN, onToggle }: AssistantSourcesProps) {
+ const { t } = useTranslation("assistant");
+
+ return (
+
+
{t("message.sources")}
+ {sources.map((source) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/assistant/message-shape.ts b/frontend/src/components/assistant/message-shape.ts
index 0b41cdff..18600f0f 100644
--- a/frontend/src/components/assistant/message-shape.ts
+++ b/frontend/src/components/assistant/message-shape.ts
@@ -1,4 +1,56 @@
+import type { AnswerChecks, SourceRef } from "@/types/api";
+
/** The id of a source row, which a `[n]` citation in the same message points at. */
export function sourceAnchorId(messageId: string, n: number): string {
return `assistant-src-${messageId}-${n}`;
}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function toSource(value: unknown): SourceRef | null {
+ if (!isRecord(value)) return null;
+ const { n, title, section, snippet, url, layer } = value;
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) return null;
+ if (typeof title !== "string" || typeof section !== "string") return null;
+ if (layer !== "public" && layer !== "local") return null;
+ if (url !== null && typeof url !== "string") return null;
+ // A stored entry from before snippets existed has none; it shows an empty quote.
+ return { n, title, section, snippet: typeof snippet === "string" ? snippet : "", url, layer };
+}
+
+/**
+ * Storage only checks that `sources` is an array and a stored message can be edited
+ * by hand, so each entry is checked here: malformed ones and repeated numbers drop out.
+ */
+export function cleanSources(value: unknown): SourceRef[] {
+ if (!Array.isArray(value)) return [];
+ const seen = new Set();
+ const sources: SourceRef[] = [];
+ for (const entry of value) {
+ const source = toSource(entry);
+ if (source && !seen.has(source.n)) {
+ seen.add(source.n);
+ sources.push(source);
+ }
+ }
+ return sources;
+}
+
+export function cleanToolsUsed(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((tool): tool is string => typeof tool === "string") : [];
+}
+
+/** Only a complete set of checks counts; anything else is treated as "no checks". */
+export function cleanChecks(value: unknown): AnswerChecks | null {
+ if (!isRecord(value)) return null;
+ const { citations_valid, numbers_grounded, ungrounded_numbers } = value;
+ if (typeof citations_valid !== "boolean" || typeof numbers_grounded !== "boolean") return null;
+ if (!Array.isArray(ungrounded_numbers)) return null;
+ return {
+ citations_valid,
+ numbers_grounded,
+ ungrounded_numbers: ungrounded_numbers.filter((n): n is string => typeof n === "string"),
+ };
+}
diff --git a/frontend/src/i18n/locales/cs/assistant.json b/frontend/src/i18n/locales/cs/assistant.json
index 579ddb3f..0d898278 100644
--- a/frontend/src/i18n/locales/cs/assistant.json
+++ b/frontend/src/i18n/locales/cs/assistant.json
@@ -31,6 +31,36 @@
"hint": "Enter odešle · Shift+Enter nový řádek"
},
"message": {
- "noSuchSource": "Zdroj neexistuje"
+ "sources": "Zdroje",
+ "localCopy": "Místní kopie",
+ "localHint": "Z neveřejného korpusu (diplomová práce, články). Veřejná stránka není.",
+ "publicLayer": "Dokumentace",
+ "openSource": "Otevřít stránku",
+ "noSuchSource": "Zdroj neexistuje",
+ "numbersNotConfirmed": "Čísla, která zdroje nepotvrzují: {{numbers}}",
+ "numbersNotConfirmedGeneric": "Některá čísla zdroje nepotvrzují.",
+ "citationInvalid": "Citace odkazuje na neexistující zdroj.",
+ "read": "Načteno: {{tools}}",
+ "tools": {
+ "search_docs": "dokumentace",
+ "list_my_projects": "projekty",
+ "get_project": "projekt",
+ "get_project_result": "výsledek",
+ "get_project_opinions": "názory"
+ },
+ "stopped": "Zastaveno",
+ "stoppedHint": "Částečná odpověď. S další otázkou se neodesílá.",
+ "retry": "Zkusit znovu",
+ "errors": {
+ "rateLimited": "Limit zpráv vyčerpán. Zkuste to znovu za {{minutes}} min.",
+ "rateLimitedGeneric": "Limit zpráv vyčerpán. Zkuste to znovu později.",
+ "unavailable": "Asistent je dočasně nedostupný.",
+ "unavailableHint": "Model neodpovídá.",
+ "notFound": "Tento projekt není dostupný.",
+ "notFoundHint": "Mohl být smazán nebo už nejste jeho členem.",
+ "generic": "Něco se pokazilo. Zkuste to prosím znovu.",
+ "cut": "Spojení se přerušilo před dokončením odpovědi.",
+ "cutHint": "Co dorazilo, je výše. S další otázkou se neodesílá."
+ }
}
}
diff --git a/frontend/src/i18n/locales/en/assistant.json b/frontend/src/i18n/locales/en/assistant.json
index f5371510..83e95696 100644
--- a/frontend/src/i18n/locales/en/assistant.json
+++ b/frontend/src/i18n/locales/en/assistant.json
@@ -31,6 +31,36 @@
"hint": "Enter to send · Shift+Enter for a new line"
},
"message": {
- "noSuchSource": "No such source"
+ "sources": "Sources",
+ "localCopy": "Local copy",
+ "localHint": "From the private corpus (thesis, articles). No public page.",
+ "publicLayer": "Documentation",
+ "openSource": "Open the page",
+ "noSuchSource": "No such source",
+ "numbersNotConfirmed": "Numbers not confirmed by the sources: {{numbers}}",
+ "numbersNotConfirmedGeneric": "Some numbers are not confirmed by the sources.",
+ "citationInvalid": "A citation points to no source.",
+ "read": "Read: {{tools}}",
+ "tools": {
+ "search_docs": "documentation",
+ "list_my_projects": "projects",
+ "get_project": "project",
+ "get_project_result": "result",
+ "get_project_opinions": "opinions"
+ },
+ "stopped": "Stopped",
+ "stoppedHint": "Partial answer. Not sent with your next question.",
+ "retry": "Try again",
+ "errors": {
+ "rateLimited": "Message limit reached. Try again in {{minutes}} min.",
+ "rateLimitedGeneric": "Message limit reached. Try again later.",
+ "unavailable": "The assistant is temporarily unavailable.",
+ "unavailableHint": "The model is not responding.",
+ "notFound": "This project is not available.",
+ "notFoundHint": "It may have been deleted or you are no longer a member.",
+ "generic": "Something went wrong. Please try again.",
+ "cut": "The connection dropped before the answer finished.",
+ "cutHint": "What arrived is shown above. It is not sent with your next question."
+ }
}
}
diff --git a/frontend/tests/components/assistant/AssistantMarkdown.test.tsx b/frontend/tests/components/assistant/AssistantMarkdown.test.tsx
index 88d2d396..bdede4f1 100644
--- a/frontend/tests/components/assistant/AssistantMarkdown.test.tsx
+++ b/frontend/tests/components/assistant/AssistantMarkdown.test.tsx
@@ -269,6 +269,24 @@ describe('AssistantMarkdown', () => {
expect(container.textContent).toBe('a [1] b [2] c');
});
+ it.each([
+ ['See [1][2].', ['[1]', '[2]']],
+ ['[1] opens the line', ['[1]']],
+ ])('turns the citations in %j into anchors in order', (content, names) => {
+ renderMarkdown(content, { sourceNumbers: [1, 2] });
+
+ expect(screen.getAllByRole('link').map((a) => a.textContent)).toEqual(names);
+ });
+
+ it('keeps the citation anchor node while the content grows', () => {
+ const { rerender } = renderMarkdown('A [1].', { sourceNumbers: [1] });
+ const cite = screen.getByRole('link', { name: '[1]' });
+
+ rerender( );
+
+ expect(screen.getByRole('link', { name: '[1]' })).toBe(cite);
+ });
+
it('leaves [1] literal inside inline code', () => {
renderMarkdown('use `arr[1]` here', { sourceNumbers: [1] });
diff --git a/frontend/tests/components/assistant/AssistantMessage.test.tsx b/frontend/tests/components/assistant/AssistantMessage.test.tsx
new file mode 100644
index 00000000..73560446
--- /dev/null
+++ b/frontend/tests/components/assistant/AssistantMessage.test.tsx
@@ -0,0 +1,475 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { render, screen, within } from '@tests/utils';
+import userEvent from '@testing-library/user-event';
+import i18n from '@/i18n';
+import { AssistantMessage } from '@/components/assistant/AssistantMessage';
+import type { AssistantMessage as Message } from '@/components/assistant/useAssistantChat';
+import '@/components/assistant/i18n';
+
+const base: Message = {
+ id: 'm1',
+ role: 'assistant',
+ content: 'The answer is 42 [1].',
+ status: 'done',
+ createdAt: 1,
+};
+
+const publicSource = {
+ n: 1,
+ title: 'Reading the result',
+ section: 'Confidence',
+ snippet: 'Quote from **the page**',
+ url: 'https://docs.becomify.app/user/reading-the-result',
+ layer: 'public' as const,
+};
+const localSource = {
+ n: 2,
+ title: 'BeCoMe article',
+ section: 'Introduction',
+ snippet: 'Quote from the article',
+ url: null,
+ layer: 'local' as const,
+};
+
+function renderMessage(message: Partial, onRetry?: () => void) {
+ return render( );
+}
+
+const rowButton = (name: RegExp) => screen.getByRole('button', { name });
+const originalScrollIntoView = Element.prototype.scrollIntoView;
+
+describe('AssistantMessage', () => {
+ afterEach(async () => {
+ Element.prototype.scrollIntoView = originalScrollIntoView;
+ await i18n.changeLanguage('en');
+ });
+
+ describe('text', () => {
+ it("renders the user's own message as plain text, not through markdown", () => {
+ const { container } = renderMessage({ role: 'user', content: '**not bold**\n# not a heading' });
+
+ expect(container.querySelector('strong')).toBeNull();
+ expect(container.querySelector('h1')).toBeNull();
+ expect(container.firstElementChild).toHaveClass('whitespace-pre-wrap');
+ expect(container).toHaveTextContent('**not bold**');
+ });
+
+ it('renders an assistant answer through markdown', () => {
+ renderMessage({ content: 'It is **bold**.' });
+
+ expect(screen.getByText('bold').tagName).toBe('STRONG');
+ });
+
+ it('keeps the line breaks of a fenced code block', () => {
+ const { container } = renderMessage({ content: '```\nline1\nline2\n```' });
+
+ const code = container.querySelector('code');
+ expect(code?.textContent).toBe('line1\nline2\n');
+ expect(code?.closest('[class*="[&_code]:whitespace-pre-wrap"]')).not.toBeNull();
+ });
+
+ it('renders the content so far for a pending message', () => {
+ renderMessage({ status: 'pending', content: 'partial words' });
+
+ expect(screen.getByText('partial words')).toBeInTheDocument();
+ });
+ });
+
+ describe('sources', () => {
+ it('renders compact rows with number, title and section, each with an id', () => {
+ const { container } = renderMessage({ sources: [publicSource, localSource] });
+
+ expect(rowButton(/\[1\].*Reading the result.*Confidence/)).toHaveAttribute('aria-expanded', 'false');
+ expect(rowButton(/\[2\].*BeCoMe article.*Introduction/)).toBeInTheDocument();
+ expect(container.querySelector('#assistant-src-m1-1')).not.toBeNull();
+ expect(container.querySelector('#assistant-src-m1-2')).not.toBeNull();
+ expect(screen.queryByText(/Quote from/)).not.toBeInTheDocument();
+ });
+
+ it('expands a row on click and collapses it on the second click', async () => {
+ renderMessage({ sources: [publicSource] });
+ const button = rowButton(/Reading the result/);
+
+ await userEvent.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ expect(document.getElementById(button.getAttribute('aria-controls') as string)).toHaveTextContent('Quote from');
+
+ await userEvent.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText(/Quote from/)).not.toBeInTheDocument();
+ });
+
+ it('keeps only one row open: opening another closes the first', async () => {
+ renderMessage({ sources: [publicSource, localSource] });
+
+ await userEvent.click(rowButton(/Reading the result/));
+ await userEvent.click(rowButton(/BeCoMe article/));
+
+ expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'false');
+ expect(rowButton(/BeCoMe article/)).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.queryByText(/Quote from \*\*the page\*\*/)).not.toBeInTheDocument();
+ expect(screen.getByText(/Quote from the article/)).toBeInTheDocument();
+ });
+
+ it.each([
+ ['markdown', 'Quote from **the page**', 'strong'],
+ ['HTML', ' ', 'img'],
+ ])('shows a snippet with %s as literal text, never as an element', async (_label, snippet, tag) => {
+ const { container } = renderMessage({ sources: [{ ...publicSource, snippet }] });
+
+ await userEvent.click(rowButton(/Reading the result/));
+
+ expect(screen.getByText(snippet)).toBeInTheDocument();
+ expect(container.querySelector(tag)).toBeNull();
+ });
+
+ it('gives a public https source a safe link when expanded', async () => {
+ renderMessage({ sources: [publicSource] });
+ expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument();
+
+ await userEvent.click(rowButton(/Reading the result/));
+
+ const link = screen.getByRole('link', { name: 'Open the page' });
+ expect(link).toHaveAttribute('href', publicSource.url);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(screen.getByText('Documentation')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['http:', 'http://docs.becomify.app/user'],
+ ['javascript:', 'javascript:alert(1)'],
+ ['a relative path', '/user/reading'],
+ ['null', null],
+ ])('renders a public source with a %s url as text, with no link', async (_label, url) => {
+ renderMessage({ sources: [{ ...publicSource, url }] });
+
+ await userEvent.click(rowButton(/Reading the result/));
+
+ expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument();
+ expect(screen.getByText(/Quote from/)).toBeInTheDocument();
+ });
+
+ it('never links a local source, even when it carries a url, and shows the badge and hint', async () => {
+ renderMessage({ sources: [{ ...localSource, url: 'https://example.com/leak' }] });
+
+ expect(screen.getByText('Local copy')).toBeInTheDocument();
+ await userEvent.click(rowButton(/BeCoMe article/));
+
+ expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument();
+ expect(document.body.innerHTML).not.toContain('example.com/leak');
+ expect(screen.getAllByText(/private corpus/).length).toBeGreaterThan(0);
+ });
+
+ it('shows no Local copy badge for a public source', () => {
+ renderMessage({ sources: [publicSource] });
+
+ expect(screen.queryByText('Local copy')).not.toBeInTheDocument();
+ });
+
+ it('expands the source and scrolls its row into view when a citation is clicked', async () => {
+ const scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ const { container } = renderMessage({ sources: [publicSource] });
+
+ await userEvent.click(screen.getByRole('link', { name: '[1]' }));
+
+ expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'true');
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
+ expect(scrollIntoView.mock.contexts[0]).toBe(container.querySelector('#assistant-src-m1-1'));
+ expect(window.location.hash).toBe('');
+ });
+
+ it('scrolls only once the detail exists, and again when the open row is cited again', async () => {
+ const detailAtCall: boolean[] = [];
+ Element.prototype.scrollIntoView = vi.fn(() => {
+ detailAtCall.push(document.getElementById('assistant-src-m1-1-detail') !== null);
+ });
+ renderMessage({ sources: [publicSource] });
+
+ await userEvent.click(screen.getByRole('link', { name: '[1]' }));
+ await userEvent.click(screen.getByRole('link', { name: '[1]' }));
+
+ expect(detailAtCall).toEqual([true, true]);
+ });
+
+ it('still expands the source where scrollIntoView does not exist', async () => {
+ // Some environments lack the method; the click must neither throw nor stop the expansion.
+ Object.defineProperty(Element.prototype, 'scrollIntoView', { value: undefined, configurable: true, writable: true });
+ const onError = vi.fn((event: ErrorEvent) => event.preventDefault());
+ window.addEventListener('error', onError);
+ renderMessage({ sources: [publicSource] });
+
+ await userEvent.click(screen.getByRole('link', { name: '[1]' }));
+ window.removeEventListener('error', onError);
+
+ expect(onError).not.toHaveBeenCalled();
+ expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('strikes through a citation that no source backs', () => {
+ renderMessage({ content: 'Claim [4].', sources: [publicSource] });
+
+ expect(screen.queryByRole('link', { name: '[4]' })).not.toBeInTheDocument();
+ expect(screen.getByText('[4]').closest('s')).not.toBeNull();
+ });
+
+ it.each(['pending', 'cut', 'cancelled', 'error'] as const)(
+ 'leaves a [1] in a %s answer as plain text, since its sources are not known',
+ (status) => {
+ const { container } = renderMessage({ status, content: 'See [1].', sources: [publicSource] });
+
+ expect(container.querySelector('s')).toBeNull();
+ expect(screen.queryByRole('link', { name: '[1]' })).not.toBeInTheDocument();
+ expect(container).toHaveTextContent('See [1].');
+ expect(container).not.toHaveTextContent('No such source');
+ },
+ );
+
+ it('shows a source row without a dangling separator when its section is empty', () => {
+ renderMessage({ sources: [{ ...publicSource, section: '' }] });
+
+ expect(rowButton(/Reading the result/).textContent).toBe('[1]Reading the result');
+ });
+
+ it('drops malformed sources without throwing and keeps the good ones', () => {
+ const sources = [
+ { ...publicSource, title: undefined },
+ { ...publicSource, n: '1' },
+ { ...publicSource, n: 5, layer: 'evil' },
+ null,
+ 'x',
+ localSource,
+ { ...localSource, title: 'Duplicate of 2' },
+ ] as unknown as Message['sources'];
+
+ renderMessage({ sources, content: 'text' });
+
+ expect(screen.getAllByRole('button')).toHaveLength(1);
+ expect(rowButton(/BeCoMe article/)).toBeInTheDocument();
+ expect(screen.queryByText('Duplicate of 2')).not.toBeInTheDocument();
+ });
+
+ it('treats a source stored without a snippet as an empty quote', async () => {
+ const older: Record = { ...publicSource };
+ delete older.snippet;
+ const { container } = renderMessage({ sources: [older as unknown as typeof publicSource] });
+
+ await userEvent.click(rowButton(/Reading the result/));
+
+ expect(screen.getByRole('link', { name: 'Open the page' })).toBeInTheDocument();
+ expect(container.querySelector('q')).toBeNull();
+ });
+
+ it.each(['nope', []])('renders no sources block for %j', (sources) => {
+ renderMessage({ sources: sources as Message['sources'] });
+
+ expect(screen.queryByText('Sources')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('checks', () => {
+ it('shows one line with the numbers when numbers are not grounded', () => {
+ renderMessage({ checks: { citations_valid: true, numbers_grounded: false, ungrounded_numbers: ['17', '4.5'] } });
+
+ expect(screen.getByText('Numbers not confirmed by the sources: 17, 4.5')).toBeInTheDocument();
+ expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument();
+ });
+
+ it('shows the citation line when a citation is invalid', () => {
+ renderMessage({ checks: { citations_valid: false, numbers_grounded: true, ungrounded_numbers: [] } });
+
+ expect(screen.getByText('A citation points to no source.')).toBeInTheDocument();
+ expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument();
+ });
+
+ it('shows both lines when both checks fail', () => {
+ renderMessage({ checks: { citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['1'] } });
+
+ expect(screen.getByText(/Numbers not confirmed/)).toBeInTheDocument();
+ expect(screen.getByText(/points to no source/)).toBeInTheDocument();
+ });
+
+ it.each([
+ ['both checks pass', { citations_valid: true, numbers_grounded: true, ungrounded_numbers: [] }],
+ ['the flags are not booleans', { citations_valid: 'false', numbers_grounded: 'false', ungrounded_numbers: [] }],
+ ])('shows nothing when %s', (_label, checks) => {
+ renderMessage({ checks: checks as unknown as Message['checks'] });
+
+ expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ['en', 'Some numbers are not confirmed by the sources.'],
+ ['cs', 'Některá čísla zdroje nepotvrzují.'],
+ ])('shows a line without a list when numbers are not grounded but none are named (%s)', async (lang, text) => {
+ await i18n.changeLanguage(lang);
+ renderMessage({ checks: { citations_valid: true, numbers_grounded: false, ungrounded_numbers: [] } });
+
+ expect(screen.getByText(text)).toBeInTheDocument();
+ });
+ });
+
+ describe('tools line', () => {
+ it('names the known tools with short labels', () => {
+ renderMessage({
+ toolsUsed: ['search_docs', 'list_my_projects', 'get_project', 'get_project_result', 'get_project_opinions'],
+ });
+
+ expect(screen.getByText('Read: documentation, projects, project, result, opinions')).toBeInTheDocument();
+ });
+
+ it('shows an unknown tool by its raw name', () => {
+ renderMessage({ toolsUsed: ['made_up_tool', 'search_docs'] });
+
+ expect(screen.getByText('Read: made_up_tool, documentation')).toBeInTheDocument();
+ });
+
+ it('keeps string tool names only', () => {
+ renderMessage({ toolsUsed: ['search_docs', 7, null] as unknown as string[] });
+
+ expect(screen.getByText('Read: documentation')).toBeInTheDocument();
+ });
+
+ it('shows no line for a non-array value or an empty list', () => {
+ const { unmount } = renderMessage({ toolsUsed: 5 as unknown as string[] });
+ expect(screen.queryByText(/^Read:/)).not.toBeInTheDocument();
+ unmount();
+
+ renderMessage({ toolsUsed: [] });
+ expect(screen.queryByText(/^Read:/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('failed turns', () => {
+ const DETAIL = 'Traceback: db password hunter2 at /srv/app.py';
+
+ it.each([
+ [429, 120, 'Message limit reached. Try again in 2 min.'],
+ [429, 61, 'Message limit reached. Try again in 2 min.'],
+ [429, 30, 'Message limit reached. Try again in 1 min.'],
+ [429, undefined, 'Message limit reached. Try again later.'],
+ [429, 0, 'Message limit reached. Try again later.'],
+ [429, -5, 'Message limit reached. Try again later.'],
+ [429, Number.NaN, 'Message limit reached. Try again later.'],
+ [429, Number.POSITIVE_INFINITY, 'Message limit reached. Try again later.'],
+ [503, undefined, 'The assistant is temporarily unavailable.'],
+ [404, undefined, 'This project is not available.'],
+ [0, undefined, 'Something went wrong. Please try again.'],
+ [500, undefined, 'Something went wrong. Please try again.'],
+ ])('code %s with retryAfter %s shows a fixed message and never the detail', (code, retryAfter, text) => {
+ renderMessage({ status: 'error', content: '', error: { code, detail: DETAIL, retryAfter } });
+
+ expect(screen.getByText(text)).toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent('hunter2');
+ expect(document.body).not.toHaveTextContent('Traceback');
+ });
+
+ it('adds a hint under the 503 and 404 messages', () => {
+ const { unmount } = renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } });
+ expect(screen.getByText('The model is not responding.')).toBeInTheDocument();
+ unmount();
+
+ renderMessage({ status: 'error', content: '', error: { code: 404, detail: 'x' } });
+ expect(screen.getByText('It may have been deleted or you are no longer a member.')).toBeInTheDocument();
+ });
+
+ it('falls back to the generic message when an error message has no error object', () => {
+ renderMessage({ status: 'error', content: '' });
+
+ expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument();
+ });
+
+ it('keeps the partial text of a cut answer, muted, with the interrupted block under it', () => {
+ const { container } = renderMessage({ status: 'cut', content: 'The best compromise is' });
+
+ expect(screen.getByText('The best compromise is')).toBeInTheDocument();
+ expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('The best compromise is'));
+ expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument();
+ expect(screen.getByText(/What arrived is shown above/)).toBeInTheDocument();
+ });
+
+ it.each(['', ' \n'])('renders a cut answer with content %j as the block alone: no wrapper, no hint', (content) => {
+ const { container } = renderMessage({ status: 'cut', content });
+
+ expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument();
+ expect(screen.queryByText(/What arrived is shown above/)).not.toBeInTheDocument();
+ expect(container.querySelector('.opacity-75')).toBeNull();
+ expect(container.querySelector('p')?.textContent).toBe('The connection dropped before the answer finished.');
+ });
+
+ it('keeps the partial text of an error turn, muted, above the block', () => {
+ const { container } = renderMessage({ status: 'error', content: 'half', error: { code: 503, detail: 'x' } });
+
+ expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('half'));
+ expect(screen.getByText('The assistant is temporarily unavailable.')).toBeInTheDocument();
+ });
+
+ it('offers a retry button on an error and on a cut answer when onRetry is passed', async () => {
+ const onRetry = vi.fn();
+ const { unmount } = renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } }, onRetry);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ unmount();
+
+ renderMessage({ status: 'cut', content: 'partial' }, onRetry);
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(onRetry).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows no retry button without onRetry', () => {
+ renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } });
+
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('cancelled', () => {
+ it('shows the partial text muted with a Stopped note and no red block', () => {
+ const { container } = renderMessage({ status: 'cancelled', content: 'half an answ' });
+
+ expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('half an answ'));
+ const note = screen.getByText('Stopped');
+ expect(note).toBeInTheDocument();
+ expect(within(note.parentElement as HTMLElement).getByText(/Not sent with your next question/)).toBeInTheDocument();
+ expect(screen.queryByText(/connection dropped/)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
+ });
+
+ it.each(['', ' \n'])('shows Stopped without the partial-answer hint or a wrapper when the content is %j', (content) => {
+ const { container } = renderMessage({ status: 'cancelled', content });
+
+ expect(screen.getByText('Stopped')).toBeInTheDocument();
+ expect(screen.queryByText(/Partial answer/)).not.toBeInTheDocument();
+ expect(container.querySelector('.opacity-75')).toBeNull();
+ });
+ });
+
+ describe('Czech', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('cs');
+ });
+
+ it('speaks Czech in the sources, checks, tools and error copy', async () => {
+ renderMessage({
+ sources: [localSource],
+ checks: { citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17'] },
+ toolsUsed: ['get_project_result'],
+ });
+
+ expect(screen.getByText('Zdroje')).toBeInTheDocument();
+ expect(screen.getByText('Místní kopie')).toBeInTheDocument();
+ expect(screen.getByText('Čísla, která zdroje nepotvrzují: 17')).toBeInTheDocument();
+ expect(screen.getByText('Citace odkazuje na neexistující zdroj.')).toBeInTheDocument();
+ expect(screen.getByText('Načteno: výsledek')).toBeInTheDocument();
+ });
+
+ it('shows the Czech rate-limit message with the minutes', () => {
+ renderMessage({ status: 'error', content: '', error: { code: 429, detail: 'x', retryAfter: 600 } });
+
+ expect(screen.getByText('Limit zpráv vyčerpán. Zkuste to znovu za 10 min.')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/tests/components/assistant/message-shape.test.ts b/frontend/tests/components/assistant/message-shape.test.ts
new file mode 100644
index 00000000..e84b292f
--- /dev/null
+++ b/frontend/tests/components/assistant/message-shape.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect } from 'vitest';
+import {
+ cleanChecks,
+ cleanSources,
+ cleanToolsUsed,
+ sourceAnchorId,
+} from '@/components/assistant/message-shape';
+
+const good = {
+ n: 1,
+ title: 'Reading the result',
+ section: 'Confidence',
+ snippet: 'Quote',
+ url: 'https://docs.becomify.app/user/reading-the-result',
+ layer: 'public',
+};
+
+describe('sourceAnchorId', () => {
+ it('names the row after the message and the source number', () => {
+ expect(sourceAnchorId('m1', 3)).toBe('assistant-src-m1-3');
+ });
+});
+
+describe('cleanSources', () => {
+ it('keeps a well-formed source as it is', () => {
+ expect(cleanSources([good])).toEqual([good]);
+ });
+
+ it('keeps a local source with a null url', () => {
+ const local = { ...good, n: 2, url: null, layer: 'local' };
+
+ expect(cleanSources([local])).toEqual([local]);
+ });
+
+ it('treats a missing snippet as empty', () => {
+ const older: Record = { ...good };
+ delete older.snippet;
+
+ expect(cleanSources([older])).toEqual([{ ...good, snippet: '' }]);
+ });
+
+ it.each([
+ ['not an array', 'nope'],
+ ['null', null],
+ ['an object', { 0: good }],
+ ])('returns nothing for %s', (_label, value) => {
+ expect(cleanSources(value)).toEqual([]);
+ });
+
+ it.each([
+ ['null entry', null],
+ ['string entry', 'x'],
+ ['array entry', [good]],
+ ['n as a string', { ...good, n: '1' }],
+ ['n zero', { ...good, n: 0 }],
+ ['n negative', { ...good, n: -2 }],
+ ['n fractional', { ...good, n: 1.5 }],
+ ['n beyond the safe integers', { ...good, n: 1e21 }],
+ ['n NaN', { ...good, n: Number.NaN }],
+ ['missing title', { ...good, title: undefined }],
+ ['numeric section', { ...good, section: 4 }],
+ ['unknown layer', { ...good, layer: 'evil' }],
+ ['numeric url', { ...good, url: 5 }],
+ ['undefined url', { ...good, url: undefined }],
+ ])('drops an entry with %s', (_label, entry) => {
+ expect(cleanSources([entry, { ...good, n: 9 }])).toEqual([{ ...good, n: 9 }]);
+ });
+
+ it('drops a second source with a number already taken', () => {
+ const second = { ...good, title: 'Second' };
+
+ expect(cleanSources([good, second])).toEqual([good]);
+ });
+});
+
+describe('cleanToolsUsed', () => {
+ it('keeps string names only', () => {
+ expect(cleanToolsUsed(['search_docs', 3, null, 'get_project'])).toEqual(['search_docs', 'get_project']);
+ });
+
+ it.each([undefined, null, 'search_docs', 7, {}])('returns nothing for %s', (value) => {
+ expect(cleanToolsUsed(value)).toEqual([]);
+ });
+});
+
+describe('cleanChecks', () => {
+ it('keeps complete checks and the string numbers', () => {
+ expect(
+ cleanChecks({ citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17', 4, '9'] })
+ ).toEqual({ citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17', '9'] });
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['an array', []],
+ ['string flags', { citations_valid: 'true', numbers_grounded: 'false', ungrounded_numbers: [] }],
+ ['one flag missing', { numbers_grounded: true, ungrounded_numbers: [] }],
+ ['the other flag missing', { citations_valid: true, ungrounded_numbers: [] }],
+ ['numbers not an array', { citations_valid: true, numbers_grounded: true, ungrounded_numbers: '17' }],
+ ['numbers missing', { citations_valid: true, numbers_grounded: true }],
+ ])('ignores checks with %s', (_label, value) => {
+ expect(cleanChecks(value)).toBeNull();
+ });
+});