diff --git a/apps/client/src/components/ui/ai-agent-badge.test.tsx b/apps/client/src/components/ui/ai-agent-badge.test.tsx new file mode 100644 index 00000000..108883e6 --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { AiAgentBadge } from "./ai-agent-badge"; + +// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom +// does not implement. Provide a minimal stub so the provider can render. +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); +}); + +function renderBadge(props: { authorName?: string; aiChatId?: string | null }) { + return render( + + + , + ); +} + +describe("AiAgentBadge", () => { + it("renders the AI-agent label", () => { + renderBadge({ authorName: "Bot" }); + expect(screen.getByText("AI-agent")).toBeDefined(); + }); + + it("is clickable (accessible button) when aiChatId is present", () => { + renderBadge({ authorName: "Bot", aiChatId: "chat-1" }); + const badge = screen.getByRole("button"); + expect(badge).toBeDefined(); + expect(badge.textContent).toContain("AI-agent"); + // Clicking does not throw — the deep-link handler runs against the default + // jotai store. (Asserting the badge exposes an interactive role is the + // observable contract; the atom side-effects are covered by the history UI.) + fireEvent.click(badge); + }); + + it("is a plain non-clickable label when aiChatId is null (external MCP agent)", () => { + renderBadge({ authorName: "Bot", aiChatId: null }); + expect(screen.getByText("AI-agent")).toBeDefined(); + // No interactive role is exposed when there is no chat to deep-link into. + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("is non-clickable when aiChatId is absent", () => { + renderBadge({ authorName: "Bot" }); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/apps/client/src/components/ui/ai-agent-badge.tsx b/apps/client/src/components/ui/ai-agent-badge.tsx new file mode 100644 index 00000000..e7879177 --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.tsx @@ -0,0 +1,92 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { IconSparkles } from "@tabler/icons-react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useSetAtom } from "jotai"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; + +interface AiAgentBadgeProps { + authorName?: string; + aiChatId?: string | null; +} + +/** + * Badge marking content written by the AI agent (provenance C3 / §7.4). It is + * ADDITIVE — shown next to the human author, never replacing them. Reused by the + * page-history list and the comments sidebar. + * + * When the item carries an `aiChatId` (an internal AI-chat edit), clicking the + * badge deep-links into that chat: it sets the active-chat atom, opens the + * floating AI-chat window, and closes the history modal. When `aiChatId` is + * null/absent (an external MCP write with no internal ai_chats row), the badge + * is a plain non-clickable label. The click is contained (stopPropagation) so it + * does not also trigger an enclosing row's click handler. + */ +export function AiAgentBadge({ authorName, aiChatId }: AiAgentBadgeProps) { + const { t } = useTranslation(); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); + const setActiveChatId = useSetAtom(activeAiChatIdAtom); + const setDraft = useSetAtom(aiChatDraftAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const tooltip = t("Edited by AI agent on behalf of {{name}}", { + name: authorName ?? "", + }); + + const openChat = useCallback( + (event: React.SyntheticEvent) => { + event.stopPropagation(); + if (!aiChatId) return; + setActiveChatId(aiChatId); + // Switching to another chat must start with a clean composer — clear any + // unsent draft so it does not leak from the previously open chat. + setDraft(""); + setAiChatWindowOpen(true); + setHistoryModalOpen(false); + }, + [aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, setHistoryModalOpen], + ); + + const badge = ( + } + style={aiChatId ? { cursor: "pointer" } : undefined} + {...(aiChatId + ? { + // Keep the default Badge root element (not a