From c138d01938dd930f1befb78e2334380a3f1dbcc9 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:46:03 -0700 Subject: [PATCH 1/6] graduate avatar collection canvas --- scripts/design-system-audit.mjs | 1 - .../__tests__/AgentBuilderCapability.test.tsx | 3 - src/features/agents/ui/AgentBuilderRail.tsx | 119 +----------- src/features/agents/ui/AgentDetailPage.tsx | 171 ++++-------------- .../ui/__tests__/AgentBuilderRail.test.tsx | 59 +----- .../ui/__tests__/AgentsView.entry.test.tsx | 101 +---------- .../__tests__/ExperimentsSettings.test.tsx | 2 - .../experiments/experimentDefinitions.ts | 12 -- src/shared/i18n/locales/en/settings.json | 4 - src/shared/i18n/locales/es/settings.json | 4 - 10 files changed, 42 insertions(+), 434 deletions(-) diff --git a/scripts/design-system-audit.mjs b/scripts/design-system-audit.mjs index 9b712d4fa..903df9489 100644 --- a/scripts/design-system-audit.mjs +++ b/scripts/design-system-audit.mjs @@ -113,7 +113,6 @@ const buttonStylingPattern = /(?:^|[\s"'`(])(?:bg-(?!transparent\b)[a-z[]|text-(?:foreground|muted|primary|secondary|destructive|accent|current|white|black|surface|app|sidebar)|border-(?:input|border|destructive|primary|accent|current|surface)|hover:(?!opacity-100\b)|active:(?:bg|text|border|opacity)|focus-visible:(?:bg|text|border)|data-\[state=open\]:(?:bg|text)|aria-expanded:(?:bg|text)|shadow-(?!none)|opacity-(?!0\b|100\b)\d|backdrop-|ring-(?!offset|ring\b|\d))/; const buttonStylingBaseline = new Set([ - "src/features/agents/ui/AgentDetailPage.tsx", "src/features/chat/ui/ChatInputToolbar.tsx", "src/features/chat/ui/MessageBubbleActions.tsx", "src/features/chat/ui/PersonaPicker.tsx", diff --git a/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx b/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx index dd0085944..6c15cd39a 100644 --- a/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx +++ b/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx @@ -55,8 +55,6 @@ import { useChatSessionStore, type ChatSession, } from "@/features/chat/stores/chatSessionStore"; -import { setExperimentEnabled } from "@/features/experiments/experimentPreferences"; -import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import type { AgentSourceEntry } from "@/shared/api/agents"; const existingAgentSource: AgentSourceEntry = { @@ -119,7 +117,6 @@ describe("AgentBuilderCapability keep-save telemetry", () => { ); apiMocks.listPersonas.mockResolvedValue([]); resetAgentBuilderSourceLifecycleForTests(); - setExperimentEnabled(AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, false); useAgentStore.setState({ personas: [], personasLoading: false, diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index 18b098366..e23b12aed 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -8,13 +8,12 @@ import { import { useTranslation } from "react-i18next"; import { IconAlertTriangle, - IconArrowLeft, IconLayoutSidebarLeftExpand, IconPhoto, IconSparkles, IconX, } from "@tabler/icons-react"; -import { avatarRef, parseAvatarRef } from "@/shared/avatars/catalog"; +import { avatarRef } from "@/shared/avatars/catalog"; import { normalizeAvatarUrl } from "@/shared/lib/avatarUrl"; import { cn } from "@/shared/lib/cn"; import type { AgentSourceEntry } from "@/shared/api/agents"; @@ -45,10 +44,7 @@ import { PLACEHOLDER_AGENT_DESCRIPTION, promoteDraft, } from "@/features/agents/lib/agentBuilderSession"; -import { useExperiment } from "@/features/experiments/experimentPreferences"; -import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { AvatarCollectionOverlay } from "@/features/agents/ui/AvatarCollectionOverlay"; -import { AvatarLibraryPicker } from "@/features/agents/ui/AvatarLibraryPicker"; import { ProviderModelFields } from "@/features/agents/ui/PersonaFields/ProviderModelFields"; import { FORM_FIELD_CLASS } from "@/shared/ui/form-field-tokens"; @@ -139,15 +135,6 @@ export function AgentBuilderRail({ const [failedMissingDraftRecoveryKey, setFailedMissingDraftRecoveryKey] = useState(null); const avatarLibrary = useAvatarLibrary(true); - const avatarCollectionExperiment = useExperiment( - AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, - ); - // When on, "library" renders as the full-surface collection canvas overlay - // (portal over the whole app) instead of the inline picker. The chat + - // builder stay mounted underneath, so composer drafts, resize state, all survive the takeover. - const avatarCollectionOverlayEnabled = Boolean( - avatarCollectionExperiment?.enabled, - ); const isWaitingForDraftTarget = !targetAgentPath; const missingDraftRecoveryKey = `${sessionId}:${targetAgentPath ?? "pending"}`; const [previousMissingDraftRecoveryKey, setPreviousMissingDraftRecoveryKey] = @@ -197,17 +184,6 @@ export function AgentBuilderRail({ typeof data?.properties?.avatar === "string" ? data.properties.avatar : ""; const trimmedAvatar = avatarRaw.trim(); const normalizedAvatar = normalizeAvatarUrl(trimmedAvatar); - const [selectedCollectionId, setSelectedCollectionId] = useState< - string | null - >(null); - const selectedCollection = useMemo( - () => - avatarLibrary.catalog?.collections.find( - (collection) => collection.id === selectedCollectionId, - ) ?? null, - [avatarLibrary.catalog, selectedCollectionId], - ); - const provider = (data?.properties?.provider as string | undefined) ?? ""; const modelProviderId = (data?.properties?.modelProviderId as string | undefined) ?? ""; @@ -230,7 +206,6 @@ export function AgentBuilderRail({ const onSelectAvatar = useCallback( (avatarId: string) => { writeProperty("avatar", avatarRef(avatarId)); - setSelectedCollectionId(null); setAvatarPanel("closed"); }, [writeProperty], @@ -258,11 +233,6 @@ export function AgentBuilderRail({ : null; const effectiveAvatar = normalizedAvatar ?? (defaultAvatarId ? avatarRef(defaultAvatarId) : null); - const selectedAvatarRefValue = effectiveAvatar - ? parseAvatarRef(effectiveAvatar) - ? effectiveAvatar - : null - : null; const selectedAvatarMediaState = useAvatarMediaState(effectiveAvatar); const onChangeProvider = useCallback( @@ -522,55 +492,15 @@ export function AgentBuilderRail({ ); - // Rendered by both the compact rail and the full-page builder. Declared once - // so picker changes cannot be applied to one layout and silently missed in - // the other. - const avatarLibraryPickerNode = ( - {}} - selectedCollectionId={selectedCollectionId} - onSelectCollection={setSelectedCollectionId} - /> - ); - const avatarCollectionOverlayNode = - avatarCollectionOverlayEnabled && avatarPanel === "library" ? ( + avatarPanel === "library" ? ( { - setSelectedCollectionId(null); - setAvatarPanel("closed"); - }} + onClose={() => setAvatarPanel("closed")} /> ) : null; - const pickerHeaderNode = ( -
- -

- {selectedCollection - ? selectedCollection.label - : t("builderRail.chooseAvatarTitle")} -

-
- ); - if (error === "parse") { return shell( headerNode, @@ -666,17 +596,6 @@ export function AgentBuilderRail({ ); } - // With the collection canvas experiment on, the "library" panel renders as - // the full-surface overlay (mounted below) instead of swapping the rail - // body, so the form stays visible underneath the frosted glass. - if ( - avatarPanel === "library" && - !fullPage && - !avatarCollectionOverlayEnabled - ) { - return shell(pickerHeaderNode, avatarLibraryPickerNode); - } - const avatarNode = (
-

- {selectedCollection - ? selectedCollection.label - : t("builderRail.chooseAvatarTitle")} -

- - {avatarLibraryPickerNode} - - ) : ( -
{avatarNode}
- ); + const fullPageLeftColumn =
{avatarNode}
; if (fullPage) { return ( diff --git a/src/features/agents/ui/AgentDetailPage.tsx b/src/features/agents/ui/AgentDetailPage.tsx index 10d614148..383d67b6f 100644 --- a/src/features/agents/ui/AgentDetailPage.tsx +++ b/src/features/agents/ui/AgentDetailPage.tsx @@ -1,8 +1,6 @@ import { useCallback, useState } from "react"; -import type { ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; import { - ArrowLeft, ChevronLeft, Copy, Download, @@ -14,7 +12,7 @@ import { Trash2, } from "lucide-react"; import { usePinToHomeWidget } from "@/features/home/hooks/usePinToHomeWidget"; -import { avatarRef, isBundledAvatarRef } from "@/shared/avatars/catalog"; +import { avatarRef } from "@/shared/avatars/catalog"; import { MessageResponse } from "@/shared/ui/ai-elements/message"; import { AvatarMedia } from "@/shared/ui/avatar-media"; import { Badge } from "@/shared/ui/badge"; @@ -41,16 +39,12 @@ import { import { AGENT_PROFILE_FIELDS_TRANSITION_NAME, getAgentAvatarTransitionName, - runAgentViewTransition, } from "@/features/agents/lib/agentViewTransitions"; import { resolveAgentIcon } from "@/features/agents/lib/resolveAgentIcon"; import { AgentProfileLayout } from "@/features/agents/ui/AgentProfileLayout"; import { AgentIdentityRail } from "@/features/agents/ui/AgentIdentityRail"; import { useAvatarLibrary } from "@/features/agents/hooks/useAvatarLibrary"; -import { AgentAvatarSection } from "@/features/agents/ui/AgentAvatarSection"; import { AvatarCollectionOverlay } from "@/features/agents/ui/AvatarCollectionOverlay"; -import { useExperiment } from "@/features/experiments/experimentPreferences"; -import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { AVATAR_CUSTOMIZE_LABEL_CLASS, AVATAR_CUSTOMIZE_SURFACE_CLASS, @@ -76,10 +70,6 @@ const SECONDARY_ACTION_CLASS = const OVERFLOW_TRIGGER_CLASS = "bg-surface-agent-profile-control-bg text-surface-agent-profile-fg shadow-none hover:bg-surface-agent-profile-control-bg-hover"; const ACTION_ICON_CLASS = "size-3"; -const AVATAR_FIELD_INPUT_CLASS = - "h-[42px] rounded-sm border-0 bg-surface-agent-profile-control-bg px-4 text-[14px] leading-[15px] text-surface-agent-profile-fg shadow-none outline-none transition-[box-shadow,background-color] duration-200 placeholder:text-surface-agent-profile-fg-placeholder hover:shadow-agent-profile-input-hover focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-agent-profile-input-focus"; -const AVATAR_FIELD_LABEL_CLASS = - "text-xs leading-4 font-medium text-surface-agent-profile-fg-muted"; const INSTRUCTIONS_PANEL_CLASS = "relative h-[min(32rem,calc(100vh-var(--spacing-app-top-bar)-7rem))] min-h-0 w-full overflow-hidden rounded-md bg-surface-agent-profile-control-bg text-sm leading-relaxed text-surface-agent-profile-fg shadow-none"; const INSTRUCTIONS_SCROLL_CLASS = @@ -121,38 +111,15 @@ export function AgentDetailPage({ } = usePinToHomeWidget({ kind: "agent", id: persona.id }); const personaAvatarValue = normalizeAvatarUrl(persona.avatar) ?? ""; const [avatarValue, setAvatarValue] = useState(personaAvatarValue); - const [avatarPreviewFailed, setAvatarPreviewFailed] = useState(false); + const [, setAvatarPreviewFailed] = useState(false); const [avatarSavePending, setAvatarSavePending] = useState(false); - const [showAvatarSection, setShowAvatarSection] = useState(false); const [showAvatarOverlay, setShowAvatarOverlay] = useState(false); - // Same gate as the agent builder: when on, avatar picking happens in the - // full-surface collection gallery instead of the inline customize section. - const avatarCollectionOverlayEnabled = Boolean( - useExperiment(AVATAR_COLLECTION_PAGE_EXPERIMENT_ID)?.enabled, - ); const [previousPersonaAvatarValue, setPreviousPersonaAvatarValue] = useState(personaAvatarValue); const [previousPersonaId, setPreviousPersonaId] = useState(persona.id); const avatarLibrary = useAvatarLibrary(isEditable); - const trimmedAvatarValue = avatarValue.trim(); - const normalizedAvatarValue = normalizeAvatarUrl(trimmedAvatarValue); - const customAvatarUrlValue = isBundledAvatarRef(trimmedAvatarValue) - ? "" - : avatarValue; - const avatarUrlError = - trimmedAvatarValue.length > 0 && !normalizedAvatarValue - ? t("editor.avatarUrlInvalid") - : null; + const normalizedAvatarValue = normalizeAvatarUrl(avatarValue.trim()); const avatarMedia = useAvatarMedia(normalizedAvatarValue ?? null); - const selectedBundledAvatarRef = - normalizedAvatarValue && isBundledAvatarRef(normalizedAvatarValue) - ? normalizedAvatarValue - : null; - const canSaveCustomAvatar = - Boolean(normalizedAvatarValue) && - !isBundledAvatarRef(normalizedAvatarValue ?? "") && - normalizedAvatarValue !== personaAvatarValue && - !avatarSavePending; const descriptionValue = getRealPersonaDescription(persona); const providerLabel = getPersonaProviderLabel( persona.provider, @@ -190,23 +157,11 @@ export function AgentDetailPage({ if (previousPersonaId !== persona.id) { setPreviousPersonaId(persona.id); - setShowAvatarSection(false); setShowAvatarOverlay(false); } const handleOpenAvatarSection = useCallback(() => { - // With the collection canvas experiment on, avatar picking opens the - // full-surface gallery takeover (same surface as the agent builder) - // instead of swapping the profile body for the inline customize section. - if (avatarCollectionOverlayEnabled) { - setShowAvatarOverlay(true); - return; - } - runAgentViewTransition(() => setShowAvatarSection(true)); - }, [avatarCollectionOverlayEnabled]); - - const handleCloseAvatarSection = useCallback(() => { - runAgentViewTransition(() => setShowAvatarSection(false)); + setShowAvatarOverlay(true); }, []); const commitAvatar = useCallback( @@ -235,27 +190,6 @@ export function AgentDetailPage({ ], ); - const handleClearAvatar = useCallback(() => { - setAvatarValue(""); - void commitAvatar(null); - }, [commitAvatar]); - - const handleAvatarUrlChange = useCallback( - (event: ChangeEvent) => { - setAvatarValue(event.target.value); - setAvatarPreviewFailed(false); - }, - [], - ); - - const handleSaveCustomAvatar = useCallback(() => { - if (!normalizedAvatarValue || avatarUrlError) { - return; - } - - void commitAvatar(normalizedAvatarValue); - }, [avatarUrlError, commitAvatar, normalizedAvatarValue]); - const handleSelectAvatar = useCallback( (avatarId: string) => { const nextAvatar = avatarRef(avatarId); @@ -304,7 +238,7 @@ export function AgentDetailPage({ )} - {isEditable && !showAvatarSection ? ( + {isEditable ? ( <> - ); - const avatarCollectionOverlayNode = showAvatarOverlay ? ( } > - {showAvatarSection ? ( - setAvatarPreviewFailed(true)} - onSaveCustomAvatar={handleSaveCustomAvatar} - onSelectAvatar={handleSelectAvatar} - selectedAvatarRef={selectedBundledAvatarRef} - showClearAvatar={trimmedAvatarValue.length > 0} - title={t("editor.customizeAvatar")} - /> - ) : ( -
-
-

- {t("view.instructions")} -

-
-
- - {persona.systemPrompt || " "} - -
-
-
-
- )} +
+
+

+ {t("view.instructions")} +

+
+
+ + {persona.systemPrompt || " "} + +
+
+
+
{avatarCollectionOverlayNode} diff --git a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx index 4167a83ed..0fcb43941 100644 --- a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx +++ b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx @@ -69,11 +69,6 @@ import { AgentBuilderRail } from "../AgentBuilderRail"; import { usePersonaSource } from "@/features/agents/hooks/usePersonaSource"; import { promoteDraft } from "@/features/agents/lib/agentBuilderSession"; import { useAvatarLibrary } from "@/features/agents/hooks/useAvatarLibrary"; -import { - EXPERIMENT_PREFERENCES_STORAGE_KEY, - EXPERIMENT_PREFERENCES_STORAGE_VERSION, -} from "@/features/experiments/experimentPreferences"; -import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import type { AgentSourceEntry } from "@/shared/api/agents"; type UsePersonaSourceReturn = ReturnType; @@ -88,25 +83,6 @@ const baseSource: AgentSourceEntry = { writable: true, } as AgentSourceEntry; -function setExperimentOverrides(overrides: Record) { - localStorage.setItem( - EXPERIMENT_PREFERENCES_STORAGE_KEY, - JSON.stringify({ - version: EXPERIMENT_PREFERENCES_STORAGE_VERSION, - experiments: Object.fromEntries( - Object.entries(overrides).map(([id, enabled]) => [id, { enabled }]), - ), - }), - ); -} - -// Most rail tests exercise the classic inline picker, so the collection -// canvas experiment (auto-enabled in dev/test) is pinned off by default. -// Overlay-specific tests re-enable it explicitly. -function disableAvatarCollectionOverlay() { - setExperimentOverrides({ [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: false }); -} - function mockHook(overrides: Partial = {}) { const result: UsePersonaSourceReturn = { data: baseSource, @@ -123,8 +99,6 @@ function mockHook(overrides: Partial = {}) { describe("AgentBuilderRail", () => { beforeEach(() => { - localStorage.removeItem(EXPERIMENT_PREFERENCES_STORAGE_KEY); - disableAvatarCollectionOverlay(); vi.mocked(usePersonaSource).mockReset(); vi.mocked(promoteDraft).mockReset(); toastMocks.success.mockReset(); @@ -369,7 +343,7 @@ describe("AgentBuilderRail", () => { expect(update).not.toHaveBeenCalled(); }); - it("shows avatar choices only after the selected avatar is clicked", () => { + it("opens avatar choices in the collection canvas", () => { mockHook(); renderWithProviders( { ); expect( - screen.queryByRole("heading", { name: /choose an avatar/i }), + screen.queryByTestId("avatar-collection-overlay"), ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /select avatar/i })); - - expect( - screen.getByRole("heading", { name: /choose an avatar/i }), - ).toBeInTheDocument(); + expect(screen.getByTestId("avatar-collection-overlay")).toBeInTheDocument(); }); it("promotes an otherwise-complete draft without provider or model overrides", async () => { @@ -845,8 +816,7 @@ describe("AgentBuilderRail", () => { }); }); - it("opens the collection canvas overlay instead of the inline picker when the experiment is on", () => { - setExperimentOverrides({ [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: true }); + it("opens the collection canvas overlay instead of replacing the form", () => { mockHook(); renderWithProviders( { it("closes the collection canvas overlay back to the untouched form", () => { vi.useFakeTimers(); try { - setExperimentOverrides({ [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: true }); mockHook(); renderWithProviders( { } }); - it("keeps the classic inline picker when the collection canvas experiment is off", () => { - mockHook(); - renderWithProviders( - , - ); - - fireEvent.click(screen.getByRole("button", { name: /select avatar/i })); - - expect( - screen.getByRole("heading", { name: /choose an avatar/i }), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("avatar-collection-overlay"), - ).not.toBeInTheDocument(); - }); - it("renders an 'Invalid frontmatter' state when the source can't be parsed", () => { mockHook({ data: null, error: "parse", isLoading: false }); renderWithProviders( diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index 4472ab475..43152b87e 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -19,11 +19,6 @@ import { importPersonas } from "@/shared/api/agents"; import { useAvatarLibrary } from "@/features/agents/hooks/useAvatarLibrary"; import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; import type { CreatePersonaRequest } from "@/shared/types/agents"; -import { - EXPERIMENT_PREFERENCES_STORAGE_KEY, - EXPERIMENT_PREFERENCES_STORAGE_VERSION, -} from "@/features/experiments/experimentPreferences"; -import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { AgentsView } from "../AgentsView"; const mockCreatePersona = vi.hoisted(() => vi.fn()); @@ -256,25 +251,12 @@ describe("AgentsView entry points", () => { }); delete document.documentElement.dataset.agentTransition; Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); - localStorage.removeItem(EXPERIMENT_PREFERENCES_STORAGE_KEY); vi.restoreAllMocks(); }); beforeEach(async () => { vi.clearAllMocks(); vi.mocked(useAvatarLibrary).mockReturnValue(EMPTY_AVATAR_LIBRARY); - // These tests exercise the inline customize section, so the collection - // gallery experiment (auto-enabled in dev/test) is pinned off. - // Gallery-specific tests re-enable it explicitly. - localStorage.setItem( - EXPERIMENT_PREFERENCES_STORAGE_KEY, - JSON.stringify({ - version: EXPERIMENT_PREFERENCES_STORAGE_VERSION, - experiments: { - [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: { enabled: false }, - }, - }), - ); // Restore the default passthrough after tests that defer extraction. const actualZipImport = await vi.importActual< typeof import("@/features/agents/lib/agentZipImport") @@ -809,7 +791,7 @@ describe("AgentsView entry points", () => { expect(screen.queryByText("Description")).not.toBeInTheDocument(); }); - it("shows and activates the avatar customization affordance", async () => { + it("opens the avatar collection gallery from the customization affordance", async () => { useAgentStore.setState({ personas: [persona] }); const user = userEvent.setup(); @@ -823,30 +805,6 @@ describe("AgentsView entry points", () => { expect(customizeAvatar).toHaveFocus(); await user.keyboard("{Enter}"); - expect(screen.getByText("editor.avatarUrl")).toBeInTheDocument(); - }); - - it("opens the avatar collection gallery instead of the inline section when the experiment is on", async () => { - localStorage.setItem( - EXPERIMENT_PREFERENCES_STORAGE_KEY, - JSON.stringify({ - version: EXPERIMENT_PREFERENCES_STORAGE_VERSION, - experiments: { - [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: { enabled: true }, - }, - }), - ); - useAgentStore.setState({ personas: [persona] }); - const user = userEvent.setup(); - - render(); - - await user.click( - screen.getByRole("button", { name: "editor.customizeAvatar" }), - ); - - // The full-surface gallery takeover renders; the inline customize - // section (with its duplicate custom-URL form) never appears. expect(screen.getByTestId("avatar-collection-overlay")).toBeInTheDocument(); expect(screen.queryByText("editor.avatarUrl")).not.toBeInTheDocument(); }); @@ -868,15 +826,6 @@ describe("AgentsView entry points", () => { height: 800, toJSON: () => ({}), } as DOMRect); - localStorage.setItem( - EXPERIMENT_PREFERENCES_STORAGE_KEY, - JSON.stringify({ - version: EXPERIMENT_PREFERENCES_STORAGE_VERSION, - experiments: { - [AVATAR_COLLECTION_PAGE_EXPERIMENT_ID]: { enabled: true }, - }, - }), - ); useAgentStore.setState({ personas: [persona] }); const user = userEvent.setup(); @@ -1207,54 +1156,6 @@ describe("AgentsView entry points", () => { }); }); - describe("berd_agent Edit Completed", () => { - async function saveCustomAvatarUrl(url: string): Promise { - const user = userEvent.setup(); - await user.click( - screen.getByRole("button", { name: "editor.customizeAvatar" }), - ); - await user.type(screen.getByLabelText("editor.avatarUrl"), url); - await user.click( - screen.getByRole("button", { name: "common:actions.save" }), - ); - } - - it("fires once with the persisted identity after a detail-page avatar change", async () => { - const qualifiedPersona = { - ...persona, - provider: "goose", - model: "gpt-5.6", - }; - useAgentStore.setState({ personas: [qualifiedPersona] }); - render(); - - await saveCustomAvatarUrl("https://example.com/avatar.png"); - - await waitFor(() => - expect(mockTrackAgentEditCompleted).toHaveBeenCalledTimes(1), - ); - expect(mockUpdatePersona).toHaveBeenCalledWith( - expect.objectContaining({ id: qualifiedPersona.id }), - { avatar: "https://example.com/avatar.png" }, - ); - expect(mockTrackAgentEditCompleted).toHaveBeenCalledWith({ - provider: "goose", - model: "gpt-5.6", - }); - }); - - it("does not fire when the avatar update fails", async () => { - mockUpdatePersona.mockRejectedValueOnce(new Error("update failed")); - useAgentStore.setState({ personas: [persona] }); - render(); - - await saveCustomAvatarUrl("https://example.com/avatar.png"); - - await waitFor(() => expect(toast.error).toHaveBeenCalled()); - expect(mockTrackAgentEditCompleted).not.toHaveBeenCalled(); - }); - }); - it("starts a gallery-to-profile view transition when opening detail", () => { const resolved = Promise.resolve(); const startViewTransition = vi.fn((callback: () => void) => { diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index c61632d66..05ad7cce6 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, BERDY_ONBOARDING_EXPERIMENT_ID, BUILDERBOT_SURFACE_EXPERIMENT_ID, EXPERIMENT_DEFINITIONS, @@ -134,7 +133,6 @@ describe("ExperimentsSettings", () => { SKILL_DISCOVERY_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, - AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, BERDY_ONBOARDING_EXPERIMENT_ID, ]); }); diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index cc8235dea..69259647a 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -55,8 +55,6 @@ export const VOICE_CONVERSATION_EXPERIMENT_ID = "voice-conversation"; export const TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID = "transcript-virtual-renderer"; -export const AVATAR_COLLECTION_PAGE_EXPERIMENT_ID = "avatar-collection-page"; - export const STARTER_TASKS_EXPERIMENT_ID = "onboarding-starter-tasks"; export const BERDY_ONBOARDING_EXPERIMENT_ID = "berdy-onboarding"; @@ -95,16 +93,6 @@ export const EXPERIMENT_DEFINITIONS = [ descriptionKey: "experiments.voiceConversation.description", defaultEnabled: true, }, - { - id: AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, - titleKey: "experiments.avatarCollectionPage.title", - descriptionKey: "experiments.avatarCollectionPage.description", - // Replaces the inline avatar picker in the agent builder with a - // full-surface, pannable collection canvas rendered as a frosted-glass - // takeover. Purely a UI swap over the same avatar library state; no - // backend authority. No explicit default, so it follows the global - // auto-enable preference: on in dev builds, off in production. - }, { id: BERDY_ONBOARDING_EXPERIMENT_ID, titleKey: "experiments.berdyOnboarding.title", diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 0f1cdadf0..553bf4199 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -199,10 +199,6 @@ "description": "Dev builds enable experiments by default. Turn off individual experiments below when you need the stable path.", "label": "Auto-enable new experiments" }, - "avatarCollectionPage": { - "description": "Choose agent avatars on a full-screen, pannable collection canvas instead of the inline picker in the agent builder.", - "title": "Avatar collection canvas" - }, "berdyOnboarding": { "description": "Show Berdy's interactive onboarding guide and help bubble on Home.", "resetDescription": "Restore Berdy's starter tooltip and center it on Home.", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 26e2b1284..76c902f5b 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -199,10 +199,6 @@ "description": "Las compilaciones de desarrollo activan los experimentos de forma predeterminada. Desactiva experimentos individuales abajo cuando necesites el flujo estable.", "label": "Activar nuevos experimentos automáticamente" }, - "avatarCollectionPage": { - "description": "Elige avatares de agentes en un lienzo de colección de pantalla completa y desplazable en lugar del selector integrado en el generador de agentes.", - "title": "Lienzo de colección de avatares" - }, "berdyOnboarding": { "description": "Muestra la guía interactiva de introducción y ayuda de Berdy en Inicio.", "resetDescription": "Restaura el mensaje inicial de Berdy y lo centra en Inicio.", From 2c6afe9fd40c649df4e8cb606fc62903be8b3df6 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:49:19 -0700 Subject: [PATCH 2/6] remove legacy avatar picker path --- .../agents/lib/getCachedAvatarMedia.ts | 12 + src/features/agents/ui/AgentAvatarSection.tsx | 142 -------- .../agents/ui/AvatarCollectionOverlay.tsx | 2 +- .../agents/ui/AvatarLibraryPicker.tsx | 305 ------------------ .../ui/__tests__/AgentsView.entry.test.tsx | 39 ++- .../ui/__tests__/AvatarLibraryPicker.test.tsx | 113 ------- .../agentAvatarAffordanceLocaleParity.test.ts | 6 +- src/shared/i18n/locales/en/agents.json | 12 - src/shared/i18n/locales/es/agents.json | 12 - 9 files changed, 52 insertions(+), 591 deletions(-) create mode 100644 src/features/agents/lib/getCachedAvatarMedia.ts delete mode 100644 src/features/agents/ui/AgentAvatarSection.tsx delete mode 100644 src/features/agents/ui/AvatarLibraryPicker.tsx delete mode 100644 src/features/agents/ui/__tests__/AvatarLibraryPicker.test.tsx diff --git a/src/features/agents/lib/getCachedAvatarMedia.ts b/src/features/agents/lib/getCachedAvatarMedia.ts new file mode 100644 index 000000000..b5e6cefab --- /dev/null +++ b/src/features/agents/lib/getCachedAvatarMedia.ts @@ -0,0 +1,12 @@ +import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; + +export function getCachedAvatarMedia( + cachedAvatarMediaById: AvatarLibraryState["cachedAvatarMediaById"], + catalogVersion: string | undefined, + avatarId: string, +) { + const cachedMediaEntry = cachedAvatarMediaById[avatarId]; + return cachedMediaEntry?.catalogVersion === catalogVersion + ? cachedMediaEntry.media + : undefined; +} diff --git a/src/features/agents/ui/AgentAvatarSection.tsx b/src/features/agents/ui/AgentAvatarSection.tsx deleted file mode 100644 index f1ad5d506..000000000 --- a/src/features/agents/ui/AgentAvatarSection.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import type { ChangeEventHandler } from "react"; -import { Check, Trash2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { AvatarLibraryPicker } from "@/features/agents/ui/AvatarLibraryPicker"; -import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Label } from "@/shared/ui/label"; - -interface AgentAvatarSectionProps { - avatarPreviewFailed: boolean; - avatarPickerDisabled?: boolean; - avatarUrlError: string | null; - avatarUrlInputId: string; - canSaveCustomAvatar?: boolean; - clearDisabled?: boolean; - customAvatarUrlValue: string; - fieldGroupClassName: string; - fieldInputClassName: string; - fieldLabelClassName: string; - library: AvatarLibraryState; - onAvatarUrlChange: ChangeEventHandler; - onClearAvatar: () => void; - onPreviewError: () => void; - onSaveCustomAvatar?: () => void; - onSelectAvatar: (avatarId: string) => void; - selectedAvatarRef: string | null; - showClearAvatar: boolean; - title: string; -} - -export function AgentAvatarSection({ - avatarPreviewFailed, - avatarPickerDisabled = false, - avatarUrlError, - avatarUrlInputId, - canSaveCustomAvatar = false, - clearDisabled = false, - customAvatarUrlValue, - fieldGroupClassName, - fieldInputClassName, - fieldLabelClassName, - library, - onAvatarUrlChange, - onClearAvatar, - onPreviewError, - onSaveCustomAvatar, - onSelectAvatar, - selectedAvatarRef, - showClearAvatar, - title, -}: AgentAvatarSectionProps) { - const { t } = useTranslation(["agents", "common"]); - const titleId = `${avatarUrlInputId}-section-title`; - const errorId = `${avatarUrlInputId}-error`; - - return ( -
-
-

- {title} -

-
- -
- -
- - {onSaveCustomAvatar ? ( - - ) : null} - {showClearAvatar ? ( - - ) : null} -
- {avatarUrlError ? ( -

- {avatarUrlError} -

- ) : null} -
- -
- - - {avatarPreviewFailed ? ( -

- {t("avatar.loadFailed")} -

- ) : null} -
-
- ); -} diff --git a/src/features/agents/ui/AvatarCollectionOverlay.tsx b/src/features/agents/ui/AvatarCollectionOverlay.tsx index 90d15df74..cbcd1e039 100644 --- a/src/features/agents/ui/AvatarCollectionOverlay.tsx +++ b/src/features/agents/ui/AvatarCollectionOverlay.tsx @@ -28,7 +28,7 @@ import { type ScatterItemLayout, } from "@/features/agents/lib/avatarScatter"; import { useAvatarScatterPan } from "@/features/agents/hooks/useAvatarScatterPan"; -import { getCachedAvatarMedia } from "@/features/agents/ui/AvatarLibraryPicker"; +import { getCachedAvatarMedia } from "@/features/agents/lib/getCachedAvatarMedia"; /** Exit animation length; keep in sync with .avatar-overlay-exit. */ const OVERLAY_EXIT_MS = 260; diff --git a/src/features/agents/ui/AvatarLibraryPicker.tsx b/src/features/agents/ui/AvatarLibraryPicker.tsx deleted file mode 100644 index c141adb39..000000000 --- a/src/features/agents/ui/AvatarLibraryPicker.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { ArrowLeft, Check, RefreshCw } from "lucide-react"; -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - avatarRef, - getAvatarCatalogEntry, - mediaTypeFromMimeType, -} from "@/shared/avatars/catalog"; -import type { - AvatarCatalogEntry, - AvatarCollection, -} from "@/shared/avatars/catalog"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { AvatarMedia } from "@/shared/ui/avatar-media"; -import { Spinner } from "@/shared/ui/spinner"; -import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; - -interface AvatarLibraryPickerProps { - library: AvatarLibraryState; - selectedAvatarRef: string | null; - onSelectAvatar: (avatarId: string) => void; - onPreviewError: () => void; - disabled?: boolean; - /** - * When provided, the parent controls collection navigation and the picker's - * internal back-to-collections row is suppressed (the parent is expected to - * render its own back affordance, e.g. in a surrounding header). - */ - selectedCollectionId?: string | null; - onSelectCollection?: (collectionId: string | null) => void; -} - -export function getCachedAvatarMedia( - cachedAvatarMediaById: AvatarLibraryState["cachedAvatarMediaById"], - catalogVersion: string | undefined, - avatarId: string, -) { - const cachedMediaEntry = cachedAvatarMediaById[avatarId]; - return cachedMediaEntry?.catalogVersion === catalogVersion - ? cachedMediaEntry.media - : undefined; -} - -export function AvatarLibraryPicker({ - library, - selectedAvatarRef, - onSelectAvatar, - onPreviewError, - disabled = false, - selectedCollectionId: controlledCollectionId, - onSelectCollection, -}: AvatarLibraryPickerProps) { - const { t } = useTranslation(["agents", "common"]); - const isControlled = controlledCollectionId !== undefined; - const [uncontrolledCollectionId, setUncontrolledCollectionId] = useState< - string | null - >(null); - const selectedCollectionId = isControlled - ? controlledCollectionId - : uncontrolledCollectionId; - const setSelectedCollectionId = useCallback( - (next: string | null) => { - if (isControlled) { - onSelectCollection?.(next); - } else { - setUncontrolledCollectionId(next); - } - }, - [isControlled, onSelectCollection], - ); - - const avatarCollections = library.catalog?.collections ?? []; - const selectedCollection = avatarCollections.find( - (collection) => collection.id === selectedCollectionId, - ); - const catalogErrorText = - library.errorCode === "networkAccess" - ? t("editor.avatarCatalogNetworkAccess") - : t("editor.avatarCatalogUnavailable"); - const catalogVersion = library.catalog?.catalogVersion; - const { cachedAvatarMediaById } = library; - - const renderAvatarTile = useCallback( - (entry: AvatarCatalogEntry) => { - const ref = avatarRef(entry.id); - const selected = selectedAvatarRef === ref; - const cachedMedia = getCachedAvatarMedia( - cachedAvatarMediaById, - catalogVersion, - entry.id, - ); - const fallbackVariant = entry.variants.webm ?? entry.variants.hevc; - const fallbackMediaType = fallbackVariant - ? mediaTypeFromMimeType(fallbackVariant.mimeType) - : "image"; - const selectable = Boolean(cachedMedia) && !disabled; - - return ( - - ); - }, - [ - cachedAvatarMediaById, - catalogVersion, - disabled, - onPreviewError, - onSelectAvatar, - selectedAvatarRef, - ], - ); - - const renderCollectionButton = useCallback( - (collection: AvatarCollection) => { - const cover = getAvatarCatalogEntry( - library.catalog, - collection.coverAvatarId, - ); - if (!cover) { - return null; - } - const cachedCoverMedia = getCachedAvatarMedia( - cachedAvatarMediaById, - catalogVersion, - cover.id, - ); - - return ( - - ); - }, - [ - cachedAvatarMediaById, - catalogVersion, - disabled, - library, - onPreviewError, - setSelectedCollectionId, - ], - ); - - const renderCollectionSkeleton = (index: number) => ( -
- - - - - {t("editor.avatarLoading")} - -
- ); - - return ( -
- {library.error ? ( -
- {catalogErrorText} - -
- ) : library.mediaError ? ( -
- {t("avatar.loadFailed")} - -
- ) : null} - {selectedCollection ? ( -
- {isControlled ? null : ( -
- -

- {selectedCollection.label} -

-
- )} -
- {selectedCollection.avatarIds.map((avatarId) => { - const entry = getAvatarCatalogEntry(library.catalog, avatarId); - return entry ? renderAvatarTile(entry) : null; - })} -
-
- ) : ( -
- {library.loading && avatarCollections.length === 0 - ? [0, 1, 2].map(renderCollectionSkeleton) - : avatarCollections.map(renderCollectionButton)} -
- )} -
- ); -} diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index 43152b87e..ea5d60914 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -806,7 +806,6 @@ describe("AgentsView entry points", () => { await user.keyboard("{Enter}"); expect(screen.getByTestId("avatar-collection-overlay")).toBeInTheDocument(); - expect(screen.queryByText("editor.avatarUrl")).not.toBeInTheDocument(); }); it("persists the avatar picked in the collection gallery and closes the takeover", async () => { @@ -849,6 +848,11 @@ describe("AgentsView entry points", () => { { avatar: "app-avatar:gloopies-1" }, ), ); + expect(mockTrackAgentEditCompleted).toHaveBeenCalledTimes(1); + expect(mockTrackAgentEditCompleted).toHaveBeenCalledWith({ + provider: undefined, + model: undefined, + }); // The takeover hands control back to the profile after committing. await waitFor(() => @@ -858,6 +862,39 @@ describe("AgentsView entry points", () => { ); }); + it("does not report a completed edit when a canvas selection fails to persist", async () => { + vi.mocked(useAvatarLibrary).mockReturnValue( + singleAvatarLibrary("gloopies-1"), + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 1200, + bottom: 800, + width: 1200, + height: 800, + toJSON: () => ({}), + } as DOMRect); + mockUpdatePersona.mockRejectedValueOnce(new Error("update failed")); + useAgentStore.setState({ personas: [persona] }); + const user = userEvent.setup(); + + render(); + await user.click( + screen.getByRole("button", { name: "editor.customizeAvatar" }), + ); + const overlay = within(screen.getByTestId("avatar-collection-overlay")); + await user.click(overlay.getAllByRole("button", { name: "gloopies-1" })[0]); + await user.click( + overlay.getAllByRole("button", { name: "collectionPage.select" })[0], + ); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(mockTrackAgentEditCompleted).not.toHaveBeenCalled(); + }); + it("clicking detail Start chat calls onStartChatWithAgent with the persona id", () => { const onStartChatWithAgent = vi.fn(); useAgentStore.setState({ personas: [persona] }); diff --git a/src/features/agents/ui/__tests__/AvatarLibraryPicker.test.tsx b/src/features/agents/ui/__tests__/AvatarLibraryPicker.test.tsx deleted file mode 100644 index 615d09d42..000000000 --- a/src/features/agents/ui/__tests__/AvatarLibraryPicker.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "@/test/render"; -import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; -import { AvatarLibraryPicker } from "../AvatarLibraryPicker"; - -function libraryWithError( - errorCode: AvatarLibraryState["errorCode"], -): AvatarLibraryState { - return { - catalog: null, - cachedAvatarMediaById: {}, - loading: false, - cacheChecking: false, - error: true, - errorCode, - mediaError: false, - mediaErrorCode: null, - retryCatalog: vi.fn(), - retryMedia: vi.fn(), - }; -} - -function libraryWithMediaError(): AvatarLibraryState { - const retryMedia = vi.fn(); - return { - catalog: { - schemaVersion: 1, - catalogVersion: "v1", - collections: [ - { - id: "gloopies", - label: "Gloopies", - coverAvatarId: "g-1", - avatarIds: ["g-1"], - }, - ], - assets: [ - { - id: "g-1", - label: "Gloopie One", - collectionId: "gloopies", - variants: { - webm: { - path: "g-1.webm", - mimeType: "video/webm", - byteSize: 1, - sha256: "0".repeat(64), - }, - hevc: { - path: "g-1.mov", - mimeType: "video/quicktime", - byteSize: 1, - sha256: "0".repeat(64), - }, - }, - }, - ], - }, - cachedAvatarMediaById: {}, - loading: false, - cacheChecking: false, - error: false, - errorCode: null, - mediaError: true, - mediaErrorCode: "unavailable", - retryCatalog: vi.fn(), - retryMedia, - }; -} - -function picker(library: AvatarLibraryState) { - return ( - - ); -} - -describe("AvatarLibraryPicker", () => { - it("shows catalog error copy without referencing custom URLs", () => { - const { rerender } = renderWithProviders( - picker(libraryWithError("networkAccess")), - ); - - expect( - screen.getByText( - "Unable to load avatar library. Check your network connection and try again.", - ), - ).toBeInTheDocument(); - - rerender(picker(libraryWithError("unavailable"))); - - expect( - screen.getByText("Avatar library unavailable. Try again."), - ).toBeInTheDocument(); - expect( - screen.queryByText(/Custom URLs still work/i), - ).not.toBeInTheDocument(); - }); - - it("shows missing-media feedback with a manual retry", () => { - const library = libraryWithMediaError(); - renderWithProviders(picker(library)); - - expect(screen.getByText("Failed to load image")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - expect(library.retryMedia).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/shared/i18n/__tests__/agentAvatarAffordanceLocaleParity.test.ts b/src/shared/i18n/__tests__/agentAvatarAffordanceLocaleParity.test.ts index de5a5281f..ec4f4fba5 100644 --- a/src/shared/i18n/__tests__/agentAvatarAffordanceLocaleParity.test.ts +++ b/src/shared/i18n/__tests__/agentAvatarAffordanceLocaleParity.test.ts @@ -15,11 +15,7 @@ import esAgents from "../locales/es/agents.json"; * cleared, this pins the contract for the keys the avatar affordance owns. */ -const AVATAR_AFFORDANCE_KEYS = [ - "changeAvatar", - "customizeAvatar", - "avatarBackToProfile", -] as const; +const AVATAR_AFFORDANCE_KEYS = ["changeAvatar", "customizeAvatar"] as const; describe("agent avatar affordance locale parity", () => { it.each( diff --git a/src/shared/i18n/locales/en/agents.json b/src/shared/i18n/locales/en/agents.json index 086cb0c4b..071306399 100644 --- a/src/shared/i18n/locales/en/agents.json +++ b/src/shared/i18n/locales/en/agents.json @@ -26,17 +26,9 @@ "descriptionPlaceholder": "A short line about what this agent does", "instructionsLabel": "Agent instructions", "instructionsPlaceholder": "Describe the agent's goal and instructions", - "selectedAvatar": "Selected avatar", - "selectedAvatarNone": "None selected", - "selectedAvatarCustom": "Custom avatar", - "selectedAvatarUnavailable": "Avatar file unavailable", - "chooseAvatar": "Choose one from the library", - "chooseAvatarTitle": "Choose an avatar", - "backToForm": "Back", "backToAgent": "Back to agent", "selectAvatar": "Select avatar", "changeAvatar": "Change avatar", - "doneChoosingAvatar": "Done", "saveChanges": "Save changes", "savingChanges": "Saving changes...", "creatingAgent": "Creating agent...", @@ -94,11 +86,7 @@ "avatarLibrary": "Avatar library", "avatarLoading": "Loading", "avatarRetry": "Retry", - "avatarBackToProfile": "Back to profile", "changeAvatar": "Change avatar", - "avatarUrl": "Custom avatar URL", - "avatarUrlInvalid": "Enter a valid http or https URL without credentials.", - "avatarUrlPlaceholder": "https://example.com/avatar.png", "customizeAvatar": "Customize", "connectProvider": "Connect", "displayName": "Agent Name", diff --git a/src/shared/i18n/locales/es/agents.json b/src/shared/i18n/locales/es/agents.json index e3facae35..16605d395 100644 --- a/src/shared/i18n/locales/es/agents.json +++ b/src/shared/i18n/locales/es/agents.json @@ -26,17 +26,9 @@ "descriptionPlaceholder": "Una línea breve sobre lo que hace este agente", "instructionsLabel": "Instrucciones del agente", "instructionsPlaceholder": "Describe el objetivo e instrucciones del agente", - "selectedAvatar": "Avatar seleccionado", - "selectedAvatarNone": "Ninguno seleccionado", - "selectedAvatarCustom": "Avatar personalizado", - "selectedAvatarUnavailable": "El archivo del avatar no está disponible", - "chooseAvatar": "Elige uno de la biblioteca", - "chooseAvatarTitle": "Elegir avatar", - "backToForm": "Atrás", "backToAgent": "Volver al agente", "selectAvatar": "Seleccionar avatar", "changeAvatar": "Cambiar avatar", - "doneChoosingAvatar": "Listo", "saveChanges": "Guardar cambios", "savingChanges": "Guardando cambios...", "creatingAgent": "Creando agente...", @@ -94,11 +86,7 @@ "avatarLibrary": "Biblioteca de avatares", "avatarLoading": "Cargando", "avatarRetry": "Reintentar", - "avatarBackToProfile": "Volver al perfil", "changeAvatar": "Cambiar avatar", - "avatarUrl": "URL de avatar personalizada", - "avatarUrlInvalid": "Ingresa una URL http o https válida sin credenciales.", - "avatarUrlPlaceholder": "https://example.com/avatar.png", "customizeAvatar": "Personalizar", "connectProvider": "Conectar", "displayName": "Nombre del agente", From 2ce6583af27b32d1af5e49d738d7a5b208f94a98 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:13:02 -0700 Subject: [PATCH 3/6] preserve avatar selection retry context --- src/features/agents/ui/AgentBuilderRail.tsx | 1 - src/features/agents/ui/AgentDetailPage.tsx | 17 ++--- .../agents/ui/AvatarCollectionOverlay.tsx | 67 +++++++++++++++++-- .../AvatarCollectionOverlay.test.tsx | 54 ++++++++++++++- 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index e23b12aed..b5a589a5a 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -206,7 +206,6 @@ export function AgentBuilderRail({ const onSelectAvatar = useCallback( (avatarId: string) => { writeProperty("avatar", avatarRef(avatarId)); - setAvatarPanel("closed"); }, [writeProperty], ); diff --git a/src/features/agents/ui/AgentDetailPage.tsx b/src/features/agents/ui/AgentDetailPage.tsx index 383d67b6f..93e5ca00e 100644 --- a/src/features/agents/ui/AgentDetailPage.tsx +++ b/src/features/agents/ui/AgentDetailPage.tsx @@ -175,8 +175,9 @@ export function AgentDetailPage({ await onAvatarUpdate(persona, nextAvatar); setAvatarValue(nextAvatar ?? ""); setAvatarPreviewFailed(false); - } catch { + } catch (error) { setAvatarValue(personaAvatarValue); + throw error; } finally { setAvatarSavePending(false); } @@ -190,24 +191,16 @@ export function AgentDetailPage({ ], ); - const handleSelectAvatar = useCallback( - (avatarId: string) => { + const handleSelectOverlayAvatar = useCallback( + async (avatarId: string) => { const nextAvatar = avatarRef(avatarId); setAvatarValue(nextAvatar); setAvatarPreviewFailed(false); - void commitAvatar(nextAvatar); + await commitAvatar(nextAvatar); }, [commitAvatar], ); - const handleSelectOverlayAvatar = useCallback( - (avatarId: string) => { - setShowAvatarOverlay(false); - handleSelectAvatar(avatarId); - }, - [handleSelectAvatar], - ); - const handleCloseAvatarOverlay = useCallback(() => { setShowAvatarOverlay(false); }, []); diff --git a/src/features/agents/ui/AvatarCollectionOverlay.tsx b/src/features/agents/ui/AvatarCollectionOverlay.tsx index cbcd1e039..f89fd0b36 100644 --- a/src/features/agents/ui/AvatarCollectionOverlay.tsx +++ b/src/features/agents/ui/AvatarCollectionOverlay.tsx @@ -18,7 +18,9 @@ import type { } from "@/shared/avatars/catalog"; import { getAvatarCatalogEntry } from "@/shared/avatars/catalog"; import { cn } from "@/shared/lib/cn"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; import { AvatarMedia } from "@/shared/ui/avatar-media"; +import { BerdLoaderInline } from "@/shared/ui/berd-loader-inline"; import { Button } from "@/shared/ui/button"; import { CanvasNavButton } from "@/shared/ui/canvas-nav-button"; import { Spinner } from "@/shared/ui/spinner"; @@ -137,7 +139,7 @@ interface AvatarCollectionOverlayProps { library: AvatarLibraryState; /** Collection to open with; null starts at the collections level. */ initialCollectionId?: string | null; - onSelectAvatar: (avatarId: string) => void; + onSelectAvatar: (avatarId: string) => void | Promise; onClose: () => void; } @@ -177,6 +179,8 @@ export function AvatarCollectionOverlay({ ); const [pendingAvatarId, setPendingAvatarId] = useState(null); const [hoveredAvatarId, setHoveredAvatarId] = useState(null); + const [selectionPending, setSelectionPending] = useState(false); + const [selectionFailed, setSelectionFailed] = useState(false); const [closing, setClosing] = useState(false); // Where the funnel exit collapses to (viewport px); null = plain fade. const [exitTarget, setExitTarget] = useState<{ @@ -393,13 +397,32 @@ export function AvatarCollectionOverlay({ ); const onConfirmSelect = useCallback(() => { - if (!pendingAvatarId || closing) { + if (!pendingAvatarId || closing || selectionPending) { return; } const avatarId = pendingAvatarId; - // The chosen avatar lands on the rail's preview; funnel toward it. - closeWithAnimation(() => onSelectAvatar(avatarId), "funnel"); - }, [closing, closeWithAnimation, onSelectAvatar, pendingAvatarId]); + setSelectionPending(true); + setSelectionFailed(false); + void Promise.resolve(onSelectAvatar(avatarId)) + .then(() => { + // The chosen avatar lands on the rail's preview; funnel toward it only + // after persistence succeeds so a failed save keeps retry context. + closeWithAnimation(onClose, "funnel"); + }) + .catch(() => { + setSelectionFailed(true); + }) + .finally(() => { + setSelectionPending(false); + }); + }, [ + closing, + closeWithAnimation, + onClose, + onSelectAvatar, + pendingAvatarId, + selectionPending, + ]); const hoverHandlers = useCallback( (id: string) => ({ @@ -488,6 +511,8 @@ export function AvatarCollectionOverlay({ + + + ) : null} + {/* Navigation chrome, per design feedback (Berd-Updates 704-3688): one black icon-only circle in the top-right corner. An arrow when it goes up a level, an X when it dismisses the takeover outright; diff --git a/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx b/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx index f6f334352..dae1f6af1 100644 --- a/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx +++ b/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx @@ -124,7 +124,7 @@ describe("AvatarCollectionOverlay", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("highlights an avatar on click and commits it via the Select button", () => { + it("highlights an avatar, persists it, then closes", async () => { const onSelectAvatar = vi.fn(); renderWithProviders( { ).toBe(true); fireEvent.click(overlay().getByRole("button", { name: /^select$/i })); - expect(onSelectAvatar).not.toHaveBeenCalled(); - finishExitAnimation(); expect(onSelectAvatar).toHaveBeenCalledWith("g-1"); + await act(async () => {}); + finishExitAnimation(); + }); + + it("shows a neutral loading state while the catalog loads", () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + expect(overlay().getByRole("status")).toHaveTextContent("Loading"); + fireEvent.click(overlay().getByRole("button", { name: /^close$/i })); + finishExitAnimation(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("keeps a failed selection highlighted and lets the user retry", async () => { + const onSelectAvatar = vi + .fn() + .mockRejectedValueOnce(new Error("save failed")) + .mockResolvedValueOnce(undefined); + renderWithProviders( + , + ); + + fireEvent.click(overlay().getAllByRole("button", { name: "g-1" })[0]); + fireEvent.click(overlay().getByRole("button", { name: /^select$/i })); + await act(async () => {}); + + expect(overlay().getByRole("alert")).toHaveTextContent( + "Save failed. Your edits are still here.", + ); + expect( + overlay() + .getAllByRole("button", { name: "g-1" }) + .some((tile) => tile.getAttribute("aria-pressed") === "true"), + ).toBe(true); + + fireEvent.click(overlay().getByRole("button", { name: /retry save/i })); + await act(async () => {}); + expect(onSelectAvatar).toHaveBeenCalledTimes(2); + finishExitAnimation(); }); it("toggles the highlight off when the same avatar is clicked again", () => { From 90305b7034348acb5eac1423bbd775d3b2e6ea08 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:07:53 -0700 Subject: [PATCH 4/6] clarify avatar selection acceptance boundary --- src/features/agents/ui/AgentBuilderRail.tsx | 3 + .../agents/ui/AvatarCollectionOverlay.tsx | 9 +- .../ui/__tests__/AgentBuilderRail.test.tsx | 87 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index b5a589a5a..2e71384e6 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -205,6 +205,9 @@ export function AgentBuilderRail({ const onSelectAvatar = useCallback( (avatarId: string) => { + // Avatar selection joins the same working buffer as every other field. + // Existing-agent edits stay local until Save; drafts keep their normal + // debounced durability rather than making this field a separate commit. writeProperty("avatar", avatarRef(avatarId)); }, [writeProperty], diff --git a/src/features/agents/ui/AvatarCollectionOverlay.tsx b/src/features/agents/ui/AvatarCollectionOverlay.tsx index f89fd0b36..0945169b6 100644 --- a/src/features/agents/ui/AvatarCollectionOverlay.tsx +++ b/src/features/agents/ui/AvatarCollectionOverlay.tsx @@ -139,6 +139,11 @@ interface AvatarCollectionOverlayProps { library: AvatarLibraryState; /** Collection to open with; null starts at the collections level. */ initialCollectionId?: string | null; + /** + * Hands the selection to the owning editor. A synchronous owner has accepted + * it into its working buffer; an asynchronous owner can delay dismissal + * until its own commit boundary succeeds. + */ onSelectAvatar: (avatarId: string) => void | Promise; onClose: () => void; } @@ -405,8 +410,8 @@ export function AvatarCollectionOverlay({ setSelectionFailed(false); void Promise.resolve(onSelectAvatar(avatarId)) .then(() => { - // The chosen avatar lands on the rail's preview; funnel toward it only - // after persistence succeeds so a failed save keeps retry context. + // The owner decides its acceptance boundary: the builder accepts into + // its working buffer, while the detail page waits for persistence. closeWithAnimation(onClose, "funnel"); }) .catch(() => { diff --git a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx index 0fcb43941..01915ba47 100644 --- a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx +++ b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx @@ -343,6 +343,93 @@ describe("AgentBuilderRail", () => { expect(update).not.toHaveBeenCalled(); }); + it("accepts an avatar into the working buffer without saving other edits", async () => { + vi.useFakeTimers(); + try { + const { update, saveNow } = mockHook(); + vi.mocked(useAvatarLibrary).mockReturnValue({ + catalog: { + schemaVersion: 1, + catalogVersion: "v1", + collections: [ + { + id: "gloopies", + label: "Gloopies", + coverAvatarId: "gloopy-1", + avatarIds: ["gloopy-1"], + }, + ], + assets: [ + { + id: "gloopy-1", + label: "Gloopy 1", + collectionId: "gloopies", + variants: { + webm: { + path: "gloopy-1.webm", + mimeType: "video/webm", + byteSize: 1, + sha256: "a".repeat(64), + }, + hevc: { + path: "gloopy-1.mov", + mimeType: "video/quicktime", + byteSize: 1, + sha256: "b".repeat(64), + }, + }, + }, + ], + }, + cachedAvatarMediaById: { + "gloopy-1": { + catalogVersion: "v1", + media: { src: "/cached/gloopy-1.webm", mediaType: "video" }, + }, + }, + loading: false, + cacheChecking: false, + error: false, + errorCode: null, + mediaError: false, + mediaErrorCode: null, + retryCatalog: vi.fn(), + retryMedia: vi.fn(), + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 1200, + bottom: 800, + width: 1200, + height: 800, + toJSON: () => ({}), + } as DOMRect); + + renderWithProviders( + , + ); + fireEvent.click(screen.getByRole("button", { name: /select avatar/i })); + fireEvent.click(screen.getAllByRole("button", { name: "Gloopy 1" })[0]); + fireEvent.click(screen.getByRole("button", { name: /^select$/i })); + await act(async () => {}); + + expect(update).toHaveBeenCalledWith({ + properties: { avatar: "app-avatar:gloopy-1" }, + }); + expect(saveNow).not.toHaveBeenCalled(); + act(() => vi.runAllTimers()); + } finally { + vi.useRealTimers(); + } + }); + it("opens avatar choices in the collection canvas", () => { mockHook(); renderWithProviders( From d15a6276d129162f64aefe759bbf69e0a347822a Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:59:36 -0700 Subject: [PATCH 5/6] lock avatar canvas during profile save --- .../agents/ui/AvatarCollectionOverlay.tsx | 21 +++++++--- .../AvatarCollectionOverlay.test.tsx | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/features/agents/ui/AvatarCollectionOverlay.tsx b/src/features/agents/ui/AvatarCollectionOverlay.tsx index 0945169b6..ff5312125 100644 --- a/src/features/agents/ui/AvatarCollectionOverlay.tsx +++ b/src/features/agents/ui/AvatarCollectionOverlay.tsx @@ -269,7 +269,7 @@ export function AvatarCollectionOverlay({ ); const goBack = useCallback(() => { - if (closing) { + if (closing || selectionPending) { return; } if (collection && hasCollectionsLevel) { @@ -278,7 +278,14 @@ export function AvatarCollectionOverlay({ return; } closeWithAnimation(onClose); - }, [closing, closeWithAnimation, collection, hasCollectionsLevel, onClose]); + }, [ + closing, + closeWithAnimation, + collection, + hasCollectionsLevel, + onClose, + selectionPending, + ]); // Esc mirrors the back control at every level. useEffect(() => { @@ -376,7 +383,7 @@ export function AvatarCollectionOverlay({ const onCanvasClick = useCallback( (event: React.MouseEvent) => { - if (closing) { + if (closing || selectionPending) { return; } const target = event.target as HTMLElement; @@ -398,7 +405,7 @@ export function AvatarCollectionOverlay({ // overlay like a dialog scrim. closeWithAnimation(onClose); }, - [closing, closeWithAnimation, collection, onClose], + [closing, closeWithAnimation, collection, onClose, selectionPending], ); const onConfirmSelect = useCallback(() => { @@ -476,7 +483,7 @@ export function AvatarCollectionOverlay({ )} aria-label={entry.label} aria-pressed={pending} - disabled={!cachedMedia || closing} + disabled={!cachedMedia || closing || selectionPending} {...hoverHandlers(entry.id)} onClick={() => setPendingAvatarId((current) => @@ -566,7 +573,7 @@ export function AvatarCollectionOverlay({ aria-label={t("collectionPage.openCollection", { label: entry.label, })} - disabled={closing} + disabled={closing || selectionPending} onClick={() => setCollectionId(entry.id)} > @@ -604,6 +611,7 @@ export function AvatarCollectionOverlay({ library.catalog, markReady, readyIds, + selectionPending, t, ], ); @@ -772,6 +780,7 @@ export function AvatarCollectionOverlay({ size="icon-lg" aria-label={backLabel} title={backLabel} + disabled={selectionPending} onClick={goBack} > {collection && hasCollectionsLevel ? ( diff --git a/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx b/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx index dae1f6af1..d28a4b324 100644 --- a/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx +++ b/src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx @@ -153,6 +153,47 @@ describe("AvatarCollectionOverlay", () => { finishExitAnimation(); }); + it("keeps the canvas and highlighted selection locked while persistence is pending", async () => { + let resolveSelection: (() => void) | undefined; + const onSelectAvatar = vi.fn( + () => + new Promise((resolve) => { + resolveSelection = resolve; + }), + ); + const onClose = vi.fn(); + renderWithProviders( + , + ); + + const selectedTile = overlay().getAllByRole("button", { name: "g-1" })[0]; + fireEvent.click(selectedTile); + fireEvent.click(overlay().getByRole("button", { name: /^select$/i })); + + expect(selectedTile).toBeDisabled(); + expect(overlay().getAllByRole("button", { name: "g-2" })[0]).toBeDisabled(); + expect(overlay().getByRole("button", { name: /^close$/i })).toBeDisabled(); + + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.click(screen.getByTestId("avatar-collection-overlay")); + finishExitAnimation(); + + expect(onClose).not.toHaveBeenCalled(); + expect( + overlay() + .getAllByRole("button", { name: "g-1" }) + .some((tile) => tile.getAttribute("aria-pressed") === "true"), + ).toBe(true); + + await act(async () => resolveSelection?.()); + finishExitAnimation(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("shows a neutral loading state while the catalog loads", () => { const onClose = vi.fn(); renderWithProviders( From c9f0c67669e4c445c4fa4f219da605e98f5274e8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 21 Aug 2026 16:24:54 +1000 Subject: [PATCH 6/6] fix(agents): clear stale avatar selection errors Signed-off-by: Matt Toohey --- .../agents/ui/AvatarCollectionOverlay.tsx | 31 +++++++--- .../AvatarCollectionOverlay.test.tsx | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/features/agents/ui/AvatarCollectionOverlay.tsx b/src/features/agents/ui/AvatarCollectionOverlay.tsx index ff5312125..da68a8bc0 100644 --- a/src/features/agents/ui/AvatarCollectionOverlay.tsx +++ b/src/features/agents/ui/AvatarCollectionOverlay.tsx @@ -268,17 +268,28 @@ export function AvatarCollectionOverlay({ [], ); + const clearPendingSelection = useCallback(() => { + setPendingAvatarId(null); + setSelectionFailed(false); + }, []); + + const togglePendingSelection = useCallback((avatarId: string) => { + setPendingAvatarId((current) => (current === avatarId ? null : avatarId)); + setSelectionFailed(false); + }, []); + const goBack = useCallback(() => { if (closing || selectionPending) { return; } if (collection && hasCollectionsLevel) { - setPendingAvatarId(null); + clearPendingSelection(); setCollectionId(null); return; } closeWithAnimation(onClose); }, [ + clearPendingSelection, closing, closeWithAnimation, collection, @@ -398,14 +409,21 @@ export function AvatarCollectionOverlay({ // highlight — everything fades back up. Deliberately not a dismiss: // mis-clicking near an avatar must not throw the user out of the // picker. - setPendingAvatarId(null); + clearPendingSelection(); return; } // On the collections level, empty-canvas clicks light-dismiss the // overlay like a dialog scrim. closeWithAnimation(onClose); }, - [closing, closeWithAnimation, collection, onClose, selectionPending], + [ + clearPendingSelection, + closing, + closeWithAnimation, + collection, + onClose, + selectionPending, + ], ); const onConfirmSelect = useCallback(() => { @@ -485,11 +503,7 @@ export function AvatarCollectionOverlay({ aria-pressed={pending} disabled={!cachedMedia || closing || selectionPending} {...hoverHandlers(entry.id)} - onClick={() => - setPendingAvatarId((current) => - current === entry.id ? null : entry.id, - ) - } + onClick={() => togglePendingSelection(entry.id)} > {cachedMedia ? ( { finishExitAnimation(); }); + it("clears a failed selection when the highlighted avatar is toggled off", async () => { + const onSelectAvatar = vi + .fn() + .mockRejectedValueOnce(new Error("save failed")); + renderWithProviders( + , + ); + + fireEvent.click(overlay().getAllByRole("button", { name: "g-1" })[0]); + fireEvent.click(overlay().getByRole("button", { name: /^select$/i })); + await act(async () => {}); + + expect(overlay().getByRole("alert")).toBeInTheDocument(); + fireEvent.click(overlay().getAllByRole("button", { name: "g-1" })[0]); + + expect(overlay().queryByRole("alert")).not.toBeInTheDocument(); + expect( + overlay().queryByRole("button", { name: /^select$/i }), + ).not.toBeInTheDocument(); + }); + it("toggles the highlight off when the same avatar is clicked again", () => { renderWithProviders( { ).not.toBeInTheDocument(); }); + it("clears a failed selection when going up to the collections level", async () => { + const onSelectAvatar = vi.fn().mockRejectedValue(new Error("save failed")); + renderWithProviders( + , + ); + + fireEvent.click( + overlay().getAllByRole("button", { + name: /open the gloopies collection/i, + })[0], + ); + fireEvent.click(overlay().getAllByRole("button", { name: "g-1" })[0]); + fireEvent.click(overlay().getByRole("button", { name: /^select$/i })); + await act(async () => {}); + + expect(overlay().getByRole("alert")).toBeInTheDocument(); + fireEvent.click( + overlay().getByRole("button", { name: /back to avatar collections/i }), + ); + + expect(overlay().queryByRole("alert")).not.toBeInTheDocument(); + expect( + overlay().getByRole("heading", { name: /avatar collections/i }), + ).toBeInTheDocument(); + }); + it("returns to the collections level on Escape before closing", () => { const onClose = vi.fn(); renderWithProviders(