diff --git a/src-tauri/src/commands/avatars.rs b/src-tauri/src/commands/avatars.rs index 51c5454e0..1c8379e4e 100644 --- a/src-tauri/src/commands/avatars.rs +++ b/src-tauri/src/commands/avatars.rs @@ -22,6 +22,7 @@ const USER_AVATAR_REF_PREFIX: &str = "user-avatar:"; const USER_AVATAR_CATALOG_VERSION: &str = "user-generated"; const USER_AVATAR_COLLECTION_ID: &str = "generated-gloopies"; const AVATAR_CACHE_WARMED_EVENT: &str = "berd:avatar-cache-warmed"; +const USER_AVATAR_LIBRARY_CHANGED_EVENT: &str = "berd:user-avatar-library-changed"; const LATEST_PATH: &str = "latest.json"; const MANIFEST_FILE: &str = "manifest.json"; const AVATAR_REFRESH_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); @@ -361,7 +362,14 @@ pub async fn get_avatar_library_snapshot( // Reading cached collections only inspects atomically-placed files, so it // does not need the catalog lock. - let cached_collections = cached_collections_for_catalog(&paths, &catalog)?; + let mut cached_collections = cached_collections_for_catalog(&paths, &catalog)?; + // User-generated gloopies are local library citizens: they ride the same + // snapshot so the picker and the collection gallery see one source of + // truth, stamped with the user catalog version instead of the published + // catalog's version. + if let Some(user_collection) = user_avatar_cached_collection(&app) { + cached_collections.push(user_collection); + } let (media_refreshing, media_refresh_completed, media_error_code) = avatar_refresh_status().lock().unwrap().snapshot(); Ok(AvatarLibrarySnapshot { @@ -1695,7 +1703,11 @@ pub(crate) fn write_user_avatar_with_poster( ) -> Result { let id = format!("gloopie-{}", Uuid::new_v4()); let paths = user_avatar_paths(app)?; - write_user_avatar_at(&paths, &id, bytes, mime_type, alpha_mode, poster) + let avatar_ref = write_user_avatar_at(&paths, &id, bytes, mime_type, alpha_mode, poster)?; + if let Err(error) = app.emit(USER_AVATAR_LIBRARY_CHANGED_EVENT, ()) { + log::warn!("Failed to emit user avatar library change event: {error}"); + } + Ok(avatar_ref) } fn write_user_avatar_at( @@ -1771,6 +1783,75 @@ fn rollback_user_avatar_files(paths: &[&Path]) { } } +/// Enumerates every user-generated gloopie on this machine as a cached +/// collection, so the avatar library snapshot can surface them alongside the +/// bundled catalog. Enumeration is best-effort: a corrupt or half-written +/// manifest skips that avatar instead of hiding the whole collection. A +/// machine with no gloopies yields an empty collection; browsing surfaces omit +/// it entirely. +fn user_avatar_cached_collection(app: &AppHandle) -> Option { + let paths = user_avatar_paths(app).ok()?; + user_avatar_cached_collection_at(&paths) +} + +fn user_avatar_cached_collection_at(paths: &UserAvatarPaths) -> Option { + let mut manifests: Vec = Vec::new(); + if paths.meta.exists() { + let entries = fs::read_dir(&paths.meta).ok()?; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(avatar_id) = file_name + .to_str() + .and_then(|name| name.strip_suffix(".json")) + else { + continue; + }; + let Ok(manifest) = read_user_avatar_manifest(paths, avatar_id) else { + log::warn!("Skipping unreadable user avatar manifest: {avatar_id}"); + continue; + }; + let Ok(media_path) = user_avatar_media_path(paths, &manifest) else { + continue; + }; + if media_path.exists() { + manifests.push(manifest); + } + } + } + // Newest first, so the collection reads as a timeline of what the user + // made most recently; the id tiebreak keeps the order deterministic. + manifests.sort_by(|left, right| { + right + .created_at_ms + .cmp(&left.created_at_ms) + .then_with(|| left.id.cmp(&right.id)) + }); + let assets = manifests + .into_iter() + .map(|manifest| CachedAvatarAsset { + path: paths + .media + .join(&manifest.path) + .to_string_lossy() + .into_owned(), + poster_path: manifest + .poster_path + .as_deref() + .map(|poster| paths.media.join(poster).to_string_lossy().into_owned()), + id: manifest.id, + mime_type: manifest.mime_type, + alpha_mode: manifest.alpha_mode, + }) + .collect(); + Some(CachedAvatarCollection { + catalog_version: USER_AVATAR_CATALOG_VERSION.to_string(), + collection_id: USER_AVATAR_COLLECTION_ID.to_string(), + assets, + failed_asset_ids: Vec::new(), + error_code: None, + }) +} + fn cached_user_avatar_for_id( app: &AppHandle, avatar_id: &str, @@ -2653,6 +2734,78 @@ mod tests { ); } + #[test] + fn user_avatar_collection_lists_gloopies_newest_first_and_skips_broken_entries() { + let (_dir, paths) = temp_user_avatar_paths(); + + // Older avatar. + seed_user_avatar(&paths, "gloopie-old"); + // Newer avatar with a poster and stacked alpha. + let newer_media = paths.media.join("gloopie-new.webm"); + fs::write(&newer_media, b"webm-bytes").unwrap(); + let poster = paths.media.join("gloopie-new.poster.png"); + fs::write(&poster, b"poster").unwrap(); + let manifest = UserAvatarManifest { + id: "gloopie-new".to_string(), + path: "gloopie-new.webm".to_string(), + mime_type: "video/webm".to_string(), + alpha_mode: Some("stacked".to_string()), + poster_path: Some("gloopie-new.poster.png".to_string()), + byte_size: 10, + created_at_ms: 5, + }; + fs::write( + paths.meta.join("gloopie-new.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + // Manifest without media on disk: excluded. + let manifest = UserAvatarManifest { + id: "gloopie-missing".to_string(), + path: "gloopie-missing.png".to_string(), + mime_type: "image/png".to_string(), + alpha_mode: None, + poster_path: None, + byte_size: 1, + created_at_ms: 9, + }; + fs::write( + paths.meta.join("gloopie-missing.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + // Corrupt manifest: skipped, does not fail the collection. + fs::write(paths.meta.join("gloopie-corrupt.json"), b"not json").unwrap(); + + let collection = user_avatar_cached_collection_at(&paths).unwrap(); + + assert_eq!(collection.collection_id, USER_AVATAR_COLLECTION_ID); + assert_eq!(collection.catalog_version, USER_AVATAR_CATALOG_VERSION); + assert!(collection.failed_asset_ids.is_empty()); + let ids: Vec<&str> = collection + .assets + .iter() + .map(|asset| asset.id.as_str()) + .collect(); + assert_eq!(ids, vec!["gloopie-new", "gloopie-old"]); + let newest = &collection.assets[0]; + assert_eq!(newest.mime_type, "video/webm"); + assert_eq!(newest.alpha_mode.as_deref(), Some("stacked")); + assert!(newest + .poster_path + .as_deref() + .unwrap() + .ends_with("gloopie-new.poster.png")); + } + + #[test] + fn user_avatar_collection_is_empty_when_no_gloopies_exist() { + let (_dir, paths) = temp_user_avatar_paths(); + let collection = user_avatar_cached_collection_at(&paths).unwrap(); + assert!(collection.assets.is_empty()); + assert!(collection.failed_asset_ids.is_empty()); + } + #[test] fn delete_user_avatar_removes_media_and_manifest() { let (_dir, paths) = temp_user_avatar_paths(); diff --git a/src/features/agents/hooks/__tests__/useAvatarLibrary.test.tsx b/src/features/agents/hooks/__tests__/useAvatarLibrary.test.tsx index c88d0ab08..764946b4c 100644 --- a/src/features/agents/hooks/__tests__/useAvatarLibrary.test.tsx +++ b/src/features/agents/hooks/__tests__/useAvatarLibrary.test.tsx @@ -5,7 +5,8 @@ import type { CachedAvatarCollection, } from "@/shared/avatars/catalog"; -const listeners: Array<() => void> = []; +const cacheWarmedListeners: Array<() => void> = []; +const userLibraryChangedListeners: Array<() => void> = []; vi.mock("@/shared/api/avatars", async () => { const actual = await vi.importActual( "@/shared/api/avatars", @@ -15,7 +16,11 @@ vi.mock("@/shared/api/avatars", async () => { getAvatarLibrarySnapshot: vi.fn(), refreshAvatarCache: vi.fn(), listenAvatarCacheWarmed: vi.fn(async (handler: () => void) => { - listeners.push(handler); + cacheWarmedListeners.push(handler); + return vi.fn(); + }), + listenUserAvatarLibraryChanged: vi.fn(async (handler: () => void) => { + userLibraryChangedListeners.push(handler); return vi.fn(); }), cachedAssetToMedia: (asset: { path: string; mimeType: string }) => ({ @@ -29,6 +34,7 @@ vi.mock("@/shared/api/avatars", async () => { import { getAvatarLibrarySnapshot, + listenUserAvatarLibraryChanged, refreshAvatarCache, } from "@/shared/api/avatars"; import { useAvatarLibrary } from "../useAvatarLibrary"; @@ -73,7 +79,8 @@ function cachedCollection(path: string): CachedAvatarCollection { describe("useAvatarLibrary", () => { beforeEach(() => { - listeners.length = 0; + cacheWarmedListeners.length = 0; + userLibraryChangedListeners.length = 0; vi.clearAllMocks(); vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ catalog, @@ -95,7 +102,7 @@ describe("useAvatarLibrary", () => { }); it("keeps missing first-run media in progress while the initial refresh runs", async () => { - vi.mocked(getAvatarLibrarySnapshot).mockResolvedValueOnce({ + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ catalog, cachedCollections: [], mediaRefreshing: true, @@ -109,7 +116,7 @@ describe("useAvatarLibrary", () => { }); it("surfaces missing media after a failed refresh and retries", async () => { - vi.mocked(getAvatarLibrarySnapshot).mockResolvedValueOnce({ + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ catalog, cachedCollections: [], mediaRefreshing: false, @@ -119,6 +126,12 @@ describe("useAvatarLibrary", () => { const { result } = renderHook(() => useAvatarLibrary(true)); await waitFor(() => expect(result.current.mediaError).toBe(true)); + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ + catalog, + cachedCollections: [cachedCollection("/cache/a-1.webm")], + mediaRefreshing: false, + mediaRefreshCompleted: true, + }); act(() => result.current.retryMedia()); await waitFor(() => expect(refreshAvatarCache).toHaveBeenCalledOnce()); @@ -126,7 +139,7 @@ describe("useAvatarLibrary", () => { }); it("surfaces an incomplete poster fallback after a failed refresh", async () => { - vi.mocked(getAvatarLibrarySnapshot).mockResolvedValueOnce({ + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ catalog, cachedCollections: [ { @@ -151,7 +164,7 @@ describe("useAvatarLibrary", () => { }); it("surfaces a failed manual media refresh", async () => { - vi.mocked(getAvatarLibrarySnapshot).mockResolvedValueOnce({ + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ catalog, cachedCollections: [], mediaRefreshing: false, @@ -182,7 +195,7 @@ describe("useAvatarLibrary", () => { mediaRefreshing: false, mediaRefreshCompleted: true, }); - act(() => listeners[0]?.()); + act(() => cacheWarmedListeners[0]?.()); await waitFor(() => expect(result.current.cachedAvatarMediaById["a-1"].media.src).toBe( @@ -190,4 +203,85 @@ describe("useAvatarLibrary", () => { ), ); }); + + it("reconciles after delayed listener registration", async () => { + let finishRegistration!: () => void; + vi.mocked(listenUserAvatarLibraryChanged).mockImplementationOnce( + async (handler: () => void) => { + userLibraryChangedListeners.push(handler); + await new Promise((resolve) => { + finishRegistration = resolve; + }); + return () => {}; + }, + ); + const { result } = renderHook(() => useAvatarLibrary(true)); + await waitFor(() => expect(result.current.catalog).toEqual(catalog)); + + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ + catalog, + cachedCollections: [ + cachedCollection("/cache/a-1.webm"), + { + catalogVersion: "user-generated", + collectionId: "generated-gloopies", + assets: [ + { + id: "gloopie-during-registration", + path: "/user-avatars/gloopie-during-registration.mp4", + mimeType: "video/mp4", + alphaMode: "stacked", + }, + ], + failedAssetIds: [], + }, + ], + mediaRefreshing: false, + mediaRefreshCompleted: true, + }); + act(() => finishRegistration()); + + await waitFor(() => + expect(result.current.userAvatarIds).toEqual([ + "gloopie-during-registration", + ]), + ); + }); + + it("adds a newly created gloopie after the user library change event", async () => { + const { result } = renderHook(() => useAvatarLibrary(true)); + await waitFor(() => expect(result.current.catalog).toEqual(catalog)); + + vi.mocked(getAvatarLibrarySnapshot).mockResolvedValue({ + catalog, + cachedCollections: [ + cachedCollection("/cache/a-1.webm"), + { + catalogVersion: "user-generated", + collectionId: "generated-gloopies", + assets: [ + { + id: "gloopie-new", + path: "/user-avatars/gloopie-new.mp4", + mimeType: "video/mp4", + alphaMode: "stacked", + }, + ], + failedAssetIds: [], + }, + ], + mediaRefreshing: false, + mediaRefreshCompleted: true, + }); + act(() => userLibraryChangedListeners[0]?.()); + + await waitFor(() => + expect(result.current.userAvatarIds).toEqual(["gloopie-new"]), + ); + expect(result.current.userAvatarMediaById["gloopie-new"]).toMatchObject({ + src: "/user-avatars/gloopie-new.mp4", + mediaType: "video", + }); + expect(result.current.cachedAvatarMediaById["gloopie-new"]).toBeUndefined(); + }); }); diff --git a/src/features/agents/hooks/__tests__/usePersonas.test.ts b/src/features/agents/hooks/__tests__/usePersonas.test.ts index 5ec0fa248..4b81a875f 100644 --- a/src/features/agents/hooks/__tests__/usePersonas.test.ts +++ b/src/features/agents/hooks/__tests__/usePersonas.test.ts @@ -220,7 +220,7 @@ describe("usePersonas", () => { ).toBe("Updated"); }); - it("reclaims a replaced user avatar after the final reference changes", async () => { + it("preserves a replaced gloopie in the library after its final agent reference changes", async () => { const existing = makePersona({ id: "test-id", avatar: "user-avatar:shared", @@ -251,12 +251,10 @@ describe("usePersonas", () => { await act(async () => { await result.current.updatePersona(shared, { avatar: null }); }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:shared", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); - it("reclaims avatars displaced by overlapping updates", async () => { + it("preserves gloopies displaced by overlapping updates", async () => { const existing = makePersona({ id: "test-id", avatar: "user-avatar:a" }); vi.mocked(api.listPersonas).mockResolvedValueOnce([existing]); const first = makePersona({ id: "test-id", avatar: "user-avatar:b" }); @@ -290,12 +288,7 @@ describe("usePersonas", () => { await updateTwo; }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:a", - ); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:b", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); it("deletePersona calls API and removes from store", async () => { @@ -320,7 +313,7 @@ describe("usePersonas", () => { ).toBeUndefined(); }); - it("reclaims a deleted user avatar only after its final reference", async () => { + it("preserves a gloopie in the library after its final agent is deleted", async () => { const first = makePersona({ id: "first", avatar: "user-avatar:shared", @@ -341,9 +334,7 @@ describe("usePersonas", () => { await act(async () => { await result.current.deletePersona("second"); }); - expect(avatarApiMocks.deleteUserAvatar).toHaveBeenCalledWith( - "user-avatar:shared", - ); + expect(avatarApiMocks.deleteUserAvatar).not.toHaveBeenCalled(); }); }); diff --git a/src/features/agents/hooks/useAvatarLibrary.ts b/src/features/agents/hooks/useAvatarLibrary.ts index ab0b719b6..191c320f5 100644 --- a/src/features/agents/hooks/useAvatarLibrary.ts +++ b/src/features/agents/hooks/useAvatarLibrary.ts @@ -3,14 +3,17 @@ import { cachedAssetToMedia, getAvatarLibrarySnapshot, listenAvatarCacheWarmed, + listenUserAvatarLibraryChanged, normalizeAvatarLibraryError, refreshAvatarCache, type AvatarLibraryErrorCode, } from "@/shared/api/avatars"; -import type { - AvatarCatalog, - CachedAvatarCollection, - ResolvedAvatarMedia, +import { + USER_AVATAR_CATALOG_VERSION, + USER_AVATAR_COLLECTION_ID, + type AvatarCatalog, + type CachedAvatarCollection, + type ResolvedAvatarMedia, } from "@/shared/avatars/catalog"; interface CachedAvatarMediaEntry { @@ -20,6 +23,14 @@ interface CachedAvatarMediaEntry { export interface AvatarLibraryState { catalog: AvatarCatalog | null; + /** + * Ids of the user's generated gloopies (newest first). They are local + * library citizens prepended to the published Gloopies collection and + * persisted on agents as `user-avatar:`. Their media remains separate + * from the bundled cache so the two durable ref namespaces cannot collide. + */ + userAvatarIds: string[]; + userAvatarMediaById: Record; cachedAvatarMediaById: Record; loading: boolean; cacheChecking: boolean; @@ -37,12 +48,15 @@ function cachedMediaForCatalog( ): Record { const mediaById: Record = {}; for (const collection of collections) { - if (collection.catalogVersion !== catalogVersion) { + if ( + collection.collectionId === USER_AVATAR_COLLECTION_ID || + collection.catalogVersion !== catalogVersion + ) { continue; } for (const asset of collection.assets) { mediaById[asset.id] = { - catalogVersion, + catalogVersion: collection.catalogVersion, media: cachedAssetToMedia(asset), }; } @@ -50,6 +64,33 @@ function cachedMediaForCatalog( return mediaById; } +function userAvatarMediaForCollections( + collections: CachedAvatarCollection[], +): Record { + const mediaById: Record = {}; + const collection = collections.find( + (candidate) => + candidate.collectionId === USER_AVATAR_COLLECTION_ID && + candidate.catalogVersion === USER_AVATAR_CATALOG_VERSION, + ); + for (const asset of collection?.assets ?? []) { + mediaById[asset.id] = cachedAssetToMedia(asset); + } + return mediaById; +} + +function userAvatarIdsForCollections( + collections: CachedAvatarCollection[], +): string[] { + return ( + collections + .find( + (collection) => collection.collectionId === USER_AVATAR_COLLECTION_ID, + ) + ?.assets.map((asset) => asset.id) ?? [] + ); +} + export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { const [catalog, setCatalog] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -66,6 +107,10 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { const [cachedAvatarMediaById, setCachedAvatarMediaById] = useState< Record >({}); + const [userAvatarIds, setUserAvatarIds] = useState([]); + const [userAvatarMediaById, setUserAvatarMediaById] = useState< + Record + >({}); useEffect(() => { if (!enabled) { @@ -73,15 +118,41 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { } let cancelled = false; - const unlistenPromise = listenAvatarCacheWarmed(() => { + const unlisteners: Array<() => void> = []; + const reload = () => { if (!cancelled) { setReloadToken((value) => value + 1); } + }; + + void Promise.allSettled([ + listenAvatarCacheWarmed(reload), + listenUserAvatarLibraryChanged(reload), + ]).then((registrations) => { + for (const registration of registrations) { + if (registration.status === "rejected") { + console.warn( + "Failed to subscribe to avatar library changes:", + registration.reason, + ); + continue; + } + if (cancelled) { + registration.value(); + } else { + unlisteners.push(registration.value); + } + } + // Close the snapshot/subscription gap: mutations emitted before listener + // registration completed are recovered by one post-subscribe read. + reload(); }); return () => { cancelled = true; - void unlistenPromise.then((unlisten) => unlisten()); + for (const unlisten of unlisteners) { + unlisten(); + } }; }, [enabled]); @@ -105,6 +176,12 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { ); setCatalog(snapshot.catalog); setCachedAvatarMediaById(cachedMedia); + setUserAvatarIds( + userAvatarIdsForCollections(snapshot.cachedCollections), + ); + setUserAvatarMediaById( + userAvatarMediaForCollections(snapshot.cachedCollections), + ); setBackendMediaRefreshing(snapshot.mediaRefreshing); const hasIncompleteMedia = snapshot.cachedCollections.some( (collection) => collection.failedAssetIds.length > 0, @@ -167,6 +244,8 @@ export function useAvatarLibrary(enabled: boolean): AvatarLibraryState { return { catalog, + userAvatarIds, + userAvatarMediaById, cachedAvatarMediaById, loading, cacheChecking: loading || mediaRefreshing || backendMediaRefreshing, diff --git a/src/features/agents/hooks/usePersonas.ts b/src/features/agents/hooks/usePersonas.ts index 393096959..7ec2b3a72 100644 --- a/src/features/agents/hooks/usePersonas.ts +++ b/src/features/agents/hooks/usePersonas.ts @@ -10,23 +10,9 @@ import type { Persona, } from "@/shared/types/agents"; import * as api from "@/shared/api/agents"; -import { deleteUserAvatar } from "@/shared/api/avatars"; -import { isUserAvatarRef } from "@/shared/avatars/catalog"; const REFRESH_INTERVAL_MS = 60_000; -function deleteUnreferencedUserAvatar(avatar: string | null | undefined) { - if (!avatar || !isUserAvatarRef(avatar)) return; - const stillReferenced = useAgentStore - .getState() - .personas.some((persona) => persona.avatar === avatar); - if (!stillReferenced) { - void deleteUserAvatar(avatar).catch((error) => { - console.warn("Failed to clean up unreferenced agent avatar:", error); - }); - } -} - export function usePersonas() { const personas = useAgentStore(selectPersonas); const personasLoading = useAgentStore(selectPersonasLoading); @@ -130,18 +116,17 @@ export function usePersonas() { [addPersona, trackMutation], ); + // Custom gloopies are library citizens, not per-agent attachments: a + // displaced or orphaned `user-avatar:` stays in the Gloopies collection + // so any agent can wear it again. Library-level delete is a deliberate later + // feature (alongside export), so no reference-count garbage collection + // happens here. const updatePersona = useCallback( async (existing: Persona, req: UpdatePersonaRequest) => { const persona = await trackMutation(() => api.updatePersona(existing, req), ); - const displacedAvatar = useAgentStore - .getState() - .personas.find((candidate) => candidate.id === existing.id)?.avatar; updatePersonaInStore(existing.id, persona); - if (displacedAvatar !== persona.avatar) { - deleteUnreferencedUserAvatar(displacedAvatar); - } return persona; }, [trackMutation, updatePersonaInStore], @@ -149,12 +134,8 @@ export function usePersonas() { const deletePersona = useCallback( async (id: string) => { - const deletedAvatar = useAgentStore - .getState() - .personas.find((persona) => persona.id === id)?.avatar; await trackMutation(() => api.deletePersona(id)); removePersona(id); - deleteUnreferencedUserAvatar(deletedAvatar); }, [removePersona, trackMutation], ); diff --git a/src/features/agents/lib/avatarLibraryView.test.ts b/src/features/agents/lib/avatarLibraryView.test.ts new file mode 100644 index 000000000..55630cb07 --- /dev/null +++ b/src/features/agents/lib/avatarLibraryView.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; +import type { AvatarCatalog } from "@/shared/avatars/catalog"; +import { buildAvatarDisplayCollections } from "./avatarLibraryView"; + +const catalog: AvatarCatalog = { + schemaVersion: 1, + catalogVersion: "v1", + collections: [ + { + id: "gloopies", + label: "Gloopies", + coverAvatarId: "bundled-cover", + avatarIds: ["shared-id", "bundled-cover"], + }, + { + id: "pollies", + label: "Pollies", + coverAvatarId: "pollie-cover", + avatarIds: ["pollie-first", "pollie-cover"], + }, + ], + assets: [ + ["shared-id", "gloopies"], + ["bundled-cover", "gloopies"], + ["pollie-first", "pollies"], + ["pollie-cover", "pollies"], + ].map(([id, collectionId]) => ({ + id, + label: id, + collectionId, + variants: { + webm: { + path: `${id}.webm`, + mimeType: "video/webm", + byteSize: 1, + sha256: id, + }, + hevc: { + path: `${id}.mov`, + mimeType: "video/quicktime", + byteSize: 1, + sha256: id, + }, + }, + })), +}; + +function library( + overrides: Partial = {}, +): AvatarLibraryState { + return { + catalog, + userAvatarIds: [], + userAvatarMediaById: {}, + cachedAvatarMediaById: Object.fromEntries( + catalog.assets.map((asset) => [ + asset.id, + { + catalogVersion: "v1", + media: { src: `bundled:${asset.id}`, mediaType: "video" as const }, + }, + ]), + ), + loading: false, + cacheChecking: false, + error: false, + errorCode: null, + mediaError: false, + mediaErrorCode: null, + retryCatalog: vi.fn(), + retryMedia: vi.fn(), + ...overrides, + }; +} + +describe("buildAvatarDisplayCollections", () => { + it("preserves a catalog-authored cover independently of tile order", () => { + const pollies = buildAvatarDisplayCollections(library()).find( + (collection) => collection.id === "pollies", + ); + + expect(pollies?.entries.map((entry) => entry.ref)).toEqual([ + "app-avatar:pollie-first", + "app-avatar:pollie-cover", + ]); + expect(pollies?.cover?.ref).toBe("app-avatar:pollie-cover"); + }); + + it("uses the newest custom gloopie as cover without collapsing ref namespaces", () => { + const gloopies = buildAvatarDisplayCollections( + library({ + userAvatarIds: ["shared-id"], + userAvatarMediaById: { + "shared-id": { src: "custom:shared-id", mediaType: "video" }, + }, + }), + ).find((collection) => collection.id === "gloopies"); + + expect(gloopies?.cover?.ref).toBe("user-avatar:shared-id"); + expect(gloopies?.entries.slice(0, 2).map((entry) => entry.ref)).toEqual([ + "user-avatar:shared-id", + "app-avatar:shared-id", + ]); + expect( + gloopies?.entries.slice(0, 2).map((entry) => entry.media?.src), + ).toEqual(["custom:shared-id", "bundled:shared-id"]); + }); +}); diff --git a/src/features/agents/lib/avatarLibraryView.ts b/src/features/agents/lib/avatarLibraryView.ts new file mode 100644 index 000000000..7f72855cf --- /dev/null +++ b/src/features/agents/lib/avatarLibraryView.ts @@ -0,0 +1,101 @@ +import type { AvatarLibraryState } from "@/features/agents/hooks/useAvatarLibrary"; +import { + avatarRef, + getAvatarCatalogEntry, + mediaTypeFromMimeType, + userAvatarRef, +} from "@/shared/avatars/catalog"; +import type { + AvatarMediaType, + ResolvedAvatarMedia, +} from "@/shared/avatars/catalog"; + +const GLOOPIES_COLLECTION_ID = "gloopies"; + +/** One selectable avatar in a browsing surface. */ +export interface AvatarDisplayEntry { + id: string; + /** Full persisted ref; also the stable identity across avatar namespaces. */ + ref: string; + label?: string; + media?: ResolvedAvatarMedia; + /** Media type of the source asset even when the media is not cached yet. */ + fallbackMediaType: AvatarMediaType; + /** Custom gloopies have no catalog-authored label. */ + isUserAvatar: boolean; +} + +/** One collection in a browsing surface. */ +export interface AvatarDisplayCollection { + id: string; + label?: string; + /** Cover presentation is independent from the ordered collection entries. */ + cover?: AvatarDisplayEntry; + entries: AvatarDisplayEntry[]; +} + +function bundledEntry( + library: AvatarLibraryState, + avatarId: string, +): AvatarDisplayEntry | undefined { + const entry = getAvatarCatalogEntry(library.catalog, avatarId); + if (!entry) { + return undefined; + } + const cachedMedia = library.cachedAvatarMediaById[avatarId]; + const catalogVersion = library.catalog?.catalogVersion; + const fallbackVariant = entry.variants.webm ?? entry.variants.hevc; + return { + id: entry.id, + ref: avatarRef(entry.id), + label: entry.label, + media: + cachedMedia?.catalogVersion === catalogVersion + ? cachedMedia.media + : undefined, + fallbackMediaType: fallbackVariant + ? mediaTypeFromMimeType(fallbackVariant.mimeType) + : "image", + isUserAvatar: false, + }; +} + +function userGloopieEntries(library: AvatarLibraryState): AvatarDisplayEntry[] { + return library.userAvatarIds.map((avatarId) => ({ + id: avatarId, + ref: userAvatarRef(avatarId), + media: library.userAvatarMediaById[avatarId], + fallbackMediaType: "video", + isUserAvatar: true, + })); +} + +/** + * Builds the single browsing model shared by the inline picker and full-screen + * gallery. Custom gloopies are prepended to the existing Gloopies collection; + * the newest custom gloopie also becomes that collection's cover. Other + * collections retain their catalog-authored cover independently of tile order. + */ +export function buildAvatarDisplayCollections( + library: AvatarLibraryState, +): AvatarDisplayCollection[] { + const customGloopies = userGloopieEntries(library); + + return (library.catalog?.collections ?? []).map((collection) => { + const bundledEntries = collection.avatarIds.flatMap((avatarId) => { + const entry = bundledEntry(library, avatarId); + return entry ? [entry] : []; + }); + const catalogCover = bundledEntry(library, collection.coverAvatarId); + const isGloopies = collection.id === GLOOPIES_COLLECTION_ID; + + return { + id: collection.id, + label: collection.label, + cover: isGloopies ? (customGloopies[0] ?? catalogCover) : catalogCover, + entries: isGloopies + ? [...customGloopies, ...bundledEntries] + : bundledEntries, + }; + }); +} diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index 0da52cb8a..4f982c258 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -14,7 +14,7 @@ import { IconSparkles, IconX, } from "@tabler/icons-react"; -import { avatarRef, parseAvatarRef } from "@/shared/avatars/catalog"; +import { avatarRef, isLibraryAvatarRef } from "@/shared/avatars/catalog"; import { normalizeAvatarUrl } from "@/shared/lib/avatarUrl"; import { cn } from "@/shared/lib/cn"; import type { AgentSourceEntry } from "@/shared/api/agents"; @@ -200,13 +200,16 @@ export function AgentBuilderRail({ const [selectedCollectionId, setSelectedCollectionId] = useState< string | null >(null); - const selectedCollection = useMemo( - () => + const selectedCollectionLabel = useMemo(() => { + if (!selectedCollectionId) { + return null; + } + return ( avatarLibrary.catalog?.collections.find( (collection) => collection.id === selectedCollectionId, - ) ?? null, - [avatarLibrary.catalog, selectedCollectionId], - ); + )?.label ?? null + ); + }, [avatarLibrary.catalog, selectedCollectionId]); const provider = (data?.properties?.provider as string | undefined) ?? ""; const modelProviderId = @@ -228,8 +231,8 @@ export function AgentBuilderRail({ ); const onSelectAvatar = useCallback( - (avatarId: string) => { - writeProperty("avatar", avatarRef(avatarId)); + (selectedAvatarRef: string) => { + writeProperty("avatar", selectedAvatarRef); setSelectedCollectionId(null); setAvatarPanel("closed"); }, @@ -258,11 +261,10 @@ export function AgentBuilderRail({ : null; const effectiveAvatar = normalizedAvatar ?? (defaultAvatarId ? avatarRef(defaultAvatarId) : null); - const selectedAvatarRefValue = effectiveAvatar - ? parseAvatarRef(effectiveAvatar) + const selectedAvatarRefValue = + effectiveAvatar && isLibraryAvatarRef(effectiveAvatar) ? effectiveAvatar - : null - : null; + : null; const selectedAvatarMediaState = useAvatarMediaState(effectiveAvatar); const onChangeProvider = useCallback( @@ -564,9 +566,7 @@ export function AgentBuilderRail({