diff --git a/src/features/chat/ui/AgentIdentityAvatar.tsx b/src/features/chat/ui/AgentIdentityAvatar.tsx new file mode 100644 index 000000000..8fd3b7114 --- /dev/null +++ b/src/features/chat/ui/AgentIdentityAvatar.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { cn } from "@/shared/lib/cn"; +import type { Persona } from "@/shared/types/agents"; +import { AvatarVisual } from "@/shared/ui/avatar-visual"; +import { useAvatarImage, useAvatarMedia } from "@/shared/hooks/useAvatarSrc"; +import { AvatarStaticMedia } from "@/shared/ui/avatar-static-media"; + +function normalizeAgentIdentity(value: string): string { + return value + .trim() + .replace(/\\/g, "/") + .replace(/\/+$/, "") + .toLocaleLowerCase(); +} + +function normalizedDisplayName(value: string): string { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase(); +} + +export function findPersonaForAgentName( + personas: readonly Persona[], + agentName: string, +): Persona | undefined { + const normalizedIdentity = normalizeAgentIdentity(agentName); + if (!normalizedIdentity) return undefined; + + const exactIdentityMatch = personas.find( + (persona) => normalizeAgentIdentity(persona.id) === normalizedIdentity, + ); + if (exactIdentityMatch) return exactIdentityMatch; + + const baseIdentityMatches = personas.filter((persona) => { + const normalizedId = normalizeAgentIdentity(persona.id); + const baseName = normalizedId.split("/").at(-1)?.replace(/\.md$/, ""); + return baseName === normalizedIdentity; + }); + if (baseIdentityMatches.length === 1) return baseIdentityMatches[0]; + + const normalizedName = normalizedDisplayName(agentName); + const displayMatches = personas.filter( + (persona) => normalizedDisplayName(persona.displayName) === normalizedName, + ); + return displayMatches.length === 1 ? displayMatches[0] : undefined; +} + +function agentInitial(agentName: string): string { + return agentName.match(/[\p{L}\p{N}]/u)?.[0]?.toLocaleUpperCase() ?? "?"; +} + +export function AgentIdentityAvatar({ + agentName, + className, +}: { + agentName: string; + className?: string; +}) { + const persona = useAgentStore((state) => + findPersonaForAgentName(state.personas, agentName), + ); + const avatar = persona?.avatar; + const staticImage = useAvatarImage(avatar); + const media = useAvatarMedia(avatar); + const resolvedSource = staticImage ?? media?.posterSrc ?? media?.src; + const [failedSource, setFailedSource] = useState(); + + const sourceFailed = failedSource === resolvedSource; + + const fallback = ( + + ); + + const avatarContent = (() => { + if (resolvedSource && sourceFailed) return fallback; + if (media?.mediaType === "video" && !staticImage && !media.posterSrc) { + return ( + + ); + } + if (staticImage) { + return ( + setFailedSource(staticImage)} + /> + ); + } + return ( + { + if (resolvedSource) setFailedSource(resolvedSource); + }} + /> + ); + })(); + + return ( + + ); +} + +const MAX_VISIBLE_AGENTS = 3; + +export function ActiveAgentFacepile({ + agentNames, + label, +}: { + agentNames: readonly string[]; + label: string; +}) { + if (agentNames.length === 0) return null; + + const visibleNames = agentNames.slice(0, MAX_VISIBLE_AGENTS); + const overflowCount = agentNames.length - visibleNames.length; + + return ( + + {visibleNames.map((agentName) => ( + + ))} + {overflowCount > 0 ? ( + + ) : null} + + ); +} diff --git a/src/features/chat/ui/AgentWorkPanel.tsx b/src/features/chat/ui/AgentWorkPanel.tsx index 7a237bd38..5213ea193 100644 --- a/src/features/chat/ui/AgentWorkPanel.tsx +++ b/src/features/chat/ui/AgentWorkPanel.tsx @@ -31,6 +31,8 @@ import type { TranscriptAgentWorkPayload } from "@/features/chat/transcript/proj import { useTranscriptRowStateAdapter } from "@/features/chat/transcript/row-state"; import { ToolCallAdapter } from "./ToolCallAdapter"; import { VoiceSpeechStatusIndicator } from "./VoiceSpeechStatusIndicator"; +import { getSubagentToolCallInfo } from "@/features/chat/lib/subagentToolCalls"; +import { ActiveAgentFacepile } from "./AgentIdentityAvatar"; interface ToolTimelineItem { kind: "tool"; @@ -199,6 +201,104 @@ function getActivePreviewState( }; } +const GOOSE_TASK_ID_PATTERN = /\b\d{8}_\w+\b/g; + +function taskIdsFromToolResult(item: ToolTimelineItem): string[] { + if (!item.response) return []; + let structuredText = ""; + if (item.response.structuredContent !== undefined) { + try { + structuredText = JSON.stringify(item.response.structuredContent); + } catch { + // The plain result can still carry the task id. + } + } + const text = `${item.response.result} ${structuredText}`; + return [...new Set(text.match(GOOSE_TASK_ID_PATTERN) ?? [])]; +} + +function addUniqueAgentName( + names: string[], + seen: Set, + value?: string, +) { + const name = value?.trim(); + if (!name) return; + const key = name.toLocaleLowerCase(); + if (seen.has(key)) return; + seen.add(key); + names.push(name); +} + +function getActiveDelegatedAgentNames( + items: readonly AgentWorkTimelineItem[], +): string[] { + const activeTaskAgents = new Map(); + const activeToolAgents: string[] = []; + const seenActiveTools = new Set(); + + for (const item of items) { + if (item.kind !== "tool" || !item.request) continue; + const status = getToolStatus(item); + const info = getSubagentToolCallInfo({ + toolName: item.request.toolName, + arguments: item.request.arguments ?? {}, + }); + if (!info) continue; + + const agentNames = info.agentNames ?? [ + info.agentName ?? item.request.subagentAgentName, + ]; + const isRunningTool = status === "pending" || status === "in_progress"; + + if (info.activity === "delegating" && isRunningTool) { + for (const name of agentNames) { + addUniqueAgentName(activeToolAgents, seenActiveTools, name); + } + } + + if ( + item.request.toolName === "delegate" && + item.request.arguments?.async === true && + item.response && + !item.response.isError + ) { + const agentName = agentNames[0]?.trim(); + if (agentName) { + for (const taskId of taskIdsFromToolResult(item)) { + activeTaskAgents.set(taskId, agentName); + } + } + } + + if (item.request.toolName === "load" && info.taskId) { + if ( + info.activity === "waiting" && + item.response && + !item.response.isError + ) { + activeTaskAgents.delete(info.taskId); + } else if ( + info.activity === "cancelling" && + item.response && + !item.response.isError + ) { + activeTaskAgents.delete(info.taskId); + } + } + } + + const names: string[] = []; + const seen = new Set(); + for (const name of activeTaskAgents.values()) { + addUniqueAgentName(names, seen, name); + } + for (const name of activeToolAgents) { + addUniqueAgentName(names, seen, name); + } + return names; +} + function getRailColor( status: "thought" | "progress" | ToolCallStatus, primary = false, @@ -390,7 +490,7 @@ export function AgentWorkPanel({ payload: TranscriptAgentWorkPayload; settleOnMount?: boolean; }) { - const { t } = useTranslation("chat"); + const { t, i18n } = useTranslation("chat"); const prefersReducedMotion = useReducedMotion(); const { markRowInteracted, pinScrollAnchor } = useTranscriptRowStateAdapter(); const items = useMemo( @@ -461,6 +561,10 @@ export function AgentWorkPanel({ const hiddenItems = activePreviewState?.hiddenItems ?? []; const hiddenStepCount = activePreviewState?.hiddenStepCount ?? 0; const shouldShowPreviousSteps = isActiveWorkPreview && hiddenStepCount > 0; + const activeDelegatedAgentNames = useMemo( + () => getActiveDelegatedAgentNames(items), + [items], + ); return ( + diff --git a/src/features/chat/ui/ToolCallAdapter.tsx b/src/features/chat/ui/ToolCallAdapter.tsx index fca416885..2f374ff39 100644 --- a/src/features/chat/ui/ToolCallAdapter.tsx +++ b/src/features/chat/ui/ToolCallAdapter.tsx @@ -24,6 +24,7 @@ import { import type { ToolCallLocation, ToolCallStatus } from "@/shared/types/messages"; import { useArtifactActionsContext } from "@/features/chat/hooks/ArtifactPolicyContext"; import { getSubagentToolCallInfo } from "@/features/chat/lib/subagentToolCalls"; +import { AgentIdentityAvatar } from "./AgentIdentityAvatar"; interface ToolCallAdapterProps { className?: string; @@ -313,13 +314,43 @@ function AgentWorkToolSection({ ); } +interface SubagentTitlePresentation { + text: string; + agentName?: string; + beforeAgent?: string; + afterAgent?: string; +} + +const AGENT_NAME_MARKER = "\u{e000}"; + +function translatedAgentTitle( + t: (key: string, options?: Record) => string, + key: string, + agentName: string, + options: Record = {}, +): SubagentTitlePresentation { + const markedTitle = t(key, { ...options, name: AGENT_NAME_MARKER }); + const markerIndex = markedTitle.indexOf(AGENT_NAME_MARKER); + if (markerIndex < 0) { + return { text: t(key, { ...options, name: agentName }) }; + } + const beforeAgent = markedTitle.slice(0, markerIndex); + const afterAgent = markedTitle.slice(markerIndex + AGENT_NAME_MARKER.length); + return { + text: `${beforeAgent}${agentName}${afterAgent}`, + agentName, + beforeAgent, + afterAgent, + }; +} + function subagentTitle( t: (key: string, options?: Record) => string, info: NonNullable>, resolvedAgentName?: string, resolvedTaskLabel?: string, resolvedTaskIsConfigured?: boolean, -): string { +): SubagentTitlePresentation { // Explicit key map keeps the i18n usage statically checkable. const keys = { delegating: [ @@ -384,23 +415,33 @@ function subagentTitle( const agentNames = info.agentNames; const taskLabel = info.label ?? resolvedTaskLabel; if (agentNames) { - return t("tools.subagent.waitingAgents", { names: agentNames.join(", ") }); + return { + text: t("tools.subagent.waitingAgents", { names: agentNames.join(", ") }), + }; } if (agentName && (info.sourceDefinesTask || resolvedTaskIsConfigured)) { - return t(configuredTaskKeys[info.activity], { name: agentName }); + return translatedAgentTitle( + t, + configuredTaskKeys[info.activity], + agentName, + ); } if (agentName && taskLabel) { - return t(agentLabeled, { name: agentName, label: taskLabel }); + return translatedAgentTitle(t, agentLabeled, agentName, { + label: taskLabel, + }); } - if (agentName) return t(agent, { name: agentName }); + if (agentName) return translatedAgentTitle(t, agent, agentName); if (taskLabel) { - return info.taskId - ? t(taskLabeledKeys[info.activity], { label: taskLabel }) - : t(labeled, { label: taskLabel }); + return { + text: info.taskId + ? t(taskLabeledKeys[info.activity], { label: taskLabel }) + : t(labeled, { label: taskLabel }), + }; } // A task id is correlation identity, not a task description. When no // delegate context can be recovered, show only the known activity fact. - return t(plain); + return { text: t(plain) }; } function sentenceCaseToolTitle(name: string): string { @@ -463,7 +504,7 @@ export function ToolCallAdapter({ () => getSubagentToolCallInfo({ toolName, arguments: args }), [toolName, args], ); - const displayName = subagentInfo + const subagentTitlePresentation = subagentInfo ? subagentTitle( t, subagentInfo, @@ -471,7 +512,9 @@ export function ToolCallAdapter({ subagentTaskLabel, subagentTaskIsConfigured, ) - : sentenceCaseToolTitle(name); + : undefined; + const displayName = + subagentTitlePresentation?.text ?? sentenceCaseToolTitle(name); const pathRow = summaryRows.find((row) => row.kind === "path"); const headerFileLabel = pathRow?.value; @@ -508,6 +551,22 @@ export function ToolCallAdapter({ const showResultBody = hasOutput && !textIsStringifiedCopy && !canHoistResultIntoHeader; + const subagentHeaderTitle: ReactNode = + subagentTitlePresentation?.agentName ? ( + + {subagentTitlePresentation.beforeAgent} + + + {subagentTitlePresentation.agentName} + {subagentTitlePresentation.afterAgent} + + + ) : ( + displayName + ); const headerTitle: ReactNode = headerTitleParts ? ( <> {headerTitleParts.prefix} @@ -538,7 +597,7 @@ export function ToolCallAdapter({ ) : canHoistResultIntoHeader ? ( <> - {displayName} + {subagentHeaderTitle} @@ -547,7 +606,7 @@ export function ToolCallAdapter({ ) : ( - displayName + subagentHeaderTitle ); const showCombinedSurface = summaryRows.length > 0 || hasStructuredArgs; diff --git a/src/features/chat/ui/__tests__/AgentWorkPanel.test.tsx b/src/features/chat/ui/__tests__/AgentWorkPanel.test.tsx index e90c1c690..f38687476 100644 --- a/src/features/chat/ui/__tests__/AgentWorkPanel.test.tsx +++ b/src/features/chat/ui/__tests__/AgentWorkPanel.test.tsx @@ -1,11 +1,265 @@ import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { TranscriptAgentWorkPayload } from "@/features/chat/transcript/projection/transcriptItemTypes"; import { renderWithProviders } from "@/test/render"; import { AgentWorkPanel } from "../AgentWorkPanel"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; + +beforeEach(() => { + useAgentStore.setState({ personas: [] }); +}); describe("AgentWorkPanel", () => { + it("shows a capped facepile for active named delegates in previous steps", () => { + const content = [ + { + type: "thinking" as const, + text: "Planning", + }, + { + type: "toolRequest" as const, + id: "delegate-rivet", + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet", instructions: "Review the code" }, + status: "in_progress" as const, + }, + { + type: "toolRequest" as const, + id: "delegate-trace", + name: "delegate", + toolName: "delegate", + arguments: { source: "Trace", instructions: "Run the tests" }, + status: "pending" as const, + }, + { + type: "toolRequest" as const, + id: "delegate-scout", + name: "delegate", + toolName: "delegate", + arguments: { source: "Scout", instructions: "Check the UX" }, + status: "in_progress" as const, + }, + { + type: "toolRequest" as const, + id: "delegate-lens", + name: "delegate", + toolName: "delegate", + arguments: { source: "Lens", instructions: "Check accessibility" }, + status: "in_progress" as const, + }, + ]; + const payload: TranscriptAgentWorkPayload = { + workId: "work-delegates", + message: { + id: "assistant-delegates", + role: "assistant", + created: Date.UTC(2026, 8, 2, 15, 0), + content, + }, + content, + isActiveWork: true, + hasFinalAnswer: false, + thoughtCount: 1, + toolCount: 4, + textCount: 0, + }; + + const { container } = renderWithProviders( + , + ); + + const facepile = container.querySelector('[data-active-agent-facepile=""]'); + expect(facepile).toHaveAccessibleName( + "Rivet, Trace, Scout, and Lens are working", + ); + expect( + facepile?.querySelectorAll("[data-agent-identity-avatar]"), + ).toHaveLength(3); + expect(facepile).toHaveTextContent("+1"); + }); + + it.each([ + "send_message", + "close_agent", + "interrupt_agent", + ])("does not describe pending %s activity as active work", (toolName) => { + const content = [ + { type: "thinking" as const, text: "Planning" }, + { + type: "toolRequest" as const, + id: `activity-${toolName}`, + name: toolName, + toolName, + arguments: { target: "Rivet", message: "Check this" }, + status: "in_progress" as const, + }, + { type: "text" as const, text: "Continuing" }, + { type: "text" as const, text: "Still working" }, + ]; + const payload: TranscriptAgentWorkPayload = { + workId: `work-${toolName}`, + message: { + id: `assistant-${toolName}`, + role: "assistant", + created: Date.UTC(2026, 8, 2, 15, 0), + content, + }, + content, + isActiveWork: true, + hasFinalAnswer: false, + thoughtCount: 1, + toolCount: 1, + textCount: 2, + }; + + const { container } = renderWithProviders( + , + ); + expect( + container.querySelector('[data-active-agent-facepile=""]'), + ).toBeNull(); + }); + + it("keeps an async delegate active until its task reaches a terminal response", () => { + const delegateRequest = { + type: "toolRequest" as const, + id: "delegate-rivet", + name: "delegate", + toolName: "delegate", + arguments: { + source: "Rivet", + instructions: "Review the code", + async: true, + }, + status: "completed" as const, + }; + const delegateResponse = { + type: "toolResponse" as const, + id: "delegate-rivet", + name: "delegate", + result: "Task 20260902_72 started in background", + isError: false, + }; + const baseContent = [ + { type: "thinking" as const, text: "Preparing research" }, + delegateRequest, + delegateResponse, + { type: "thinking" as const, text: "Waiting for research" }, + { type: "text" as const, text: "Continuing" }, + ]; + const makePayload = ( + content: TranscriptAgentWorkPayload["content"], + ): TranscriptAgentWorkPayload => ({ + workId: "work-async-delegate", + message: { + id: "assistant-async-delegate", + role: "assistant", + created: Date.UTC(2026, 8, 2, 15, 0), + content: [...content], + }, + content, + isActiveWork: true, + hasFinalAnswer: false, + thoughtCount: 1, + toolCount: 2, + textCount: 1, + }); + + const { container, rerender } = renderWithProviders( + , + ); + expect( + container.querySelector('[data-active-agent-facepile=""]'), + ).toHaveAccessibleName("Rivet is working"); + + const failedWaitContent = [ + ...baseContent, + { + type: "toolRequest" as const, + id: "load-rivet-failed", + name: "load", + toolName: "load", + arguments: { source: "20260902_72" }, + status: "completed" as const, + }, + { + type: "toolResponse" as const, + id: "load-rivet-failed", + name: "load", + result: "Unable to wait for task", + isError: true, + }, + ]; + rerender(); + expect( + container.querySelector('[data-active-agent-facepile=""]'), + ).toHaveAccessibleName("Rivet is working"); + + const terminalContent = [ + ...failedWaitContent, + { + type: "toolRequest" as const, + id: "load-rivet-complete", + name: "load", + toolName: "load", + arguments: { source: "20260902_72" }, + status: "completed" as const, + }, + { + type: "toolResponse" as const, + id: "load-rivet-complete", + name: "load", + result: "Review complete", + isError: false, + }, + ]; + rerender(); + expect( + container.querySelector('[data-active-agent-facepile=""]'), + ).toBeNull(); + }); + + it("does not show completed delegates in the active facepile", () => { + const content = [ + { + type: "toolRequest" as const, + id: "delegate-rivet", + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet" }, + status: "completed" as const, + }, + { type: "text" as const, text: "Continuing" }, + { type: "thinking" as const, text: "Checking results" }, + { type: "text" as const, text: "Still working" }, + ]; + const payload: TranscriptAgentWorkPayload = { + workId: "work-completed-delegate", + message: { + id: "assistant-completed-delegate", + role: "assistant", + created: Date.UTC(2026, 8, 2, 15, 0), + content, + }, + content, + isActiveWork: true, + hasFinalAnswer: false, + thoughtCount: 1, + toolCount: 1, + textCount: 2, + }; + + const { container } = renderWithProviders( + , + ); + + expect( + container.querySelector('[data-active-agent-facepile=""]'), + ).toBeNull(); + }); + it("renders independent speech states for progress text", () => { const content = [ { diff --git a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index fe63e4beb..5bffc0c99 100644 --- a/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -1,11 +1,13 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ArtifactLinkCandidate } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { ToolCallLocation } from "@/shared/types/messages"; import enChat from "@/shared/i18n/locales/en/chat.json"; import esChat from "@/shared/i18n/locales/es/chat.json"; import { ToolCallAdapter } from "../ToolCallAdapter"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; const mockResolveMarkdownHref = vi.fn<(href: string) => ArtifactLinkCandidate | null>(); @@ -40,6 +42,7 @@ vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({ beforeEach(() => { mockResolveMarkdownHref.mockReturnValue(null); + useAgentStore.setState({ personas: [] }); }); afterEach(() => { @@ -162,6 +165,235 @@ describe("ToolCallAdapter — subagent laws", () => { ).toBeInTheDocument(); }); + it("renders a matching agent avatar inline before the delegated name", () => { + useAgentStore.setState({ + personas: [ + { + id: "rivet", + displayName: "Rivet", + avatar: "https://example.test/rivet.png", + systemPrompt: "Review code", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { + source: "Rivet", + instructions: "Count markdown files", + }, + }); + + const avatar = container.querySelector( + '[data-agent-identity-avatar="Rivet"] img', + ); + expect(avatar).toHaveAttribute("src", "https://example.test/rivet.png"); + expect(avatar).toHaveAttribute("alt", ""); + expect(avatar?.parentElement?.previousElementSibling).toHaveTextContent( + "Delegating to", + ); + expect(avatar?.parentElement?.nextElementSibling).toHaveTextContent( + "Rivet · Count markdown files", + ); + }); + + it("places an overlapping agent name at the translated identity slot", () => { + useAgentStore.setState({ + personas: [ + { + id: "to", + displayName: "to", + avatar: "https://example.test/to.png", + systemPrompt: "Review code", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "to", instructions: "Review the code" }, + }); + + const avatar = container.querySelector('[data-agent-identity-avatar="to"]'); + expect(avatar?.previousElementSibling).toHaveTextContent("Delegating to"); + expect(avatar?.nextElementSibling).toHaveTextContent( + "to · Review the code", + ); + }); + + it("retains the delegated avatar when a result is hoisted", () => { + useAgentStore.setState({ + personas: [ + { + id: "rivet", + displayName: "Rivet", + avatar: "https://example.test/rivet.png", + systemPrompt: "Review code", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet", instructions: "Review the code" }, + result: "Done", + structuredContent: { outcome: "complete" }, + }); + + expect( + container.querySelector('[data-agent-identity-avatar="Rivet"] img'), + ).toHaveAttribute("src", "https://example.test/rivet.png"); + expect( + container.querySelector("[data-tool-title-hoisted]"), + ).toHaveTextContent("Done"); + }); + + it("does not choose an arbitrary avatar for ambiguous display names", () => { + useAgentStore.setState({ + personas: [ + { + id: "/agents/first.md", + displayName: "Rivet", + avatar: "https://example.test/first.png", + systemPrompt: "First", + isBuiltin: false, + writable: true, + }, + { + id: "/agents/second.md", + displayName: " rivet ", + avatar: "https://example.test/second.png", + systemPrompt: "Second", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet" }, + }); + + expect( + container.querySelector('[data-agent-avatar-fallback=""]'), + ).toHaveTextContent("R"); + expect( + container.querySelector("[data-agent-identity-avatar] img"), + ).toBeNull(); + }); + + it("prefers a canonical source id over ambiguous display names", () => { + useAgentStore.setState({ + personas: [ + { + id: "/agents/first.md", + displayName: "Rivet", + avatar: "https://example.test/first.png", + systemPrompt: "First", + isBuiltin: false, + writable: true, + }, + { + id: "/agents/second.md", + displayName: "Rivet", + avatar: "https://example.test/second.png", + systemPrompt: "Second", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "second" }, + }); + + expect( + container.querySelector('[data-agent-identity-avatar="second"] img'), + ).toHaveAttribute("src", "https://example.test/second.png"); + }); + + it("falls back after an avatar image fails and retries a changed source", () => { + useAgentStore.setState({ + personas: [ + { + id: "rivet", + displayName: "Rivet", + avatar: "https://example.test/broken.png", + systemPrompt: "Review code", + isBuiltin: false, + writable: true, + }, + ], + }); + + const { container, rerender } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet" }, + }); + const failedImage = container.querySelector( + "[data-agent-identity-avatar] img", + ); + expect(failedImage).not.toBeNull(); + act(() => fireEvent.error(failedImage as HTMLImageElement)); + expect( + container.querySelector('[data-agent-avatar-fallback=""]'), + ).toHaveTextContent("R"); + + act(() => + useAgentStore.setState({ + personas: [ + { + id: "rivet", + displayName: "Rivet", + avatar: "https://example.test/recovered.png", + systemPrompt: "Review code", + isBuiltin: false, + writable: true, + }, + ], + }), + ); + rerender( + , + ); + expect( + container.querySelector("[data-agent-identity-avatar] img"), + ).toHaveAttribute("src", "https://example.test/recovered.png"); + }); + + it("falls back to an initial for an unmatched delegated agent", () => { + const { container } = renderAdapter({ + name: "delegate", + toolName: "delegate", + arguments: { source: "Rivet" }, + }); + + expect( + container.querySelector('[data-agent-avatar-fallback=""]'), + ).toHaveTextContent("R"); + }); + it("describes a valid source-only delegation", () => { renderAdapter({ name: "delegate", diff --git a/src/features/design-system/generated/componentManifest.ts b/src/features/design-system/generated/componentManifest.ts index 9943ca385..0eb1e2ee4 100644 --- a/src/features/design-system/generated/componentManifest.ts +++ b/src/features/design-system/generated/componentManifest.ts @@ -293,6 +293,18 @@ export const designSystemComponentManifest = [ stateClasses: [], sourceTokenClasses: [], }, + { + name: "Avatar Static Media", + source: "src/shared/ui/avatar-static-media.tsx", + description: + "Renders video-only avatar media through one connected decoder, captures its\nalready-composited first visible frame, and reuses that image for every\ncompact occurrence. WebKit does not reliably decode Tauri asset URLs in\ndetached video elements, so one mounted occurrence owns decoding while all\nothers show their fixed-size fallback. Ownership transfers after remounts,\nfailures, and timeouts without creating concurrent decoders.", + exports: ["AvatarStaticMedia"], + slots: [], + cva: [], + tokenClasses: [], + stateClasses: [], + sourceTokenClasses: [], + }, { name: "Avatar Visual", source: "src/shared/ui/avatar-visual.tsx", diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 103dc0536..587bc518f 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -695,6 +695,8 @@ "summary": { "previousSteps_one": "{{count}} previous step", "previousSteps_other": "{{count}} previous steps", + "activeAgents_one": "{{names}} is working", + "activeAgents_other": "{{names}} are working", "steps_one": "{{count}} step", "steps_other": "{{count}} steps" } diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 720104f25..c1670c607 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -691,6 +691,8 @@ "summary": { "previousSteps_one": "{{count}} paso anterior", "previousSteps_other": "{{count}} pasos anteriores", + "activeAgents_one": "{{names}} está trabajando", + "activeAgents_other": "{{names}} están trabajando", "steps_one": "{{count}} paso", "steps_other": "{{count}} pasos" } diff --git a/src/shared/ui/avatar-static-media.test.tsx b/src/shared/ui/avatar-static-media.test.tsx new file mode 100644 index 000000000..a37c69e74 --- /dev/null +++ b/src/shared/ui/avatar-static-media.test.tsx @@ -0,0 +1,127 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AvatarStaticMedia } from "./avatar-static-media"; + +interface MockAvatarMediaProps { + onReady?: () => void; + onError?: () => void; +} + +const avatarMediaProps = vi.fn<(props: MockAvatarMediaProps) => void>(); + +vi.mock("@/shared/ui/avatar-media", () => ({ + AvatarMedia: (props: MockAvatarMediaProps) => { + avatarMediaProps(props); + return ; + }, +})); + +let mediaIndex = 0; +function media() { + mediaIndex += 1; + return { + src: `asset://localhost/avatar-${mediaIndex}.mp4`, + mediaType: "video" as const, + alphaMode: "stacked" as const, + }; +} + +function mockSuccessfulCanvasCapture() { + const pixels = new Uint8ClampedArray(4); + pixels[3] = 255; + const getImageData = vi.fn(() => ({ data: pixels })); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + getImageData, + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue( + "data:image/png;base64,visible", + ); + Object.defineProperties(HTMLCanvasElement.prototype, { + width: { configurable: true, get: () => 1 }, + height: { configurable: true, get: () => 1 }, + }); + return getImageData; +} + +beforeEach(() => { + avatarMediaProps.mockClear(); + vi.restoreAllMocks(); +}); + +describe("AvatarStaticMedia", () => { + it("transfers ownership to an already-mounted follower", async () => { + const avatarMedia = media(); + const { rerender } = render( + <> + owner} + /> + follower} + /> + , + ); + await screen.findByTestId("connected-avatar-decoder"); + expect(screen.getAllByTestId("connected-avatar-decoder")).toHaveLength(1); + + rerender( + follower} + />, + ); + + await waitFor(() => + expect(screen.getAllByTestId("connected-avatar-decoder")).toHaveLength(1), + ); + }); + + it("retries a thrown capture while the occurrence remains mounted", async () => { + const readPixels = mockSuccessfulCanvasCapture(); + readPixels.mockImplementationOnce(() => { + throw new DOMException("not readable"); + }); + + render(Q} />); + await screen.findByTestId("connected-avatar-decoder"); + + act(() => avatarMediaProps.mock.lastCall?.[0].onReady?.()); + + await waitFor(() => expect(avatarMediaProps).toHaveBeenCalledTimes(2)); + act(() => avatarMediaProps.mock.lastCall?.[0].onReady?.()); + + await waitFor(() => + expect(document.querySelector("img")).toHaveAttribute( + "src", + "data:image/png;base64,visible", + ), + ); + expect(readPixels).toHaveBeenCalledTimes(2); + expect( + screen.queryByTestId("connected-avatar-decoder"), + ).not.toBeInTheDocument(); + }); + + it("releases a decoder that never becomes ready", async () => { + vi.useFakeTimers(); + const avatarMedia = media(); + render(Q} />); + await act(async () => {}); + expect(screen.getByTestId("connected-avatar-decoder")).toBeInTheDocument(); + + await act(async () => vi.advanceTimersByTimeAsync(8_000)); + expect(avatarMediaProps).toHaveBeenCalledTimes(2); + + await act(async () => vi.advanceTimersByTimeAsync(8_000)); + expect( + screen.queryByTestId("connected-avatar-decoder"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Q")).toBeInTheDocument(); + vi.useRealTimers(); + }); +}); diff --git a/src/shared/ui/avatar-static-media.tsx b/src/shared/ui/avatar-static-media.tsx new file mode 100644 index 000000000..f3754bb0f --- /dev/null +++ b/src/shared/ui/avatar-static-media.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react"; +import type { ResolvedAvatarMedia } from "@/shared/avatars/catalog"; +import { AvatarMedia } from "@/shared/ui/avatar-media"; + +interface StaticFrameEntry { + status: "loading" | "ready" | "failed"; + failures: number; + owner?: symbol; + src?: string; +} + +const THUMBNAIL_SIZE = 128; +const MAX_CAPTURE_FAILURES = 2; +const DECODER_TIMEOUT_MS = 8_000; +const staticFrameEntries = new Map(); +const staticFrameListeners = new Map void>>(); + +function staticFrameKey(media: ResolvedAvatarMedia): string { + return `${media.src}:${media.alphaMode ?? "opaque"}`; +} + +function emitStaticFrameChange(key: string) { + for (const listener of staticFrameListeners.get(key) ?? []) listener(); +} + +function subscribeToStaticFrame(key: string, listener: () => void) { + const listeners = staticFrameListeners.get(key) ?? new Set<() => void>(); + listeners.add(listener); + staticFrameListeners.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) staticFrameListeners.delete(key); + }; +} + +function captureRenderedFrame(host: HTMLElement): string | null { + const source = host.querySelector("canvas, video"); + if ( + !(source instanceof HTMLCanvasElement) && + !(source instanceof HTMLVideoElement) + ) { + return null; + } + + const sourceWidth = + source instanceof HTMLCanvasElement ? source.width : source.videoWidth; + const sourceHeight = + source instanceof HTMLCanvasElement ? source.height : source.videoHeight; + if (sourceWidth === 0 || sourceHeight === 0) return null; + + if (source instanceof HTMLCanvasElement) { + const context = source.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + const pixels = context.getImageData(0, 0, sourceWidth, sourceHeight).data; + let visiblePixels = 0; + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] > 8) visiblePixels += 1; + } + if (visiblePixels < sourceWidth) return null; + + // Stacked-alpha media is already composited into this canvas by + // AvatarMedia. Copying it through a second canvas intermittently loses + // the painted frame in macOS WebKit, so serialize the proven source. + return source.toDataURL("image/png"); + } + + const canvas = document.createElement("canvas"); + canvas.width = THUMBNAIL_SIZE; + canvas.height = THUMBNAIL_SIZE; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.drawImage( + source, + 0, + 0, + sourceWidth, + sourceHeight, + 0, + 0, + THUMBNAIL_SIZE, + THUMBNAIL_SIZE, + ); + const pixels = context.getImageData( + 0, + 0, + THUMBNAIL_SIZE, + THUMBNAIL_SIZE, + ).data; + let visiblePixels = 0; + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] > 8) visiblePixels += 1; + } + if (visiblePixels < THUMBNAIL_SIZE) return null; + return canvas.toDataURL("image/png"); +} + +function afterAnimationFrame(): Promise { + return new Promise((resolve) => + window.requestAnimationFrame(() => resolve()), + ); +} + +async function captureAfterPaint(host: HTMLElement): Promise { + // WebKit can report decoded media before the canvas write is visible to a + // second canvas. Give the compositor several frames, rejecting transparent + // reads rather than permanently caching them as successful thumbnails. + for (let attempt = 0; attempt < 8; attempt += 1) { + await afterAnimationFrame(); + const src = captureRenderedFrame(host); + if (src) return src; + } + return null; +} + +/** + * Renders video-only avatar media through one connected decoder, captures its + * already-composited first visible frame, and reuses that image for every + * compact occurrence. WebKit does not reliably decode Tauri asset URLs in + * detached video elements, so one mounted occurrence owns decoding while all + * others show their fixed-size fallback. Ownership transfers after remounts, + * failures, and timeouts without creating concurrent decoders. + */ +export function AvatarStaticMedia({ + media, + alt = "", + className, + fallback = null, +}: { + media: ResolvedAvatarMedia; + alt?: string; + className?: string; + fallback?: ReactNode; +}) { + const key = staticFrameKey(media); + const ownerRef = useRef(Symbol(key)); + const hostRef = useRef(null); + const entry = useSyncExternalStore( + (listener) => subscribeToStaticFrame(key, listener), + () => staticFrameEntries.get(key), + () => undefined, + ); + + // Claim only after render. Every follower reruns when the shared entry + // becomes claimable; the current map value is checked again so the first + // effect to claim remains the sole decoder owner. + useEffect(() => { + const current = staticFrameEntries.get(key); + if ( + current?.status === "loading" || + current?.status === "ready" || + (current?.failures ?? 0) >= MAX_CAPTURE_FAILURES + ) { + return; + } + staticFrameEntries.set(key, { + status: "loading", + failures: current?.failures ?? 0, + owner: ownerRef.current, + }); + emitStaticFrameChange(key); + }, [entry?.failures, entry?.status, key]); + + useEffect( + () => () => { + const current = staticFrameEntries.get(key); + if (current?.status === "loading" && current.owner === ownerRef.current) { + staticFrameEntries.set(key, { + status: "failed", + failures: current.failures, + }); + emitStaticFrameChange(key); + } + }, + [key], + ); + + const ownsDecoder = + entry?.status === "loading" && entry.owner === ownerRef.current; + + useEffect(() => { + if (!ownsDecoder) return; + const timeout = window.setTimeout(() => { + const current = staticFrameEntries.get(key); + if (current?.status === "loading" && current.owner === ownerRef.current) { + staticFrameEntries.set(key, { + status: "failed", + failures: current.failures + 1, + }); + emitStaticFrameChange(key); + } + }, DECODER_TIMEOUT_MS); + return () => window.clearTimeout(timeout); + }, [key, ownsDecoder]); + + if (entry?.status === "ready" && entry.src) { + return {alt}; + } + + if (!ownsDecoder) return <>{fallback}; + + const failOwnedCapture = () => { + const current = staticFrameEntries.get(key); + if (current?.status !== "loading" || current.owner !== ownerRef.current) { + return; + } + staticFrameEntries.set(key, { + status: "failed", + failures: current.failures + 1, + }); + emitStaticFrameChange(key); + }; + + return ( + + {fallback} + + { + const owner = ownerRef.current; + const host = hostRef.current; + if (!host) return; + void captureAfterPaint(host) + .then((src) => { + const current = staticFrameEntries.get(key); + if (current?.status !== "loading" || current.owner !== owner) { + return; + } + if (!src) { + failOwnedCapture(); + return; + } + staticFrameEntries.set(key, { + status: "ready", + failures: current.failures, + src, + }); + emitStaticFrameChange(key); + }) + .catch(failOwnedCapture); + }} + onError={failOwnedCapture} + /> + + + ); +} diff --git a/src/shared/ui/avatar-visual.tsx b/src/shared/ui/avatar-visual.tsx index 6080bff76..71ce24ffb 100644 --- a/src/shared/ui/avatar-visual.tsx +++ b/src/shared/ui/avatar-visual.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactEventHandler, ReactNode } from "react"; import { useAvatarImage, useAvatarMedia } from "@/shared/hooks/useAvatarSrc"; import type { Avatar } from "@/shared/types/agents"; import { AvatarMedia } from "@/shared/ui/avatar-media"; @@ -9,6 +9,7 @@ interface AvatarVisualProps { className?: string; fallback?: ReactNode; loadingStrategy?: "eager" | "lazy-once" | "visible-video"; + onError?: ReactEventHandler; } /** @@ -25,6 +26,7 @@ export function AvatarVisual({ className, fallback = null, loadingStrategy = "visible-video", + onError, }: AvatarVisualProps) { const image = useAvatarImage(avatar); const media = useAvatarMedia(avatar); @@ -37,6 +39,7 @@ export function AvatarVisual({ alt={alt} className={className} data-avatar-visual="image" + onError={onError} /> ); } @@ -49,6 +52,7 @@ export function AvatarVisual({ poster={media.posterSrc} loadingStrategy={loadingStrategy} className={className} + onError={onError} /> ); }