diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ad72c56e..5dec73ff 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1222,8 +1222,8 @@ "Commented": "Commented", "Resolved comment": "Resolved comment", "Ran tool {{name}}": "Ran tool {{name}}", - "AI-agent": "AI-agent", - "Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}", + "AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}", + "AI agent {{name}}": "AI agent {{name}}", "Endpoints": "Endpoints", "where we fetch models": "where we fetch models", "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index fa0b4084..be16c5c9 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -724,7 +724,8 @@ "Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.", "Delete this chat?": "Удалить этот чат?", "Deleted successfully": "Успешно удалено", - "Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}", + "AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}", + "AI agent {{name}}": "AI-агент {{name}}", "Failed to delete chat": "Не удалось удалить чат", "Failed to rename chat": "Не удалось переименовать чат", "Failed": "Ошибка", diff --git a/apps/client/src/components/ui/agent-avatar-stack.test.tsx b/apps/client/src/components/ui/agent-avatar-stack.test.tsx new file mode 100644 index 00000000..39d446b1 --- /dev/null +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { Provider, createStore } from "jotai"; +import { AgentAvatarStack } from "./agent-avatar-stack"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +type Props = React.ComponentProps; + +function renderStack(props: Props) { + const store = createStore(); + store.set(aiChatDraftAtom, "leftover draft from another chat"); + const utils = render( + + + + + , + ); + return { store, ...utils }; +} + +describe("AgentAvatarStack", () => { + it("internal chat WITH role: emoji glyph in front + human launcher behind", () => { + const { container } = renderStack({ + agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, + launcher: { name: "Alice", avatarUrl: null }, + aiChatId: "chat-1", + }); + + // Emoji is used as the glyph (priority 2), NOT the sparkles fallback. + expect(screen.getByText("🔬")).toBeDefined(); + expect(container.querySelector(".tabler-icon-sparkles")).toBeNull(); + // Label: bold role name + dimmed "· launcher". + expect(screen.getByText("Researcher")).toBeDefined(); + expect(screen.getByText(/·/)).toBeDefined(); + expect(screen.getByText("Alice")).toBeDefined(); + }); + + it("showName=false: renders only the avatars, no inline name label", () => { + renderStack({ + agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, + launcher: { name: "Alice", avatarUrl: null }, + aiChatId: "chat-1", + showName: false, + }); + + // The agent glyph is still rendered... + expect(screen.getByText("🔬")).toBeDefined(); + // ...but neither the agent NOR the launcher inline name label is rendered + // (they live only in the hover tooltip, which is not mounted in the initial + // DOM) — guards against suppressing only the agent name and leaking the + // launcher name. + expect(screen.queryByText("Researcher")).toBeNull(); + expect(screen.queryByText("Alice")).toBeNull(); + }); + + it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => { + const { container } = renderStack({ + agent: { name: "AI agent", avatarUrl: null }, + launcher: { name: "Bob", avatarUrl: null }, + aiChatId: "chat-2", + }); + + // No avatarUrl and no emoji => sparkles glyph (priority 3). + expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull(); + expect(screen.getByText("AI agent")).toBeDefined(); + expect(screen.getByText("Bob")).toBeDefined(); + }); + + it("external MCP: agent avatar in front, NO launcher behind", () => { + const { container } = renderStack({ + agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" }, + launcher: null, + aiChatId: null, + }); + + // avatarUrl provided (priority 1) => not the sparkles fallback. + expect(container.querySelector(".tabler-icon-sparkles")).toBeNull(); + expect(screen.getByText("MCP Bot")).toBeDefined(); + // No human behind => no "·" separator is rendered. + expect(screen.queryByText(/·/)).toBeNull(); + // No internal chat => the stack is not an interactive deep-link button. + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("click deep-links into the chat when aiChatId is present", () => { + const { store } = renderStack({ + agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, + launcher: { name: "Alice", avatarUrl: null }, + aiChatId: "chat-1", + }); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(store.get(activeAiChatIdAtom)).toBe("chat-1"); + expect(store.get(aiChatWindowOpenAtom)).toBe(true); + expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch + }); + + it("click is a no-op / not interactive without a chat target", () => { + const onActivate = vi.fn(); + renderStack({ + agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" }, + launcher: null, + aiChatId: null, + onActivate, + }); + expect(screen.queryByRole("button")).toBeNull(); + expect(onActivate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/components/ui/agent-avatar-stack.tsx b/apps/client/src/components/ui/agent-avatar-stack.tsx new file mode 100644 index 00000000..59ce252c --- /dev/null +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -0,0 +1,209 @@ +import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core"; +import { IconSparkles } from "@tabler/icons-react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useSetAtom } from "jotai"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; + +// The FRONT identity (the acting agent) and the BEHIND identity (the human who +// launched it). Both are computed server-side (#300) so the client never branches +// on the internal-vs-MCP provenance — it just renders whatever it is handed. +export interface AgentInfo { + name: string; + emoji?: string | null; + avatarUrl?: string | null; +} +export interface LauncherInfo { + name: string; + avatarUrl?: string | null; +} + +// Same violet token as the former AiAgentBadge (which used color="violet"). +const AGENT_COLOR = "violet"; +const GLYPH_SIZE = 38; +const LAUNCHER_SIZE = 22; +// How far the launcher avatar sticks out past the agent's bottom-right corner, so +// the "human behind" reads as behind (lower z-index) yet stays clearly visible. +const LAUNCHER_OVERHANG = 8; + +/** + * The front avatar. Image-source priority (#300): + * 1. agent.avatarUrl -> a real avatar image (external MCP agent account). + * 2. agent.emoji -> the role emoji on a violet circle. + * 3. otherwise -> the IconSparkles glyph on a violet circle (fallback). + */ +function AgentGlyph({ agent }: { agent: AgentInfo }) { + if (agent.avatarUrl) { + return ( + + ); + } + + if (agent.emoji) { + return ( + + + {agent.emoji} + + + ); + } + + return ( + + + + ); +} + +export interface AgentAvatarStackProps { + agent: AgentInfo; + // null/absent => external MCP (front agent avatar only, no human behind). + launcher?: LauncherInfo | null; + // Deep-links into the internal AI chat when present (null for external MCP). + aiChatId?: string | null; + // Fired after the stack deep-links into its chat, so the caller can react + // (e.g. the page-history row closes the history modal). Keeps this ui/ primitive + // free of cross-feature coupling (inherited from the old AiAgentBadge, #143). + onActivate?: () => void; + // Whether to render the inline name label next to the avatars (default true). + // Set false when the caller renders the name itself (e.g. the comment row). + showName?: boolean; +} + +/** + * The "agent avatar stack" (#300): the AGENT glyph in front, and — for an + * internal AI chat — the HUMAN who launched it as a smaller avatar offset behind. + * Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the + * whole stack is a deep-link into that chat (the click the old badge owned moved + * here); the click is contained (stopPropagation) so it does not also trigger an + * enclosing row handler. + */ +export function AgentAvatarStack({ + agent, + launcher, + aiChatId, + onActivate, + showName = true, +}: AgentAvatarStackProps) { + const { t } = useTranslation(); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); + const setActiveChatId = useSetAtom(activeAiChatIdAtom); + const setDraft = useSetAtom(aiChatDraftAtom); + + const clickable = !!aiChatId; + + const openChat = useCallback( + (event: React.SyntheticEvent) => { + event.stopPropagation(); + if (!aiChatId) return; + setActiveChatId(aiChatId); + // Switching chats must start with a clean composer — clear any unsent draft + // so it does not leak from the previously open chat. + setDraft(""); + setAiChatWindowOpen(true); + onActivate?.(); + }, + [aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate], + ); + + // Internal chat => "role on behalf of person"; external MCP => just the agent. + const tooltip = launcher + ? t("AI agent «{{role}}» on behalf of {{person}}", { + role: agent.name, + person: launcher.name, + }) + : t("AI agent {{name}}", { name: agent.name }); + + // The container is only enlarged when there is a launcher to overhang; with no + // human behind it stays tight at the agent glyph size. + const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE; + + const stack = ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openChat(event); + } + }, + } + : {})} + > + {launcher && ( + + + + )} + {/* Pin the agent glyph to the top-left at its own size; the launcher then + overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */} + + + + + ); + + return ( + + + {stack} + + {showName && ( + + + {agent.name} + + {launcher && ( + <> + + · + + + {launcher.name} + + + )} + + )} + + ); +} + +export default AgentAvatarStack; diff --git a/apps/client/src/components/ui/ai-agent-badge.test.tsx b/apps/client/src/components/ui/ai-agent-badge.test.tsx deleted file mode 100644 index 678013ed..00000000 --- a/apps/client/src/components/ui/ai-agent-badge.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; -import { Provider, createStore } from "jotai"; -import { AiAgentBadge } from "./ai-agent-badge"; -import { - activeAiChatIdAtom, - aiChatWindowOpenAtom, - aiChatDraftAtom, -} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; - -// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. - -function renderBadge(props: { authorName?: string; aiChatId?: string | null }) { - return render( - - - , - ); -} - -// Render a clickable badge inside an explicit jotai store, with a leftover draft -// and an onActivate + parent-click spy, so the deep-link side effects are -// assertable. Returns the store and spies. -function setupClickable() { - const store = createStore(); - store.set(aiChatDraftAtom, "leftover draft from another chat"); - const onActivate = vi.fn(); - const onParentClick = vi.fn(); - render( - - -
- -
-
-
, - ); - return { store, onActivate, onParentClick, badge: screen.getByRole("button") }; -} - -function expectDeepLinked(store: ReturnType, onActivate: ReturnType) { - expect(store.get(activeAiChatIdAtom)).toBe("chat-1"); - expect(store.get(aiChatWindowOpenAtom)).toBe(true); - expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared - expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc. -} - -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"); - }); - - it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => { - const { store, onActivate, onParentClick, badge } = setupClickable(); - fireEvent.click(badge); - expectDeepLinked(store, onActivate); - expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click - }); - - it.each(["Enter", " "])( - "keyboard %j activates the deep-link (same side effects as click)", - (key) => { - const { store, onActivate, badge } = setupClickable(); - fireEvent.keyDown(badge, { key }); - expectDeepLinked(store, onActivate); - }, - ); - - it("an unrelated key does NOT activate the badge", () => { - const { store, onActivate, badge } = setupClickable(); - fireEvent.keyDown(badge, { key: "Tab" }); - expect(store.get(activeAiChatIdAtom)).toBeNull(); - expect(store.get(aiChatWindowOpenAtom)).toBe(false); - expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat"); - expect(onActivate).not.toHaveBeenCalled(); - }); - - it.each([{ aiChatId: null }, {}])( - "is a plain non-clickable label without a chat target (%o)", - (props) => { - renderBadge({ authorName: "Bot", ...props }); - 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(); - }, - ); -}); diff --git a/apps/client/src/components/ui/ai-agent-badge.tsx b/apps/client/src/components/ui/ai-agent-badge.tsx deleted file mode 100644 index 39e29614..00000000 --- a/apps/client/src/components/ui/ai-agent-badge.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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"; - -interface AiAgentBadgeProps { - authorName?: string; - aiChatId?: string | null; - // Fired after the badge deep-links into its chat. The caller handles its own - // context (e.g. the page-history row closes the history modal) so this generic - // ui/ primitive stays free of cross-feature coupling (#143 review Arch B). - onActivate?: () => void; -} - -/** - * 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 and opens the - * floating AI-chat window, then invokes `onActivate` so the caller can react - * (e.g. the history modal closes itself). 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, - onActivate, -}: AiAgentBadgeProps) { - const { t } = useTranslation(); - const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); - const setActiveChatId = useSetAtom(activeAiChatIdAtom); - const setDraft = useSetAtom(aiChatDraftAtom); - - 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); - onActivate?.(); - }, - [aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate], - ); - - const badge = ( - } - style={aiChatId ? { cursor: "pointer" } : undefined} - {...(aiChatId - ? { - // Keep the default Badge root element (not a