Skip to content

Commit 87ae7a7

Browse files
morgmartmatt2e
andauthored
graduate avatar collection canvas (#153)
**Category:** improvement **User Impact:** Everyone now chooses agent avatars from the full-screen, pannable collection canvas. **Problem:** The collection canvas remained behind an experiment, leaving production users on a separate inline picker and requiring two avatar-selection paths to stay maintained. **Solution:** Make the collection canvas the single avatar-picker experience across the agent builder and profile, then remove the experiment registration and legacy picker implementation. The canvas shows a neutral Berd loading state while its catalog loads. On agent profiles, it waits for persistence before closing and preserves failed selections for retry; in the builder, selections join the existing working buffer without prematurely saving unrelated edits. <details> <summary>File changes</summary> **scripts/design-system-audit.mjs** Removes a stale baseline exception after the retired inline profile editor styling was deleted. **src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx** Stops configuring the graduated experiment in capability setup. **src/features/agents/lib/getCachedAvatarMedia.ts** Moves cached avatar media lookup into a neutral shared utility used by the permanent canvas. **src/features/agents/ui/AgentAvatarSection.tsx** Removes the retired inline avatar customization surface. **src/features/agents/ui/AgentBuilderRail.tsx** Makes the collection canvas the sole avatar library path in compact and full-page builders. **src/features/agents/ui/AgentDetailPage.tsx** Opens the collection canvas directly from profile avatar customization and removes the legacy inline branch. **src/features/agents/ui/AvatarCollectionOverlay.tsx** Reads cached media through the neutral avatar utility, shows a neutral loading state, and supports async owners that preserve failed selections for retry. **src/features/agents/ui/AvatarLibraryPicker.tsx** Removes the retired inline avatar library implementation. **src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx** Updates builder expectations for the always-on canvas, removes experiment-specific cases, and pins that avatar selection joins the existing working buffer without prematurely saving unrelated edits. **src/features/agents/ui/__tests__/AgentsView.entry.test.tsx** Covers permanent canvas opening from agent profiles, persistence, closure, telemetry, and failed-write behavior. **src/features/agents/ui/__tests__/AvatarCollectionOverlay.test.tsx** Covers loading feedback and retrying a failed selection without losing canvas context. **src/features/agents/ui/__tests__/AvatarLibraryPicker.test.tsx** Removes tests for the retired inline picker. **src/features/experiments/__tests__/ExperimentsSettings.test.tsx** Updates the supported experiment registry expectation. **src/features/experiments/experimentDefinitions.ts** Removes the avatar collection canvas experiment definition and identifier. **src/shared/i18n/__tests__/agentAvatarAffordanceLocaleParity.test.ts** Narrows locale parity coverage to the remaining profile avatar affordance copy. **src/shared/i18n/locales/en/agents.json** Removes English copy used only by the retired inline picker. **src/shared/i18n/locales/en/settings.json** Removes the graduated experiment’s English settings copy. **src/shared/i18n/locales/es/agents.json** Removes Spanish copy used only by the retired inline picker. **src/shared/i18n/locales/es/settings.json** Removes the graduated experiment’s Spanish settings copy. </details> <img width="2152" height="1522" alt="Screenshot 2026-08-20 at 6 07 44 PM" src="https://github.com/user-attachments/assets/563f3706-cef1-404c-84d3-cca0e86985ea" /> --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Matt Toohey <contact@matttoohey.com>
1 parent 04505c3 commit 87ae7a7

19 files changed

Lines changed: 444 additions & 1084 deletions

scripts/design-system-audit.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ const buttonStylingPattern =
113113
/(?:^|[\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))/;
114114

115115
const buttonStylingBaseline = new Set([
116-
"src/features/agents/ui/AgentDetailPage.tsx",
117116
"src/features/chat/ui/ChatInputToolbar.tsx",
118117
"src/features/chat/ui/MessageBubbleActions.tsx",
119118
"src/features/chat/ui/PersonaPicker.tsx",

src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ import {
6161
useChatSessionStore,
6262
type ChatSession,
6363
} from "@/features/chat/stores/chatSessionStore";
64-
import { setExperimentEnabled } from "@/features/experiments/experimentPreferences";
65-
import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions";
6664
import type { AgentSourceEntry } from "@/shared/api/agents";
6765

6866
const existingAgentSource: AgentSourceEntry = {
@@ -125,7 +123,6 @@ describe("AgentBuilderCapability keep-save telemetry", () => {
125123
);
126124
apiMocks.listPersonas.mockResolvedValue([]);
127125
resetAgentBuilderSourceLifecycleForTests();
128-
setExperimentEnabled(AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, false);
129126
useAgentStore.setState({
130127
personas: [],
131128
personasLoading: false,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary";
2+
3+
export function getCachedAvatarMedia(
4+
cachedAvatarMediaById: AvatarLibraryState["cachedAvatarMediaById"],
5+
catalogVersion: string | undefined,
6+
avatarId: string,
7+
) {
8+
const cachedMediaEntry = cachedAvatarMediaById[avatarId];
9+
return cachedMediaEntry?.catalogVersion === catalogVersion
10+
? cachedMediaEntry.media
11+
: undefined;
12+
}

src/features/agents/ui/AgentAvatarSection.tsx

Lines changed: 0 additions & 142 deletions
This file was deleted.

src/features/agents/ui/AgentBuilderRail.tsx

Lines changed: 7 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import {
88
import { useTranslation } from "react-i18next";
99
import {
1010
IconAlertTriangle,
11-
IconArrowLeft,
1211
IconLayoutSidebarLeftExpand,
1312
IconPhoto,
1413
IconSparkles,
1514
IconX,
1615
} from "@tabler/icons-react";
17-
import { avatarRef, isLibraryAvatarRef } from "@/shared/avatars/catalog";
16+
import { avatarRef } from "@/shared/avatars/catalog";
1817
import { normalizeAvatarUrl } from "@/shared/lib/avatarUrl";
1918
import { cn } from "@/shared/lib/cn";
2019
import type { AgentSourceEntry } from "@/shared/api/agents";
@@ -44,10 +43,7 @@ import {
4443
PLACEHOLDER_AGENT_BODY,
4544
promoteDraft,
4645
} from "@/features/agents/lib/agentBuilderSession";
47-
import { useExperiment } from "@/features/experiments/experimentPreferences";
48-
import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions";
4946
import { AvatarCollectionOverlay } from "@/features/agents/ui/AvatarCollectionOverlay";
50-
import { AvatarLibraryPicker } from "@/features/agents/ui/AvatarLibraryPicker";
5147
import { ProviderModelFields } from "@/features/agents/ui/PersonaFields/ProviderModelFields";
5248
import { FORM_FIELD_CLASS } from "@/shared/ui/form-field-tokens";
5349
import { hasRealAgentDescription } from "@/shared/api/agents";
@@ -139,15 +135,6 @@ export function AgentBuilderRail({
139135
const [failedMissingDraftRecoveryKey, setFailedMissingDraftRecoveryKey] =
140136
useState<string | null>(null);
141137
const avatarLibrary = useAvatarLibrary(true);
142-
const avatarCollectionExperiment = useExperiment(
143-
AVATAR_COLLECTION_PAGE_EXPERIMENT_ID,
144-
);
145-
// When on, "library" renders as the full-surface collection canvas overlay
146-
// (portal over the whole app) instead of the inline picker. The chat +
147-
// builder stay mounted underneath, so composer drafts, resize state, all survive the takeover.
148-
const avatarCollectionOverlayEnabled = Boolean(
149-
avatarCollectionExperiment?.enabled,
150-
);
151138
const isWaitingForDraftTarget = !targetAgentPath;
152139
const missingDraftRecoveryKey = `${sessionId}:${targetAgentPath ?? "pending"}`;
153140
const [previousMissingDraftRecoveryKey, setPreviousMissingDraftRecoveryKey] =
@@ -197,20 +184,6 @@ export function AgentBuilderRail({
197184
typeof data?.properties?.avatar === "string" ? data.properties.avatar : "";
198185
const trimmedAvatar = avatarRaw.trim();
199186
const normalizedAvatar = normalizeAvatarUrl(trimmedAvatar);
200-
const [selectedCollectionId, setSelectedCollectionId] = useState<
201-
string | null
202-
>(null);
203-
const selectedCollectionLabel = useMemo(() => {
204-
if (!selectedCollectionId) {
205-
return null;
206-
}
207-
return (
208-
avatarLibrary.catalog?.collections.find(
209-
(collection) => collection.id === selectedCollectionId,
210-
)?.label ?? null
211-
);
212-
}, [avatarLibrary.catalog, selectedCollectionId]);
213-
214187
const provider = (data?.properties?.provider as string | undefined) ?? "";
215188
const modelProviderId =
216189
(data?.properties?.modelProviderId as string | undefined) ?? "";
@@ -232,9 +205,10 @@ export function AgentBuilderRail({
232205

233206
const onSelectAvatar = useCallback(
234207
(selectedAvatarRef: string) => {
208+
// Avatar selection joins the same working buffer as every other field.
209+
// Existing-agent edits stay local until Save; drafts keep their normal
210+
// debounced durability rather than making this field a separate commit.
235211
writeProperty("avatar", selectedAvatarRef);
236-
setSelectedCollectionId(null);
237-
setAvatarPanel("closed");
238212
},
239213
[writeProperty],
240214
);
@@ -261,10 +235,6 @@ export function AgentBuilderRail({
261235
: null;
262236
const effectiveAvatar =
263237
normalizedAvatar ?? (defaultAvatarId ? avatarRef(defaultAvatarId) : null);
264-
const selectedAvatarRefValue =
265-
effectiveAvatar && isLibraryAvatarRef(effectiveAvatar)
266-
? effectiveAvatar
267-
: null;
268238
const selectedAvatarMediaState = useAvatarMediaState(effectiveAvatar);
269239

270240
const onChangeProvider = useCallback(
@@ -524,53 +494,15 @@ export function AgentBuilderRail({
524494
</aside>
525495
);
526496

527-
// Rendered by both the compact rail and the full-page builder. Declared once
528-
// so picker changes cannot be applied to one layout and silently missed in
529-
// the other.
530-
const avatarLibraryPickerNode = (
531-
<AvatarLibraryPicker
532-
library={avatarLibrary}
533-
selectedAvatarRef={selectedAvatarRefValue}
534-
onSelectAvatar={onSelectAvatar}
535-
onPreviewError={() => {}}
536-
selectedCollectionId={selectedCollectionId}
537-
onSelectCollection={setSelectedCollectionId}
538-
/>
539-
);
540-
541497
const avatarCollectionOverlayNode =
542-
avatarCollectionOverlayEnabled && avatarPanel === "library" ? (
498+
avatarPanel === "library" ? (
543499
<AvatarCollectionOverlay
544500
library={avatarLibrary}
545-
initialCollectionId={selectedCollectionId}
546501
onSelectAvatar={onSelectAvatar}
547-
onClose={() => {
548-
setSelectedCollectionId(null);
549-
setAvatarPanel("closed");
550-
}}
502+
onClose={() => setAvatarPanel("closed")}
551503
/>
552504
) : null;
553505

554-
const pickerHeaderNode = (
555-
<div className={cn(STICKY_HEADER_CLASS, "flex items-center gap-2")}>
556-
<Button
557-
type="button"
558-
variant="ghost"
559-
size="icon-xs"
560-
aria-label={t("builderRail.backToForm")}
561-
onClick={() => {
562-
if (selectedCollectionId) setSelectedCollectionId(null);
563-
else setAvatarPanel("closed");
564-
}}
565-
>
566-
<IconArrowLeft className="size-4" aria-hidden="true" />
567-
</Button>
568-
<h2 className="truncate text-sm font-normal text-foreground">
569-
{selectedCollectionLabel ?? t("builderRail.chooseAvatarTitle")}
570-
</h2>
571-
</div>
572-
);
573-
574506
if (error === "parse") {
575507
return shell(
576508
headerNode,
@@ -666,17 +598,6 @@ export function AgentBuilderRail({
666598
);
667599
}
668600

669-
// With the collection canvas experiment on, the "library" panel renders as
670-
// the full-surface overlay (mounted below) instead of swapping the rail
671-
// body, so the form stays visible underneath the frosted glass.
672-
if (
673-
avatarPanel === "library" &&
674-
!fullPage &&
675-
!avatarCollectionOverlayEnabled
676-
) {
677-
return shell(pickerHeaderNode, avatarLibraryPickerNode);
678-
}
679-
680601
const avatarNode = (
681602
<section>
682603
<button
@@ -824,35 +745,7 @@ export function AgentBuilderRail({
824745
</>
825746
);
826747

827-
const fullPageLeftColumn =
828-
avatarPanel === "library" && !avatarCollectionOverlayEnabled ? (
829-
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-8 py-6 xl:px-12 xl:py-8">
830-
<div className="flex items-center gap-2 text-sm text-foreground">
831-
<Button
832-
type="button"
833-
variant="ghost"
834-
size="icon-xs"
835-
className="-ml-1 shrink-0"
836-
aria-label={t("builderRail.backToForm")}
837-
onClick={() => {
838-
if (selectedCollectionId) {
839-
setSelectedCollectionId(null);
840-
} else {
841-
setAvatarPanel("closed");
842-
}
843-
}}
844-
>
845-
<IconArrowLeft className="size-4" aria-hidden="true" />
846-
</Button>
847-
<h3 className="truncate text-sm font-normal text-foreground">
848-
{selectedCollectionLabel ?? t("builderRail.chooseAvatarTitle")}
849-
</h3>
850-
</div>
851-
{avatarLibraryPickerNode}
852-
</div>
853-
) : (
854-
<div className="flex flex-col">{avatarNode}</div>
855-
);
748+
const fullPageLeftColumn = <div className="flex flex-col">{avatarNode}</div>;
856749

857750
if (fullPage) {
858751
return (

0 commit comments

Comments
 (0)