diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 8182d18cd..8bfd69ded 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,6 +25,7 @@ const rules = [ const overrides = new Set([ "src/features/settings/ui/ProfileSettingsCard.tsx:584", "src/features/onboarding/ui/AvatarStep.tsx:89", + "src/features/agents/ui/AgentCreationPreview.tsx:711", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 020db363a..c9b1cd68d 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -6,6 +6,7 @@ import { } from "@/features/agents/agentReuse"; export { findReusableAgent } from "@/features/agents/agentReuse"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { resolveManagedAgentAvatarUrl } from "@/features/agents/ui/managedAgentAvatar"; import { addChannelMembers, createManagedAgent, @@ -13,7 +14,6 @@ import { listManagedAgents, startManagedAgent, updateManagedAgent, - uploadMediaBytes, } from "@/shared/api/tauri"; import type { AcpRuntime, @@ -341,21 +341,12 @@ export async function createChannelManagedAgent( } } - // If the avatar is a data URI (e.g. from a persona PNG card import), - // upload it to get a hosted URL the relay can serve. - let resolvedAvatarUrl = input.avatarUrl?.trim() || undefined; - if (resolvedAvatarUrl?.startsWith("data:image/")) { - try { - const [, b64] = resolvedAvatarUrl.split(",", 2); - if (!b64) throw new Error("empty data URI payload"); - const bytes = Array.from(atob(b64), (c) => c.charCodeAt(0)); - const blob = await uploadMediaBytes(bytes); - resolvedAvatarUrl = blob.url; - } catch (err) { - console.warn("Avatar upload failed, proceeding without avatar:", err); - resolvedAvatarUrl = undefined; - } - } + // Resolve the avatar for the channel-managed agent. Base64 data URIs (e.g. + // from a persona PNG card import) are uploaded to a hosted URL the relay can + // serve; percent-encoded emoji SVG data URLs pass through unchanged so the + // selected emoji survives deployment. Shared with agent creation so both + // paths handle emoji avatars identically. + const resolvedAvatarUrl = await resolveManagedAgentAvatarUrl(input.avatarUrl); const isProviderMode = input.backend?.type === "provider"; diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 6446a18a4..441cb9367 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -1,14 +1,43 @@ +import emojiData from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; import * as React from "react"; -import { Pencil, Plus } from "lucide-react"; +import { Link2, Pencil, Plus, UploadCloud } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + AVATAR_COLORS, + AVATAR_COLOR_SWATCHES, + CUSTOM_AVATAR_COLOR_SWATCH, + DEFAULT_CUSTOM_HUE, + DEFAULT_CUSTOM_SATURATION, + DEFAULT_CUSTOM_VALUE, + DEFAULT_EMOJI_AVATAR_COLOR, + EMOJI_MART_CATEGORIES, + type AvatarColorSwatch, + contrastColorForBackground, + emojiAvatarDataUrl, + hexToHsv, + hsvToHex, + normalizeHue, + parseEmojiAvatarDataUrl, + useEmojiMartStyles, + useEmojiMartThemeVars, +} from "@/features/profile/ui/ProfileAvatarEditor.utils"; +import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel"; import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; function isAvatarFileDrag(event: React.DragEvent) { return Array.from(event.dataTransfer.types).includes("Files"); @@ -19,6 +48,12 @@ const AVATAR_APPLY_MOTION_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +type AvatarTab = "image" | "emoji"; + +type EmojiMartEmoji = { + native?: string; +}; + export function AgentCreationPreview({ avatarUrl, disabled = false, @@ -34,14 +69,34 @@ export function AgentCreationPreview({ onUploadPendingChange?: (isPending: boolean) => void; onSelectAvatar: (avatarUrl: string) => void; }) { - const avatarEditClipId = React.useId().replace(/:/g, ""); const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); - const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] = + const [activeTab, setActiveTab] = React.useState("image"); + const [selectedEmoji, setSelectedEmoji] = React.useState(null); + const [selectedColor, setSelectedColor] = React.useState( + DEFAULT_EMOJI_AVATAR_COLOR, + ); + // Whether the user has explicitly picked a color swatch (vs. the default). + // The color grid is always visible, so a user can choose a background color + // before their first emoji — in that case the first emoji must honor the + // chosen color instead of a random one. + const [hasChosenColor, setHasChosenColor] = React.useState(false); + const [customHue, setCustomHue] = React.useState(DEFAULT_CUSTOM_HUE); + const [customSaturation, setCustomSaturation] = React.useState( + DEFAULT_CUSTOM_SATURATION, + ); + const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE); + const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = React.useState(false); + const [isPopoverDragOver, setIsPopoverDragOver] = React.useState(false); + const [squishKey, setSquishKey] = React.useState(0); const avatarDragDepthRef = React.useRef(0); + const popoverDragDepthRef = React.useRef(0); const shouldReduceMotion = useReducedMotion(); + const emojiPickerContainerRef = React.useRef(null); + const emojiMartThemeVars = useEmojiMartThemeVars(); + const { burstEmoji } = useEmojiBurst(); const { inputRef: avatarUploadInputRef, isUploading, @@ -51,9 +106,22 @@ export function AgentCreationPreview({ uploadFile: uploadAvatarFile, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ - onUploadSuccess: onSelectAvatar, + onUploadSuccess: (url) => { + onSelectAvatar(url); + setIsAvatarMenuOpen(false); + }, }); + useEmojiMartStyles( + emojiPickerContainerRef, + isAvatarMenuOpen && activeTab === "emoji", + ); + + const customColorDraft = React.useMemo( + () => hsvToHex(customHue, customSaturation, customValue), + [customHue, customSaturation, customValue], + ); + React.useEffect(() => { onUploadPendingChange?.(isUploading); return () => { @@ -61,12 +129,48 @@ export function AgentCreationPreview({ }; }, [isUploading, onUploadPendingChange]); + // Sync emoji state from avatarUrl when the popover opens React.useEffect(() => { if (isAvatarMenuOpen) { setAvatarUrlDraft(""); - setIsAvatarUrlInputFocused(false); + setIsPopoverDragOver(false); + popoverDragDepthRef.current = 0; + + const parsed = parseEmojiAvatarDataUrl(avatarUrl ?? ""); + if (parsed) { + setSelectedEmoji(parsed.emoji); + setSelectedColor(parsed.color); + setHasChosenColor(true); + setActiveTab("emoji"); + } else { + // Non-emoji avatar (image/URL or empty): clear any stale emoji + // selection so a later color-swatch tap can't re-apply an old emoji + // over the current avatar. + setSelectedEmoji(null); + setSelectedColor(DEFAULT_EMOJI_AVATAR_COLOR); + setHasChosenColor(false); + setActiveTab("image"); + } + } + }, [isAvatarMenuOpen, avatarUrl]); + + // Keep the custom color picker in sync when the selected color changes + React.useEffect(() => { + if (!isCustomColorPickerOpen || !selectedEmoji) { + return; + } + const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft); + if (avatarUrl === nextAvatarUrl) { + return; } - }, [isAvatarMenuOpen]); + onSelectAvatar(nextAvatarUrl); + }, [ + avatarUrl, + customColorDraft, + isCustomColorPickerOpen, + onSelectAvatar, + selectedEmoji, + ]); function applyAvatarUrl() { const nextUrl = avatarUrlDraft.trim(); @@ -78,22 +182,59 @@ export function AgentCreationPreview({ setIsAvatarMenuOpen(false); } - const avatarClipStyle = React.useMemo( - () => ({ - clipPath: `url(#${avatarEditClipId})`, - transform: "translateZ(0)", - }), - [avatarEditClipId], - ); - const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0; + function applyEmojiAvatar(emoji: string, color = selectedColor) { + onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + setSquishKey((key) => key + 1); + } + + function handleColorSelect(swatch: AvatarColorSwatch) { + if (disabled) { + return; + } + if (swatch === CUSTOM_AVATAR_COLOR_SWATCH) { + if (!selectedEmoji) { + return; + } + openCustomColorPicker(); + return; + } + setSelectedColor(swatch); + setHasChosenColor(true); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, swatch); + } + } + + function openCustomColorPicker() { + const nextColor = hexToHsv(selectedColor); + setCustomHue(normalizeHue(nextColor.hue)); + setCustomSaturation(nextColor.saturation); + setCustomValue(nextColor.value); + setIsCustomColorPickerOpen(true); + } + + function commitCustomColor() { + setSelectedColor(customColorDraft); + setHasChosenColor(true); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, customColorDraft); + } + setIsCustomColorPickerOpen(false); + } + const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0; + const emojiAvatarPreview = React.useMemo( + () => parseEmojiAvatarDataUrl(avatarUrl ?? ""), + [avatarUrl], + ); const applyButtonTransition = shouldReduceMotion ? { duration: 0 } : AVATAR_APPLY_MOTION_TRANSITION; + // Outer avatar drag — only active when popover is closed const handleAvatarDragEnter = React.useCallback( (event: React.DragEvent) => { - if (disabled || !isAvatarFileDrag(event)) { + if (disabled || isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -102,12 +243,12 @@ export function AgentCreationPreview({ event.dataTransfer.dropEffect = "copy"; setIsDragOverAvatar(true); }, - [disabled], + [disabled, isAvatarMenuOpen], ); const handleAvatarDragOver = React.useCallback( (event: React.DragEvent) => { - if (disabled || !isAvatarFileDrag(event)) { + if (disabled || isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -115,12 +256,12 @@ export function AgentCreationPreview({ event.dataTransfer.dropEffect = "copy"; setIsDragOverAvatar(true); }, - [disabled], + [disabled, isAvatarMenuOpen], ); const handleAvatarDragLeave = React.useCallback( (event: React.DragEvent) => { - if (!isAvatarFileDrag(event)) { + if (isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -130,12 +271,12 @@ export function AgentCreationPreview({ setIsDragOverAvatar(false); } }, - [], + [isAvatarMenuOpen], ); const handleAvatarDrop = React.useCallback( (event: React.DragEvent) => { - if (!isAvatarFileDrag(event)) { + if (isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -151,88 +292,355 @@ export function AgentCreationPreview({ clearUploadError(); void uploadAvatarFile(file); }, + [ + clearUploadError, + disabled, + isAvatarMenuOpen, + isUploading, + uploadAvatarFile, + ], + ); + + // Popover-level drag — one big drop zone for the entire popover + const handlePopoverDragEnter = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current += 1; + event.dataTransfer.dropEffect = "copy"; + setIsPopoverDragOver(true); + setActiveTab("image"); + }, + [disabled], + ); + + const handlePopoverDragOver = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }, + [disabled], + ); + + const handlePopoverDragLeave = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current = Math.max( + 0, + popoverDragDepthRef.current - 1, + ); + if (popoverDragDepthRef.current === 0) { + setIsPopoverDragOver(false); + } + }, + [], + ); + + const handlePopoverDrop = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current = 0; + setIsPopoverDragOver(false); + + const file = event.dataTransfer.files[0]; + if (!file || disabled || isUploading) { + return; + } + + clearUploadError(); + void uploadAvatarFile(file); + }, [clearUploadError, disabled, isUploading, uploadAvatarFile], ); + const isCustomColorPickerVisible = + isCustomColorPickerOpen && selectedEmoji !== null; + const avatarMenuContent = ( - - -
{ - event.preventDefault(); - event.stopPropagation(); - applyAvatarUrl(); - }} + + {/* Single drop zone covering the entire popover */} +
- - setIsAvatarUrlInputFocused(false)} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onFocus={() => setIsAvatarUrlInputFocused(true)} - placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"} - spellCheck={false} - value={avatarUrlDraft} - /> - - {hasAvatarUrlDraft ? ( - { + setActiveTab(tab as AvatarTab); + setIsCustomColorPickerOpen(false); + }} + value={activeTab} + > + + + ) : null} + + {activeTab === "emoji" ? ( +
+
+ { + if (disabled) { + return; + } + if (!emoji.native) { + return; + } + const nextColor = + selectedEmoji === null && !hasChosenColor + ? (AVATAR_COLORS[ + Math.floor(Math.random() * AVATAR_COLORS.length) + ] ?? DEFAULT_EMOJI_AVATAR_COLOR) + : selectedColor; + burstEmoji(emoji.native, event); + setSelectedEmoji(emoji.native); + setSelectedColor(nextColor); + applyEmojiAvatar(emoji.native, nextColor); + }} + previewPosition="none" + searchPosition="none" + set="native" + skinTonePosition="none" + theme="auto" + /> +
+ + {/* Color swatches — always visible */} +
+ {AVATAR_COLOR_SWATCHES.map((swatch) => { + const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH; + const isSelected = isCustomSwatch + ? !AVATAR_COLORS.some( + (color) => + color.toUpperCase() === selectedColor.toUpperCase(), + ) + : swatch.toUpperCase() === selectedColor.toUpperCase(); + + return ( + + ); + })} +
+ + { + setCustomSaturation(nextSaturation); + setCustomValue(nextValue); + }} + saturation={customSaturation} + testIdPrefix="agent-avatar" + value={customValue} + visible={isCustomColorPickerVisible} + /> + + {hasAvatar && onClearAvatar ? ( + + ) : null} +
+ ) : null} +
); @@ -243,6 +651,7 @@ export function AgentCreationPreview({ className={cn( "group/avatar-preview relative m-0 flex min-h-[190px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-transparent p-0 transition-[background-color,border-color,box-shadow] duration-150", isDragOverAvatar && + !isAvatarMenuOpen && "border-dashed border-primary/70 bg-primary/5 ring-2 ring-primary/15", )} onDragEnter={handleAvatarDragEnter} @@ -258,94 +667,103 @@ export function AgentCreationPreview({ type="file" /> -
- {hasAvatar ? ( - <> - - -
- -
- -
- + +
+ {hasAvatar ? ( + + + + } + badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} + className="h-36 w-36" + cutout={{ cx: 123, cy: 123, r: 24 }} + size={144} > - -
+ ) : ( + - - {avatarMenuContent} -
-
- - ) : ( - - - - - {avatarMenuContent} - - )} -
+ + ) : ( + + + + )} + + + {avatarMenuContent} + {uploadErrorMessage ? (

diff --git a/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs b/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs index 11f8ad36e..4495872d0 100644 --- a/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs +++ b/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs @@ -21,6 +21,16 @@ test("resolveManagedAgentAvatarUrl uploads data image URIs", async () => { assert.equal(uploaded, "https://relay.example/avatar.png"); }); +test("resolveManagedAgentAvatarUrl passes emoji svg data URLs through", async () => { + const emojiUrl = + "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E"; + const uploaded = await resolveManagedAgentAvatarUrl(emojiUrl, async () => { + throw new Error("should not upload inline emoji svg data URLs"); + }); + + assert.equal(uploaded, emojiUrl); +}); + test("resolveManagedAgentAvatarUrl passes non-data URLs through", async () => { const uploaded = await resolveManagedAgentAvatarUrl( " https://relay.example/already-hosted.png ", diff --git a/desktop/src/features/agents/ui/managedAgentAvatar.ts b/desktop/src/features/agents/ui/managedAgentAvatar.ts index f699b5707..971cf0764 100644 --- a/desktop/src/features/agents/ui/managedAgentAvatar.ts +++ b/desktop/src/features/agents/ui/managedAgentAvatar.ts @@ -21,6 +21,14 @@ export async function resolveManagedAgentAvatarUrl( return resolvedAvatarUrl; } + // Emoji avatars are stored as inline, percent-encoded SVG data URLs + // (`data:image/svg+xml,%3C...`) — the same self-contained form profile + // persists. They are not base64 and must not be run through `atob`/upload; + // pass them through unchanged so the emoji survives agent creation. + if (!isBase64DataUri(resolvedAvatarUrl)) { + return resolvedAvatarUrl; + } + try { const [, b64] = resolvedAvatarUrl.split(",", 2); if (!b64) { @@ -39,6 +47,11 @@ async function defaultUploadMediaBytes(data: number[], filename?: string) { return uploadMediaBytes(data, filename); } +function isBase64DataUri(dataUri: string) { + const header = dataUri.slice(0, dataUri.indexOf(",")); + return header.includes(";base64"); +} + function safeFallbackAvatarUrl(avatarUrl: string | null | undefined) { const trimmed = avatarUrl?.trim() || undefined; return trimmed?.startsWith("data:image/") ? undefined : trimmed; diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts index 65acde7b1..b7e506f61 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts @@ -430,6 +430,86 @@ export function dataTransferHasImage(dataTransfer: DataTransfer | null) { ); } +function normalizedWheelDeltaY(event: WheelEvent, scrollElement: HTMLElement) { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + return event.deltaY * 16; + } + + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return event.deltaY * scrollElement.clientHeight; + } + + return event.deltaY; +} + +function clampScrollTop(scrollElement: HTMLElement, scrollTop: number) { + const maxScrollTop = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight, + ); + return Math.max(0, Math.min(maxScrollTop, scrollTop)); +} + +// Wheel events are retargeted to the emoji-mart host outside its shadow root, +// so the browser does not reliably apply the default scroll to `.scroll`. +function installEmojiMartWheelScroll(shadowRoot: ShadowRoot) { + let removeWheelListener: (() => void) | null = null; + let currentScrollElement: HTMLElement | null = null; + + function connectScrollElement() { + const nextScrollElement = shadowRoot.querySelector(".scroll"); + + if (nextScrollElement === currentScrollElement) { + return; + } + + removeWheelListener?.(); + currentScrollElement = nextScrollElement; + + if (!nextScrollElement) { + removeWheelListener = null; + return; + } + + const handleWheel = (event: WheelEvent) => { + if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) { + return; + } + + const deltaY = normalizedWheelDeltaY(event, nextScrollElement); + const nextScrollTop = clampScrollTop( + nextScrollElement, + nextScrollElement.scrollTop + deltaY, + ); + + if (nextScrollTop === nextScrollElement.scrollTop) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + nextScrollElement.scrollTop = nextScrollTop; + }; + + nextScrollElement.addEventListener("wheel", handleWheel, { + passive: false, + }); + removeWheelListener = () => { + nextScrollElement.removeEventListener("wheel", handleWheel); + }; + } + + connectScrollElement(); + + const observer = new MutationObserver(connectScrollElement); + observer.observe(shadowRoot, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + removeWheelListener?.(); + }; +} + export function useEmojiMartStyles( containerRef: React.RefObject, enabled: boolean, @@ -440,6 +520,7 @@ export function useEmojiMartStyles( } let animationFrame = 0; + let removeWheelScroll: (() => void) | null = null; const installEmojiMartStyles = () => { const host = containerRef.current?.querySelector("em-emoji-picker"); @@ -456,12 +537,15 @@ export function useEmojiMartStyles( style.textContent = EMOJI_MART_SHADOW_CSS; shadowRoot.appendChild(style); } + + removeWheelScroll ??= installEmojiMartWheelScroll(shadowRoot); }; animationFrame = window.requestAnimationFrame(installEmojiMartStyles); return () => { window.cancelAnimationFrame(animationFrame); + removeWheelScroll?.(); }; }, [containerRef, enabled]); } diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 35303470c..bf704f57f 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -230,6 +230,49 @@ test("built-in personas are used from the catalog dialog", async ({ page }) => { await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); +test("agent avatar emoji picker scrolls inside its popover", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "New agent" }).click(); + + await expect(page.getByTestId("persona-dialog")).toBeVisible(); + await page.getByLabel("Add avatar").click(); + await page.getByRole("tab", { name: "Emoji" }).click(); + await expect(page.locator("em-emoji-picker")).toBeVisible(); + + await page.waitForFunction(() => { + const picker = document.querySelector("em-emoji-picker"); + const scroll = picker?.shadowRoot?.querySelector(".scroll"); + return ( + scroll instanceof HTMLElement && scroll.scrollHeight > scroll.clientHeight + ); + }); + + const before = await page.locator("em-emoji-picker").evaluate((picker) => { + const scroll = picker.shadowRoot?.querySelector(".scroll"); + return scroll instanceof HTMLElement ? scroll.scrollTop : -1; + }); + + const box = await page.locator("em-emoji-picker").boundingBox(); + if (!box) { + throw new Error("Could not measure emoji picker bounds."); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 500); + + await expect + .poll(async () => + page.locator("em-emoji-picker").evaluate((picker) => { + const scroll = picker.shadowRoot?.querySelector(".scroll"); + return scroll instanceof HTMLElement ? scroll.scrollTop : -1; + }), + ) + .toBeGreaterThan(before); +}); + test("agent catalog can reopen from the populated library header", async ({ page, }) => {