From 6af6c0d71dcc0145202d3e0a8c67788249d70a5e Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Fri, 16 Oct 2026 11:58:40 +0200 Subject: [PATCH] feat(command-center-ai): animate agent icons only where they help The platform may now deliver a colour agent icon as an animated PNG whose frame 0 is its still, and mark a colour icon imageRendering "pixelated" (tdag-django ADR-033, amended 2026-10-16). AgentIcon gains motion, "still" by default: an animated icon shows frame 0 in lists, the launcher, thread avatars and the application's agent pages, and moves only with motion="animate", which the rail header badge, the connecting stage and the avatar of the working agent pass. Reduced motion (useCommandCenterViewport) always shows frame 0, an animated avatar no longer also pulses, and a pixelated colour icon is drawn with image-rendering: pixelated. The cache makes the still once per delivery URL from the same bytes: dropping the acTL, fcTL and fdAT chunks leaves a static PNG of the default image (src/engine/apng-still.ts), so there is no second request, canvas or decode, and the still object URL is revoked with the icon's. The projection parser reads the optional imageRendering (absent means auto, pixelated only for colour). useAgentIconObjectUrl keeps its signature; the still comes through the new internal useAgentIconImage. ADR 090 amended (section 6, implementation scope, consequences); the UI README, the design-agent-conversation-capabilities skill and the changelog describe the behaviour. No storage key, skill name or path, installer, SDK peer range or route change. Tests: fixtures are the platform sanitizer's own APNG and PNG output. Node 22: command-center-ai npm run check exit 0, npm test 54 files / 403 tests passed (node --test 15 pass), npm run build exit 0; repo npm run check exit 0. Co-Authored-By: Claude Opus 5.5 --- command-center-ai/CHANGELOG.md | 22 ++++-- .../SKILL.md | 5 +- ...-agent-icons-in-command-center-surfaces.md | 38 ++++++++++- .../command-center-agent-icons-api.test.ts | 18 ++++- .../backend/command-center-agent-icons-api.ts | 10 ++- .../src/engine/agent-icon-cache.test.ts | 51 ++++++++++++++ .../src/engine/agent-icon-cache.ts | 43 +++++++++--- .../src/engine/agent-icons-context.tsx | 14 +++- command-center-ai/src/engine/apng-still.ts | 52 ++++++++++++++ .../src/ui/AgentConnectingState.tsx | 7 +- .../src/ui/AgentIcon.client.test.tsx | 68 ++++++++++++++++++- command-center-ai/src/ui/AgentIcon.tsx | 18 ++++- command-center-ai/src/ui/ChatRail.tsx | 1 + command-center-ai/src/ui/ChatThread.tsx | 1 + command-center-ai/src/ui/README.md | 3 +- command-center-ai/styles.css | 5 ++ 16 files changed, 328 insertions(+), 28 deletions(-) create mode 100644 command-center-ai/src/engine/apng-still.ts diff --git a/command-center-ai/CHANGELOG.md b/command-center-ai/CHANGELOG.md index 7bff7b13..3d3fa297 100644 --- a/command-center-ai/CHANGELOG.md +++ b/command-center-ai/CHANGELOG.md @@ -3,11 +3,15 @@ ## Unreleased Compatibility axes: additive npm API on `ChatRailProps` (`resizable`, `width`, `defaultWidth`, -`minWidth`, `maxWidth`, `onWidthChange`); additive stylesheet classes -`ms-chat-rail__resize-handle` and `ms-chat-rail--resizing`; and the content and description of the -packaged skill `compose-command-center-ai-rail`. No browser storage key or value, skill name or -path, installer behavior, SDK peer range, platform route, or payload changes. No backend action or -storage migration is required: sizing is presentation state and the application owns persistence. +`minWidth`, `maxWidth`, `onWidthChange`), on `AgentIcon` (`motion`) and on +`CommandCenterAgentIcon` (optional `imageRendering`); additive stylesheet classes +`ms-chat-rail__resize-handle` and `ms-chat-rail--resizing` and a rule that stops `ms-chat-pulse` +on an animated agent icon; the read of the platform's additive `commandCenterIcon.imageRendering` +(absent means `auto`); and the content of the packaged skills `compose-command-center-ai-rail` +(also its description) and `design-agent-conversation-capabilities`. No browser storage key or +value, skill name or path, installer behavior, SDK peer range, or platform route changes. No +backend action or storage migration is required: sizing is presentation state and the application +owns persistence, and an icon the platform does not animate or mark pixelated renders as before. - **People can resize the AI right rail.** Drag its left edge or focus "Resize chat rail" and use Left/Right, Shift+Left/Right, Home, or End. Double-click restores its default width. It starts at @@ -18,6 +22,14 @@ storage migration is required: sizing is presentation state and the application fixed overlay. The rail guide, UI README, packaged skill, consumer fixture, and standalone demo describe or exercise this API. The demo retains width across closing and expanding, docks from 1400px, and floats below it. +- **Animated agent icons move only where they help.** A colour agent icon may be an animated PNG + (the platform's ADR-033, amended 2026-10-16). `AgentIcon` shows its first frame by default, in + lists, the launcher, thread avatars and the application's agent pages, and animates it only with + `motion="animate"`, which the rail header, the connecting stage and the avatar of the working + agent pass. Reduced motion always shows the first frame, and an animated avatar does not also + pulse. The first frame is made once from the same bytes by dropping the APNG animation chunks, so + nothing is fetched or decoded twice. A colour icon the platform marks `imageRendering: + "pixelated"` keeps sharp pixels when scaled. ## 0.0.13 diff --git a/command-center-ai/agent_scaffold/skills/contracts/design-agent-conversation-capabilities/SKILL.md b/command-center-ai/agent_scaffold/skills/contracts/design-agent-conversation-capabilities/SKILL.md index 43fed602..a64d0a59 100644 --- a/command-center-ai/agent_scaffold/skills/contracts/design-agent-conversation-capabilities/SKILL.md +++ b/command-center-ai/agent_scaffold/skills/contracts/design-agent-conversation-capabilities/SKILL.md @@ -71,7 +71,10 @@ answers so the text and the tool calls tell the story on their own. Agent's with its icon, and labels actors when several take part. - Agent names are shown as the platform stores them; the thread does not shorten or rewrite them. - An Agent's icon comes from the platform's agent icon projection for the Environment, as a - theme-following mask or a colour image, with a robot as the fallback. During a live turn the avatar + theme-following mask or a colour image, with a robot as the fallback. A colour icon may be an + animated pixel-art sprite: it moves only in the rail header, the connecting stage and while its + Agent works, shows its first frame everywhere else and under reduced motion, and keeps sharp + pixels when the platform marks it pixelated. During a live turn the avatar is the robot until the next reload; that is reported to the package's owner. ## The Session Around The Answers diff --git a/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md b/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md index 63b00bf6..ff98b2c3 100644 --- a/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md +++ b/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md @@ -3,6 +3,8 @@ - Status: Accepted - Date: 2026-09-14 - Amended: 2026-09-16 for the unified Agent shortcut +- Amended: 2026-10-16 for animated and pixel-art icons (section 6), following the platform's + ADR-033 amendment of the same day - Moved: 2026-09-23 into the chat package with its code (ADR 096, step 4); the code paths below are the package's - Related: @@ -44,7 +46,8 @@ bytes itself and render a local object URL. This ADR does not change the backend one projection per active Organization Environment and trusts it for five minutes, shared by every provider on the page; a failed read is retried once. Until ADR 096 moved the engine into the package this was a TanStack query with the same staleness and no refetch on focus. It exposes -`useAgentIconLookup()`: agent uid in, `{ rendering, url }` or null out, and `useAgentIconAuth()`: +`useAgentIconLookup()`: agent uid in, `{ rendering, imageRendering, url }` or null out +(`imageRendering` is `auto` unless the platform sends `pixelated` for a colour icon), and `useAgentIconAuth()`: the connection and the token the icon requests use. Without a provider (tests, surfaces outside the chat provider) the lookup returns null and everything falls back. A failed projection never blocks a surface. @@ -102,6 +105,28 @@ navigation entry are not agents and do not change. - The configured shortcut Agent keeps whatever icon it has on the platform; Command Center adds no shortcut-specific default. +### 6. Animated and pixel-art icons + +Amended 2026-10-16. The platform may now deliver a colour icon as an animated PNG (APNG) whose +frame 0 is its still, and may mark a colour icon `imageRendering: "pixelated"`. + +- **Still unless asked.** `AgentIcon` takes `motion`, `"still"` by default. An animated icon + animates only with `motion="animate"`, which three places pass: the rail header badge, the + connecting stage, and the thread avatar of the agent that is working. Every other place, + including the application's own agent surfaces, shows frame 0. Lists and repeated avatars are + where many icons would move at once and keep the page from ever going idle. +- **Reduced motion.** When the person asks for reduced motion + (`useCommandCenterViewport().reducedMotion`), every place shows frame 0. +- **The still comes from the same bytes.** The cache makes it once per delivery URL: an APNG is a + PNG plus `acTL`, `fcTL` and `fdAT` chunks, and dropping them leaves a static PNG of the default + image, which the platform guarantees is frame 0 (`src/engine/apng-still.ts`). No second request, + canvas or decoding; the still object URL lives and is revoked with the icon's. The platform's + projection and delivery are unchanged. +- **One motion at a time.** An animated avatar is marked `data-agent-icon-motion="animated"` and + the stylesheet stops the working pulse (`ms-chat-pulse`) on it. +- **Pixel art stays sharp.** A colour icon marked `pixelated` is drawn with CSS + `image-rendering: pixelated`. Mask icons are always smooth. + ## Non-goals - Uploading or editing icons from the Command Center; the repository workflow owns that. @@ -117,7 +142,11 @@ navigation entry are not agents and do not change. de-duplication, revalidation with and without an ETag, error handling and clearing. - `src/engine/agent-icons-context.tsx`: `AgentIconsProvider`, `AgentIconsContext`, `AgentIconAuthContext`, `useAgentIconLookup`, `useAgentIconAuth`. -- `src/ui/AgentIcon.tsx`, exported by the package; tested for mask, colour, fallback and failure. +- `src/ui/AgentIcon.tsx`, exported by the package; tested for mask, colour, fallback and failure, + and since 2026-10-16 for the still default, `motion="animate"`, reduced motion and pixelated + scaling. +- `src/engine/apng-still.ts`: frame 0 of an APNG as a static PNG; tested with the platform's own + sanitizer output beside the cache tests. - `ChatEngineProvider` mounts the provider and exposes `activeAgentUid`; the package's `ChatThread.tsx` (`ActorAvatar`, the connecting stage prop) and `AgentConnectingState.tsx` use the component, and so do the application's own agent surfaces in section 4. @@ -128,7 +157,10 @@ navigation entry are not agents and do not change. - One projection request per environment every five minutes of use, one bytes request per distinct icon per page load, and a cheap revalidation per icon every five minutes. -- Object URLs live for the page; at most 512 KiB per icon. +- Object URLs live for the page; at most 512 KiB per icon, plus a still of at most the same size + for an animated one. +- Animated icons cost nothing where they are shown still; only the few single places that animate + keep the page drawing frames. - Should the platform later add a version or digest to the projection, the cache can key on it and skip revalidation; nothing here depends on that. diff --git a/command-center-ai/src/backend/command-center-agent-icons-api.test.ts b/command-center-ai/src/backend/command-center-agent-icons-api.test.ts index f7f766ca..6c09f3dd 100644 --- a/command-center-ai/src/backend/command-center-agent-icons-api.test.ts +++ b/command-center-ai/src/backend/command-center-agent-icons-api.test.ts @@ -30,10 +30,26 @@ describe("command center agent icons api", () => { "junk", ]); expect(Array.from(icons.keys())).toEqual(["a1", "a5"]); - expect(icons.get("a1")).toEqual({ agentUid: "a1", rendering: "mask", url: "https://api.test/icon/a1/" }); + expect(icons.get("a1")).toEqual({ + agentUid: "a1", + rendering: "mask", + imageRendering: "auto", + url: "https://api.test/icon/a1/", + }); expect(icons.get("a5")?.rendering).toBe("color"); expect(normalizeCommandCenterAgentIcons({ not: "an array" }).size).toBe(0); }); + + it("keeps pixelated scaling only for colour icons", () => { + const icons = normalizeCommandCenterAgentIcons([ + { agentUid: "pixel", commandCenterIcon: { rendering: "color", imageRendering: "pixelated", url: "https://api.test/p/" } }, + { agentUid: "mask", commandCenterIcon: { rendering: "mask", imageRendering: "pixelated", url: "https://api.test/m/" } }, + { agentUid: "odd", commandCenterIcon: { rendering: "color", imageRendering: "crisp", url: "https://api.test/o/" } }, + ]); + expect(icons.get("pixel")?.imageRendering).toBe("pixelated"); + expect(icons.get("mask")?.imageRendering).toBe("auto"); + expect(icons.get("odd")?.imageRendering).toBe("auto"); + }); }); describe("command center agent icon bytes", () => { diff --git a/command-center-ai/src/backend/command-center-agent-icons-api.ts b/command-center-ai/src/backend/command-center-agent-icons-api.ts index ca1710e1..c7a53b35 100644 --- a/command-center-ai/src/backend/command-center-agent-icons-api.ts +++ b/command-center-ai/src/backend/command-center-agent-icons-api.ts @@ -15,10 +15,14 @@ import { requestPlatform } from "./platform-request.js"; * fetched with the authenticated client and rendered from an object URL. */ export type CommandCenterAgentIconRendering = "mask" | "color"; +/** How a color icon is scaled: smoothly, or with sharp pixels for pixel art. */ +export type CommandCenterAgentIconImageRendering = "auto" | "pixelated"; export interface CommandCenterAgentIcon { agentUid: string; rendering: CommandCenterAgentIconRendering; + /** `pixelated` only for color icons; absent or `auto` scales smoothly. */ + imageRendering?: CommandCenterAgentIconImageRendering; url: string; } @@ -58,7 +62,11 @@ export function normalizeCommandCenterAgentIcons(payload: unknown): Map character.charCodeAt(0)); +const STATIC_PNG = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAPElEQVR42mNgGAWjYBSMdMBISMEHEY3/lFgg8OYGXjuYBjoERh0w6oBRB4w6YNQBow4YdcAoGAWjYBQAAHGoBCArKbGwAAAAAElFTkSuQmCC"), (character) => character.charCodeAt(0)); + +function pngChunkTypes(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const types: string[] = []; + for (let offset = 8; offset + 12 <= bytes.length; offset += 12 + view.getUint32(offset)) { + types.push(String.fromCharCode(...bytes.subarray(offset + 4, offset + 8))); + } + return types; +} + const connection = createChatBackendConnection({ apiBaseUrl: "http://localhost:8000" }); const url = "https://api.test/api/v1/command-center/agents/a1/icon/"; @@ -123,4 +137,41 @@ describe("agent icon cache", () => { expect(revokeObjectURL).toHaveBeenCalledWith("blob:test/1"); expect(getAgentIconCacheEntry(url)).toBeUndefined(); }); + + it("keeps a still of frame 0 for an animated PNG and revokes it with the icon", async () => { + fetchMock.mockResolvedValueOnce(new Response(APNG, { status: 200, headers: { "Content-Type": "image/png" } })); + ensureAgentIcon({ connection, url, token: "jwt" }); + await vi.waitFor(() => expect(getAgentIconCacheEntry(url)?.status).toBe("ready")); + expect(getAgentIconCacheEntry(url)).toMatchObject({ objectUrl: "blob:test/1", stillObjectUrl: "blob:test/2" }); + const still = createObjectURL.mock.calls[1]?.[0]; + expect(still?.type).toBe("image/png"); + expect(pngChunkTypes(new Uint8Array(await still!.arrayBuffer()))).toEqual(["IHDR", "IDAT", "IEND"]); + + clearAgentIconCache(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test/1"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test/2"); + }); + + it("makes no still for a static icon", async () => { + fetchMock.mockResolvedValueOnce(new Response(STATIC_PNG, { status: 200, headers: { "Content-Type": "image/png" } })); + ensureAgentIcon({ connection, url, token: "jwt" }); + await vi.waitFor(() => expect(getAgentIconCacheEntry(url)?.status).toBe("ready")); + expect(getAgentIconCacheEntry(url)?.stillObjectUrl).toBeNull(); + expect(createObjectURL).toHaveBeenCalledTimes(1); + }); +}); + +describe("apngStill", () => { + it("drops the animation chunks and keeps frame 0 byte for byte", () => { + expect(pngChunkTypes(APNG)).toEqual(["IHDR", "acTL", "fcTL", "IDAT", "fcTL", "fdAT", "IEND"]); + const still = apngStill(APNG)!; + expect(pngChunkTypes(still)).toEqual(["IHDR", "IDAT", "IEND"]); + expect(Array.from(still.subarray(0, 8))).toEqual(Array.from(APNG.subarray(0, 8))); + }); + + it("returns null for a static PNG, other bytes, and a truncated APNG", () => { + expect(apngStill(STATIC_PNG)).toBeNull(); + expect(apngStill(new TextEncoder().encode(""))).toBeNull(); + expect(apngStill(APNG.subarray(0, 80))).toBeNull(); + }); }); diff --git a/command-center-ai/src/engine/agent-icon-cache.ts b/command-center-ai/src/engine/agent-icon-cache.ts index d83739c6..fa5c9549 100644 --- a/command-center-ai/src/engine/agent-icon-cache.ts +++ b/command-center-ai/src/engine/agent-icon-cache.ts @@ -1,6 +1,7 @@ import { fetchCommandCenterAgentIconBytes } from "../backend/command-center-agent-icons-api.js"; import type { ChatBackendConnection } from "../backend/connection.js"; +import { apngStill } from "./apng-still.js"; /** * Bytes of agent icons, shared by every avatar on the page (ADR 090). @@ -11,6 +12,9 @@ import type { ChatBackendConnection } from "../backend/connection.js"; * AGENT_ICON_REVALIDATE_MS: the browser sends If-None-Match, and the object URL * is replaced only when the bytes' fingerprint (ETag when exposed, otherwise a * SHA-256 of the bytes) differs. Sign-out clears everything. + * + * An animated PNG also gets a still: a static PNG of its frame 0, made once from the same bytes, + * for the places that should not move (ADR 090, amended 2026-10-16). */ export const AGENT_ICON_REVALIDATE_MS = 5 * 60_000; export const AGENT_ICON_RETRY_MS = 60_000; @@ -18,6 +22,8 @@ export const AGENT_ICON_RETRY_MS = 60_000; export interface AgentIconCacheEntry { status: "loading" | "ready" | "error"; objectUrl: string | null; + /** A static PNG of frame 0 when the icon is an animated PNG; null otherwise. */ + stillObjectUrl: string | null; fingerprint: string | null; checkedAt: number; } @@ -64,6 +70,27 @@ function revokeObjectUrl(objectUrl: string) { } } +async function createStillObjectUrl(blob: Blob) { + if (blob.type && blob.type !== "image/png") { + return null; + } + try { + const still = apngStill(new Uint8Array(await blob.arrayBuffer())); + return still ? createObjectUrl(new Blob([still], { type: "image/png" })) : null; + } catch { + return null; + } +} + +function revokeEntryUrls(entry: AgentIconCacheEntry) { + if (entry.objectUrl) { + revokeObjectUrl(entry.objectUrl); + } + if (entry.stillObjectUrl) { + revokeObjectUrl(entry.stillObjectUrl); + } +} + async function fingerprintBlob(blob: Blob, etag: string | null) { if (etag) { return etag; @@ -104,7 +131,7 @@ export function ensureAgentIcon({ return; } if (!existing) { - entries.set(url, { status: "loading", objectUrl: null, fingerprint: null, checkedAt: now }); + entries.set(url, { status: "loading", objectUrl: null, stillObjectUrl: null, fingerprint: null, checkedAt: now }); emit(); } const request = fetchCommandCenterAgentIconBytes({ connection, url, token, tokenType }) @@ -118,12 +145,14 @@ export function ensureAgentIcon({ return; } const objectUrl = createObjectUrl(blob); - if (current?.objectUrl) { - revokeObjectUrl(current.objectUrl); + const stillObjectUrl = objectUrl ? await createStillObjectUrl(blob) : null; + if (current) { + revokeEntryUrls(current); } entries.set(url, { status: objectUrl ? "ready" : "error", objectUrl, + stillObjectUrl, fingerprint: objectUrl ? fingerprint : null, checkedAt: Date.now(), }); @@ -137,7 +166,7 @@ export function ensureAgentIcon({ url, current?.objectUrl ? { ...current, status: "ready", checkedAt: Date.now() } - : { status: "error", objectUrl: null, fingerprint: null, checkedAt: Date.now() }, + : { status: "error", objectUrl: null, stillObjectUrl: null, fingerprint: null, checkedAt: Date.now() }, ); emit(); }) @@ -148,11 +177,7 @@ export function ensureAgentIcon({ } export function clearAgentIconCache() { - entries.forEach((entry) => { - if (entry.objectUrl) { - revokeObjectUrl(entry.objectUrl); - } - }); + entries.forEach(revokeEntryUrls); entries.clear(); inFlight.clear(); emit(); diff --git a/command-center-ai/src/engine/agent-icons-context.tsx b/command-center-ai/src/engine/agent-icons-context.tsx index 9b3fa10b..4f7b5f6d 100644 --- a/command-center-ai/src/engine/agent-icons-context.tsx +++ b/command-center-ai/src/engine/agent-icons-context.tsx @@ -163,6 +163,14 @@ const noEntry = () => undefined; /** The object URL for an icon, or null while it loads, without auth, or after it failed. */ export function useAgentIconObjectUrl(url: string | null | undefined, auth: AgentIconAuth | null) { + return useAgentIconImage(url, auth).objectUrl; +} + +/** + * The icon's object URL and, for an animated PNG, the object URL of its frame 0 still; both null + * while it loads, without auth, or after it failed. + */ +export function useAgentIconImage(url: string | null | undefined, auth: AgentIconAuth | null) { const entry = useSyncExternalStore( subscribeAgentIconCache, () => getAgentIconCacheEntry(url), @@ -176,5 +184,9 @@ export function useAgentIconObjectUrl(url: string | null | undefined, auth: Agen ensureAgentIcon({ connection, url, token, tokenType }); } }, [connection, token, tokenType, url]); - return entry?.status === "ready" ? entry.objectUrl : null; + const ready = entry?.status === "ready"; + return { + objectUrl: ready ? entry.objectUrl : null, + stillObjectUrl: ready ? entry.stillObjectUrl : null, + }; } diff --git a/command-center-ai/src/engine/apng-still.ts b/command-center-ai/src/engine/apng-still.ts new file mode 100644 index 00000000..248cd185 --- /dev/null +++ b/command-center-ai/src/engine/apng-still.ts @@ -0,0 +1,52 @@ +/** + * The still of an animated agent icon (ADR 090, amended 2026-10-16). + * + * The platform stores an animated color icon as an APNG whose default image is its frame 0. An + * APNG is a PNG plus `acTL`, `fcTL` and `fdAT` chunks, so dropping those chunks leaves a valid + * static PNG of that default image. The other chunks keep their bytes and CRCs. No canvas or + * decoding is involved, so the result is exact and works wherever bytes do. + */ +const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; +const ANIMATION_CHUNKS = new Set(["acTL", "fcTL", "fdAT"]); + +function chunkType(bytes: Uint8Array, offset: number) { + return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); +} + +/** A static PNG of the APNG's default image, or null when the bytes are not an APNG. */ +export function apngStill(bytes: Uint8Array): Uint8Array | null { + if (bytes.length < PNG_SIGNATURE.length || PNG_SIGNATURE.some((value, index) => bytes[index] !== value)) { + return null; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const kept: Uint8Array[] = [bytes.subarray(0, PNG_SIGNATURE.length)]; + let animated = false; + let offset = PNG_SIGNATURE.length; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset); + const end = offset + 12 + length; + if (end > bytes.length) { + return null; + } + const type = chunkType(bytes, offset + 4); + if (ANIMATION_CHUNKS.has(type)) { + animated ||= type === "acTL"; + } else { + kept.push(bytes.subarray(offset, end)); + } + offset = end; + if (type === "IEND") { + break; + } + } + if (!animated) { + return null; + } + const still = new Uint8Array(kept.reduce((total, part) => total + part.length, 0)); + let position = 0; + for (const part of kept) { + still.set(part, position); + position += part.length; + } + return still; +} diff --git a/command-center-ai/src/ui/AgentConnectingState.tsx b/command-center-ai/src/ui/AgentConnectingState.tsx index 96cd2749..3d8664a8 100644 --- a/command-center-ai/src/ui/AgentConnectingState.tsx +++ b/command-center-ai/src/ui/AgentConnectingState.tsx @@ -152,7 +152,12 @@ export function AgentConnectingState({ data-active-step={activeStep} >
- } /> + } + motion="animate" + />
Connecting to {resolvedAgentName} diff --git a/command-center-ai/src/ui/AgentIcon.client.test.tsx b/command-center-ai/src/ui/AgentIcon.client.test.tsx index b90b1963..4483045a 100644 --- a/command-center-ai/src/ui/AgentIcon.client.test.tsx +++ b/command-center-ai/src/ui/AgentIcon.client.test.tsx @@ -17,9 +17,18 @@ const auth = { const { AgentIcon } = await import("./AgentIcon.js"); +// A 32 px two-frame APNG from the platform's sanitizer. +const APNG = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAgAAAAIAAAAAAAAAAAAGQD6AAAcyZw9wAAAD1JREFUeJxjYBgFo2AUjHTASEjBBxGN/5RYIPDmBl47mCgxnBpg1AGjDhh1wKgDRh0w6oBRB4yCUTAKRgEAcagEIFRAp3wAAAAaZmNUTAAAAAEAAAAQAAAAEAAAAAgAAAAIAGQD6AAAmBjZzgAAACFmZEFUAAAAAnicYxTR+PCfgQLARInmUQNGDRg1YDAZAAANFwJLQfgfzgAAAABJRU5ErkJggg=="), (character) => character.charCodeAt(0)); + const icons: Record = { masked: { agentUid: "masked", rendering: "mask", url: "https://api.test/agents/masked/icon/" }, colored: { agentUid: "colored", rendering: "color", url: "https://api.test/agents/colored/icon/" }, + animated: { + agentUid: "animated", + rendering: "color", + imageRendering: "pixelated", + url: "https://api.test/agents/animated/icon/", + }, }; const lookup: AgentIconLookup = (uid) => (uid ? icons[uid] ?? null : null); @@ -52,9 +61,9 @@ describe("AgentIcon", () => { clearAgentIconCache(); vi.unstubAllGlobals(); }); - function render(agentUid: string | null, withProvider = true) { + function render(agentUid: string | null, withProvider = true, motion?: "still" | "animate") { const element = ( - } /> + } motion={motion} /> ); act(() => { root.render( @@ -100,4 +109,59 @@ describe("AgentIcon", () => { expect(container.querySelector("[data-fallback]")).not.toBeNull(); expect(container.querySelector("[data-agent-icon]")).toBeNull(); }); + + describe("an animated pixel-art icon", () => { + let objectUrls = 0; + beforeEach(() => { + objectUrls = 0; + fetchMock.mockImplementation(async () => + new Response(APNG, { status: 200, headers: { ETag: '"apng"', "Content-Type": "image/png" } }), + ); + // The first object URL is the animation, the second its frame 0 still. + Object.assign(URL, { createObjectURL: () => `blob:test/${(objectUrls += 1) === 1 ? "animated" : "still"}` }); + }); + + function image() { + return container.querySelector('img[data-agent-icon="color"]'); + } + + it("shows its frame 0 still by default, sharp", async () => { + render("animated"); + await vi.waitFor(() => expect(image()).not.toBeNull()); + expect(image()?.src).toBe("blob:test/still"); + expect(image()?.dataset.agentIconMotion).toBe("still"); + expect(image()?.style.imageRendering).toBe("pixelated"); + }); + + it("animates where asked", async () => { + render("animated", true, "animate"); + await vi.waitFor(() => expect(image()).not.toBeNull()); + expect(image()?.src).toBe("blob:test/animated"); + expect(image()?.dataset.agentIconMotion).toBe("animated"); + }); + + it("stays still when the person asks for reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + (query: string) => + ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList, + ); + render("animated", true, "animate"); + await vi.waitFor(() => expect(image()).not.toBeNull()); + expect(image()?.src).toBe("blob:test/still"); + }); + }); + + it("leaves a static colour icon unmarked and smooth", async () => { + render("colored", true, "animate"); + await vi.waitFor(() => expect(container.querySelector('img[data-agent-icon="color"]')).not.toBeNull()); + const image = container.querySelector("img")!; + expect(image.dataset.agentIconMotion).toBeUndefined(); + expect(image.style.imageRendering).toBe(""); + }); }); diff --git a/command-center-ai/src/ui/AgentIcon.tsx b/command-center-ai/src/ui/AgentIcon.tsx index 8e1724bc..7646e5ec 100644 --- a/command-center-ai/src/ui/AgentIcon.tsx +++ b/command-center-ai/src/ui/AgentIcon.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from "react"; +import { useCommandCenterViewport } from "@dev-mainsequence/command-center-sdk/layout"; import { useAgentIconAuth, + useAgentIconImage, useAgentIconLookup, - useAgentIconObjectUrl, } from "../engine/agent-icons-context.js"; import { cx } from "./class-names.js"; @@ -16,20 +17,28 @@ import { cx } from "./class-names.js"; * * It reads the icon projection and the credentials from the chat engine's icon * provider, so it draws the built-in icon outside the engine. + * + * An animated `color` icon shows its frame 0 still unless `motion="animate"`, which only the + * large single places pass (the rail header, the connecting stage, the agent that is working), + * and always shows the still when the person asks for reduced motion. `imageRendering: + * "pixelated"` keeps pixel art sharp when it is scaled. */ export function AgentIcon({ agentUid, className, fallback, + motion = "still", }: { agentUid?: string | null; className?: string; fallback: ReactNode; + motion?: "still" | "animate"; }) { const lookup = useAgentIconLookup(); const icon = lookup(agentUid); const auth = useAgentIconAuth(); - const objectUrl = useAgentIconObjectUrl(icon?.url ?? null, auth); + const { objectUrl, stillObjectUrl } = useAgentIconImage(icon?.url ?? null, auth); + const { reducedMotion } = useCommandCenterViewport(); if (!icon || !objectUrl) { return <>{fallback}; } @@ -53,14 +62,17 @@ export function AgentIcon({ /> ); } + const animates = motion === "animate" && !reducedMotion; return ( ); } diff --git a/command-center-ai/src/ui/ChatRail.tsx b/command-center-ai/src/ui/ChatRail.tsx index abd32e0b..0da8ee2d 100644 --- a/command-center-ai/src/ui/ChatRail.tsx +++ b/command-center-ai/src/ui/ChatRail.tsx @@ -147,6 +147,7 @@ export function ChatRail({ } /> diff --git a/command-center-ai/src/ui/ChatThread.tsx b/command-center-ai/src/ui/ChatThread.tsx index ae17135c..812c6ebb 100644 --- a/command-center-ai/src/ui/ChatThread.tsx +++ b/command-center-ai/src/ui/ChatThread.tsx @@ -650,6 +650,7 @@ function ActorAvatar({