From 0968ea97d28f903a4564012d4bf9ffc790b9b8c5 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 3 Jul 2026 05:28:53 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(ai-chat):=20agent=20avatar=20stack=20?= =?UTF-8?q?=E2=80=94=20agent=20in=20front,=20launcher=20behind=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For AI-agent-authored content (comments + page history), replace the text AI-AGENT badge with an avatar stack: the agent in front, the human who launched it smaller and behind. This fixes the inverted hierarchy (the action was the agent's; the human just launched it). closes #300. Backend: a single server-authoritative resolver resolveAgentProvenance normalizes to { agent, launcher } from server columns only (createdSource/lastUpdatedSource, aiChatId, creator, chat role) — nothing from request input, so agent identity can't be spoofed. Internal chat -> agent = chat role (name/emoji), launcher = human; external MCP (aiChatId null) -> agent = the agent account, launcher = null; non-agent -> neither. The role join (aiChatId -> ai_chats.role_id -> ai_agent_roles) deliberately does NOT filter enabled/deleted_at, so a later-disabled role still labels historical content (mirrors findById, not findLiveEnabled). Enrichment is applied on BOTH findPageComments (list) AND findById (the create/resolve/update broadcast path), so the stack shows on live comment events and doesn't vanish on resolve/edit. Frontend: new AgentAvatarStack + AgentGlyph (avatarUrl -> role emoji on violet -> IconSparkles on violet), integrated into comment-list-item and history-item where the badge was; the deep-link-to-chat click moved onto the stack. ai-agent-badge removed. Tests: AgentAvatarStack (role/no-role/MCP/click/non-clickable), the provenance resolver + recorder tests proving the role join never filters enabled/deleted, and findById enrichment (guards the live-broadcast regression). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 4 +- .../public/locales/ru-RU/translation.json | 3 +- .../components/ui/agent-avatar-stack.test.tsx | 101 ++++++++++ .../src/components/ui/agent-avatar-stack.tsx | 183 ++++++++++++++++++ .../src/components/ui/ai-agent-badge.test.tsx | 96 --------- .../src/components/ui/ai-agent-badge.tsx | 99 ---------- .../components/comment-list-item.test.tsx | 36 ++-- .../comment/components/comment-list-item.tsx | 9 +- .../features/comment/types/comment.types.ts | 9 + .../page-history/components/history-item.tsx | 13 +- .../features/page-history/types/page.types.ts | 10 + .../database/repos/agent-provenance.spec.ts | 129 ++++++++++++ .../src/database/repos/agent-provenance.ts | 93 +++++++++ .../repos/comment/comment.repo.spec.ts | 124 ++++++++++++ .../database/repos/comment/comment.repo.ts | 75 ++++++- .../database/repos/page/page-history.repo.ts | 57 +++++- packages/mcp/node_modules/node_modules | 1 + 17 files changed, 818 insertions(+), 224 deletions(-) create mode 100644 apps/client/src/components/ui/agent-avatar-stack.test.tsx create mode 100644 apps/client/src/components/ui/agent-avatar-stack.tsx delete mode 100644 apps/client/src/components/ui/ai-agent-badge.test.tsx delete mode 100644 apps/client/src/components/ui/ai-agent-badge.tsx create mode 100644 apps/server/src/database/repos/agent-provenance.spec.ts create mode 100644 apps/server/src/database/repos/agent-provenance.ts create mode 100644 apps/server/src/database/repos/comment/comment.repo.spec.ts create mode 120000 packages/mcp/node_modules/node_modules 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..9a6460ab --- /dev/null +++ b/apps/client/src/components/ui/agent-avatar-stack.test.tsx @@ -0,0 +1,101 @@ +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("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..430c10c4 --- /dev/null +++ b/apps/client/src/components/ui/agent-avatar-stack.tsx @@ -0,0 +1,183 @@ +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; + +/** + * 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; +} + +/** + * 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, +}: 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 }); + + const stack = ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openChat(event); + } + }, + } + : {})} + > + {launcher && ( + + + + )} + + + + + ); + + return ( + + + {stack} + + + + {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