From 5b0cffc5750b148e1ff9d2524fcf960f0fc3d49d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 17:21:01 +0100 Subject: [PATCH 01/10] feat(desktop): add emoji picker tab to agent avatar creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the simple upload/URL popover in AgentCreationPreview with a tabbed popover containing an 'Image' tab (existing upload + URL flow) and an 'Emoji' tab (emoji-mart picker + color swatches + custom color). Selecting an emoji produces an emojiAvatarDataUrl that flows through the existing avatarUrl state — ProfileAvatar already renders these as SVG data URLs, so no changes needed downstream. Reuses the same emoji-mart config, color palette, custom color panel, and emoji burst animation from ProfileAvatarEditor (user profile). --- .../agents/ui/AgentCreationPreview.tsx | 443 +++++++++++++++--- 1 file changed, 368 insertions(+), 75 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 6446a18a43..327aa3b60f 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -1,14 +1,38 @@ +import emojiData from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; import * as React from "react"; import { Pencil, Plus } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; 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, + emojiAvatarDataUrl, + hexToHsv, + hsvToHex, + normalizeHue, + parseEmojiAvatarDataUrl, + contrastColorForBackground, + 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 { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Input } from "@/shared/ui/input"; import { Popover, 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 +43,12 @@ const AVATAR_APPLY_MOTION_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +type AvatarTab = "upload" | "emoji"; + +type EmojiMartEmoji = { + native?: string; +}; + export function AgentCreationPreview({ avatarUrl, disabled = false, @@ -40,8 +70,23 @@ export function AgentCreationPreview({ const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("upload"); + const [selectedEmoji, setSelectedEmoji] = React.useState(null); + const [selectedColor, setSelectedColor] = React.useState( + DEFAULT_EMOJI_AVATAR_COLOR, + ); + 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 avatarDragDepthRef = React.useRef(0); const shouldReduceMotion = useReducedMotion(); + const emojiPickerContainerRef = React.useRef(null); + const emojiMartThemeVars = useEmojiMartThemeVars(); + const { burstEmoji } = useEmojiBurst(); const { inputRef: avatarUploadInputRef, isUploading, @@ -54,6 +99,13 @@ export function AgentCreationPreview({ onUploadSuccess: onSelectAvatar, }); + useEmojiMartStyles(emojiPickerContainerRef, activeTab === "emoji"); + + const customColorDraft = React.useMemo( + () => hsvToHex(customHue, customSaturation, customValue), + [customHue, customSaturation, customValue], + ); + React.useEffect(() => { onUploadPendingChange?.(isUploading); return () => { @@ -61,12 +113,40 @@ export function AgentCreationPreview({ }; }, [isUploading, onUploadPendingChange]); + // Sync emoji state from avatarUrl when the popover opens React.useEffect(() => { if (isAvatarMenuOpen) { setAvatarUrlDraft(""); setIsAvatarUrlInputFocused(false); + + const parsed = parseEmojiAvatarDataUrl(avatarUrl ?? ""); + if (parsed) { + setSelectedEmoji(parsed.emoji); + setSelectedColor(parsed.color); + setActiveTab("emoji"); + } else { + setActiveTab("upload"); + } + } + }, [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,6 +158,43 @@ export function AgentCreationPreview({ setIsAvatarMenuOpen(false); } + function applyEmojiAvatar(emoji: string, color = selectedColor) { + onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + } + + function handleColorSelect(swatch: AvatarColorSwatch) { + if (disabled) { + return; + } + if (swatch === CUSTOM_AVATAR_COLOR_SWATCH) { + if (!selectedEmoji) { + return; + } + openCustomColorPicker(); + return; + } + setSelectedColor(swatch); + 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); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, customColorDraft); + } + setIsCustomColorPickerOpen(false); + } + const avatarClipStyle = React.useMemo( () => ({ clipPath: `url(#${avatarEditClipId})`, @@ -154,85 +271,261 @@ export function AgentCreationPreview({ [clearUploadError, disabled, isUploading, uploadAvatarFile], ); + const shouldShowColorControls = + activeTab === "emoji" && selectedEmoji !== null; + const isCustomColorPickerVisible = + isCustomColorPickerOpen && shouldShowColorControls; + const avatarMenuContent = ( - - -
{ - event.preventDefault(); - event.stopPropagation(); - applyAvatarUrl(); - }} - > - - setIsAvatarUrlInputFocused(false)} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onFocus={() => setIsAvatarUrlInputFocused(true)} - placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"} - spellCheck={false} - value={avatarUrlDraft} - /> - - {hasAvatarUrlDraft ? ( - + + Image + + + Emoji + + + + + {activeTab === "upload" ? ( +
+ + { + event.preventDefault(); + event.stopPropagation(); + applyAvatarUrl(); + }} + > + + setIsAvatarUrlInputFocused(false)} + onChange={(event) => setAvatarUrlDraft(event.target.value)} + onFocus={() => setIsAvatarUrlInputFocused(true)} + placeholder={ + isAvatarUrlInputFocused ? "https://..." : "Use a URL" + } + spellCheck={false} + value={avatarUrlDraft} + /> + + {hasAvatarUrlDraft ? ( + + + + ) : null} + + + {hasAvatar && onClearAvatar ? ( + - + Remove avatar + + ) : null} +
+ ) : ( +
+
+ { + if (disabled) { + return; + } + if (!emoji.native) { + return; + } + const nextColor = + selectedEmoji === null + ? (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" + /> +
+ +
+
+ {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} - - - {hasAvatar && onClearAvatar ? ( - - ) : null} +
+ )}
); From 83c0e7ae72ef774682fb18fa1ac3d19f24bd80ac Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 20:50:30 +0100 Subject: [PATCH 02/10] feat(desktop): rework agent avatar popover per feedback Image tab: - Replace menu-style buttons with a mini drop/browse zone (click to open file picker, drag to upload) matching the profile editor style - URL input below with Link2 icon, same rounded-lg bg-muted treatment - Upload error shown inline Emoji tab: - Color swatches always visible by default (no progressive disclosure) - Emoji picker height reduced slightly (240px) to keep popover compact - Custom color picker still available via the rainbow swatch --- .../agents/ui/AgentCreationPreview.tsx | 291 +++++++++++------- 1 file changed, 177 insertions(+), 114 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 327aa3b60f..ec0621d91e 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -1,7 +1,7 @@ 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 { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -15,12 +15,12 @@ import { DEFAULT_EMOJI_AVATAR_COLOR, EMOJI_MART_CATEGORIES, type AvatarColorSwatch, + contrastColorForBackground, emojiAvatarDataUrl, hexToHsv, hsvToHex, normalizeHue, parseEmojiAvatarDataUrl, - contrastColorForBackground, useEmojiMartStyles, useEmojiMartThemeVars, } from "@/features/profile/ui/ProfileAvatarEditor.utils"; @@ -29,7 +29,6 @@ import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; -import { Input } from "@/shared/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; @@ -43,7 +42,7 @@ const AVATAR_APPLY_MOTION_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; -type AvatarTab = "upload" | "emoji"; +type AvatarTab = "image" | "emoji"; type EmojiMartEmoji = { native?: string; @@ -68,9 +67,7 @@ export function AgentCreationPreview({ const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); - const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] = - React.useState(false); - const [activeTab, setActiveTab] = React.useState("upload"); + const [activeTab, setActiveTab] = React.useState("image"); const [selectedEmoji, setSelectedEmoji] = React.useState(null); const [selectedColor, setSelectedColor] = React.useState( DEFAULT_EMOJI_AVATAR_COLOR, @@ -82,7 +79,9 @@ export function AgentCreationPreview({ const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE); const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = React.useState(false); + const [isImageDropActive, setIsImageDropActive] = React.useState(false); const avatarDragDepthRef = React.useRef(0); + const imageDragDepthRef = React.useRef(0); const shouldReduceMotion = useReducedMotion(); const emojiPickerContainerRef = React.useRef(null); const emojiMartThemeVars = useEmojiMartThemeVars(); @@ -96,7 +95,10 @@ export function AgentCreationPreview({ uploadFile: uploadAvatarFile, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ - onUploadSuccess: onSelectAvatar, + onUploadSuccess: (url) => { + onSelectAvatar(url); + setIsAvatarMenuOpen(false); + }, }); useEmojiMartStyles(emojiPickerContainerRef, activeTab === "emoji"); @@ -117,7 +119,6 @@ export function AgentCreationPreview({ React.useEffect(() => { if (isAvatarMenuOpen) { setAvatarUrlDraft(""); - setIsAvatarUrlInputFocused(false); const parsed = parseEmojiAvatarDataUrl(avatarUrl ?? ""); if (parsed) { @@ -125,7 +126,7 @@ export function AgentCreationPreview({ setSelectedColor(parsed.color); setActiveTab("emoji"); } else { - setActiveTab("upload"); + setActiveTab("image"); } } }, [isAvatarMenuOpen, avatarUrl]); @@ -202,7 +203,6 @@ export function AgentCreationPreview({ }), [avatarEditClipId], ); - const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0; const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0; const applyButtonTransition = shouldReduceMotion ? { duration: 0 } @@ -271,10 +271,8 @@ export function AgentCreationPreview({ [clearUploadError, disabled, isUploading, uploadAvatarFile], ); - const shouldShowColorControls = - activeTab === "emoji" && selectedEmoji !== null; const isCustomColorPickerVisible = - isCustomColorPickerOpen && shouldShowColorControls; + isCustomColorPickerOpen && selectedEmoji !== null; const avatarMenuContent = ( Image @@ -307,77 +305,151 @@ export function AgentCreationPreview({ - {activeTab === "upload" ? ( -
+ {activeTab === "image" ? ( +
+ {/* Drop / browse zone — mini version of profile editor */} -
{ + onDragEnter={(event) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + imageDragDepthRef.current += 1; + event.dataTransfer.dropEffect = "copy"; + setIsImageDropActive(true); + }} + onDragLeave={(event) => { + if (!isAvatarFileDrag(event)) { + return; + } event.preventDefault(); event.stopPropagation(); - applyAvatarUrl(); + imageDragDepthRef.current = Math.max( + 0, + imageDragDepthRef.current - 1, + ); + if (imageDragDepthRef.current === 0) { + setIsImageDropActive(false); + } + }} + onDragOver={(event) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setIsImageDropActive(true); + }} + onDrop={(event) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + imageDragDepthRef.current = 0; + setIsImageDropActive(false); + const file = event.dataTransfer.files[0]; + if (!file || disabled || isUploading) { + return; + } + clearUploadError(); + void uploadAvatarFile(file); }} + type="button" > - - + ) : ( + + )} + + {isUploading + ? "Uploading..." + : isImageDropActive + ? "Drop image here" + : "Drop or browse"} + + + + {/* URL input — same style as profile editor */} +
+ + setIsAvatarUrlInputFocused(false)} + onBlur={() => applyAvatarUrl()} onChange={(event) => setAvatarUrlDraft(event.target.value)} - onFocus={() => setIsAvatarUrlInputFocused(true)} - placeholder={ - isAvatarUrlInputFocused ? "https://..." : "Use a URL" - } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyAvatarUrl(); + } + }} + placeholder="Paste a URL" spellCheck={false} + type="url" value={avatarUrlDraft} /> - {hasAvatarUrlDraft ? ( + {avatarUrlDraft.trim().length > 0 ? ( ) : null} - +
+ + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null} + {hasAvatar && onClearAvatar ? (
) : ( -
+
+ {/* Emoji picker */}
@@ -431,68 +504,58 @@ export function AgentCreationPreview({ />
-
-
- {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 ( - - ); - })} -
+ {/* 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 ( + + ); + })}
Date: Mon, 6 Jul 2026 21:00:24 +0100 Subject: [PATCH 03/10] fix(desktop): agent avatar emoji picker polish - Widen popover to 400px so emoji category nav isn't cut off and scrolling works properly in the emoji grid - Color swatches: 12 columns with smaller dots (h-6 w-6) for a more compact, less vertically spaced layout - Single drop zone: dragging a file highlights the entire popover with a 'Drop image here' overlay instead of two competing drop zones - Suppress outer avatar drag highlight when the popover is open - Auto-switch to Image tab when dragging a file into the popover - Add squish animation to the avatar preview when emoji/color changes, matching the profile editor behavior (buzz-avatar-squish CSS class) - Render emoji avatars as colored circle + glyph (not via ProfileAvatar img) so the background-color transition and squish animation work --- desktop/scripts/check-px-text.mjs | 1 + .../agents/ui/AgentCreationPreview.tsx | 686 ++++++++++-------- 2 files changed, 376 insertions(+), 311 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 8182d18cd7..37ee2a427b 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:691", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index ec0621d91e..eaf2394dde 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -79,9 +79,10 @@ export function AgentCreationPreview({ const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE); const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = React.useState(false); - const [isImageDropActive, setIsImageDropActive] = React.useState(false); + const [isPopoverDragOver, setIsPopoverDragOver] = React.useState(false); + const [squishKey, setSquishKey] = React.useState(0); const avatarDragDepthRef = React.useRef(0); - const imageDragDepthRef = React.useRef(0); + const popoverDragDepthRef = React.useRef(0); const shouldReduceMotion = useReducedMotion(); const emojiPickerContainerRef = React.useRef(null); const emojiMartThemeVars = useEmojiMartThemeVars(); @@ -119,6 +120,8 @@ export function AgentCreationPreview({ React.useEffect(() => { if (isAvatarMenuOpen) { setAvatarUrlDraft(""); + setIsPopoverDragOver(false); + popoverDragDepthRef.current = 0; const parsed = parseEmojiAvatarDataUrl(avatarUrl ?? ""); if (parsed) { @@ -161,6 +164,7 @@ export function AgentCreationPreview({ function applyEmojiAvatar(emoji: string, color = selectedColor) { onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + setSquishKey((key) => key + 1); } function handleColorSelect(swatch: AvatarColorSwatch) { @@ -204,13 +208,18 @@ export function AgentCreationPreview({ [avatarEditClipId], ); 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(); @@ -219,12 +228,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(); @@ -232,12 +241,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(); @@ -247,12 +256,12 @@ export function AgentCreationPreview({ setIsDragOverAvatar(false); } }, - [], + [isAvatarMenuOpen], ); const handleAvatarDrop = React.useCallback( (event: React.DragEvent) => { - if (!isAvatarFileDrag(event)) { + if (isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -268,6 +277,80 @@ 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); + // Switch to image tab when dragging a file + 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], ); @@ -277,318 +360,274 @@ export function AgentCreationPreview({ const avatarMenuContent = ( - { - setActiveTab(tab as AvatarTab); - setIsCustomColorPickerOpen(false); - }} - value={activeTab} + {/* Single drop zone covering the entire popover */} +
- - - Image - - - Emoji - - - - - {activeTab === "image" ? ( -
- {/* Drop / browse zone — mini version of profile editor */} - - - {/* URL input — same style as profile editor */} -
- - applyAvatarUrl()} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - applyAvatarUrl(); - } - }} - placeholder="Paste a URL" - spellCheck={false} - type="url" - value={avatarUrlDraft} - /> - - {avatarUrlDraft.trim().length > 0 ? ( - - - - ) : null} -
+ ) : null} - {uploadErrorMessage ? ( -

- {uploadErrorMessage} -

- ) : null} - - {hasAvatar && onClearAvatar ? ( + { + setActiveTab(tab as AvatarTab); + setIsCustomColorPickerOpen(false); + }} + value={activeTab} + > + + + Image + + + Emoji + + + + + {activeTab === "image" ? ( +
+ {/* Click to browse zone */} - ) : null} -
- ) : ( -
- {/* Emoji picker */} -
- { - if (disabled) { - return; - } - if (!emoji.native) { - return; - } - const nextColor = - selectedEmoji === null - ? (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 ( - - ); - })} + }} + placeholder="Paste a URL" + spellCheck={false} + type="url" + value={avatarUrlDraft} + /> + + {avatarUrlDraft.trim().length > 0 ? ( + + + + ) : null} + +
+ + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null} + + {hasAvatar && onClearAvatar ? ( + + ) : null}
- - { - setCustomSaturation(nextSaturation); - setCustomValue(nextValue); - }} - saturation={customSaturation} - testIdPrefix="agent-avatar" - value={customValue} - visible={isCustomColorPickerVisible} - /> - - {hasAvatar && onClearAvatar ? ( - - ) : null} -
- )} + { + if (disabled) { + return; + } + if (!emoji.native) { + return; + } + const nextColor = + selectedEmoji === null + ? (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} +
+ )} + ); @@ -599,6 +638,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} @@ -637,14 +677,37 @@ export function AgentCreationPreview({
- + {emojiAvatarPreview ? ( +
+ 0 && "buzz-avatar-squish", + )} + key={squishKey} + > + {emojiAvatarPreview.emoji} + +
+ ) : ( + + )}
@@ -682,6 +745,7 @@ export function AgentCreationPreview({ className={cn( "flex h-full w-full items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-primary shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-70", isDragOverAvatar && + !isAvatarMenuOpen && "border-primary/70 bg-primary/5 ring-2 ring-primary/15", )} disabled={disabled || isUploading} From 306784663aa47d11d332dc2167dcdcb8274085a0 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 21:12:56 +0100 Subject: [PATCH 04/10] =?UTF-8?q?fix(desktop):=20agent=20avatar=20picker?= =?UTF-8?q?=20=E2=80=94=20scroll,=20custom=20color,=20drag,=20pencil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove overflow-hidden from emoji picker container so the internal emoji-mart scroll works (was clipping the scrollable area) - Add 'relative' to the emoji tab wrapper so AvatarCustomColorPanel (absolute inset-0) positions correctly and custom color works - Drop overlay: simplified to just a dashed border highlight on the fieldset (no redundant icon/text since the popover already shows them) - URL placeholder: more muted (text-muted-foreground/50, icon /60) - Pencil badge: added to the empty-state avatar circle so both empty and filled states show the edit affordance --- desktop/scripts/check-px-text.mjs | 2 +- .../agents/ui/AgentCreationPreview.tsx | 42 +++++++++---------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 37ee2a427b..2abc670c04 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:691", + "src/features/agents/ui/AgentCreationPreview.tsx:681", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index eaf2394dde..2984b673c0 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -297,7 +297,6 @@ export function AgentCreationPreview({ popoverDragDepthRef.current += 1; event.dataTransfer.dropEffect = "copy"; setIsPopoverDragOver(true); - // Switch to image tab when dragging a file setActiveTab("image"); }, [disabled], @@ -367,22 +366,15 @@ export function AgentCreationPreview({ {/* Single drop zone covering the entire popover */}
- {/* Full-popover drop overlay */} - {isPopoverDragOver ? ( -
- - - Drop image here - -
- ) : null} - { @@ -411,9 +403,7 @@ export function AgentCreationPreview({
{/* Click to browse zone */} - {/* URL input — same style as profile editor */} + {/* URL input */}
- + applyAvatarUrl()} onChange={(event) => setAvatarUrlDraft(event.target.value)} @@ -500,10 +490,10 @@ export function AgentCreationPreview({ ) : null}
) : ( -
- {/* Emoji picker */} +
+ {/* Emoji picker — no overflow-hidden so internal scroll works */}
@@ -743,7 +733,7 @@ export function AgentCreationPreview({ From 8bb8272f9e9886f22611c458a3ee3a1610186f58 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 21:21:38 +0100 Subject: [PATCH 05/10] fix(desktop): use MaskedAvatarBadgeFrame for agent avatar badge - Replace the SVG clipPath + manual badge positioning with the shared MaskedAvatarBadgeFrame component, giving the pencil badge the same smooth organic mask curve as the profile editor - Fix emoji centering: zero out the x offset and reduce y offset to 4px (was 1px/7px from the profile's larger 192px avatar) - Both empty and filled states use MaskedAvatarBadgeFrame for consistent badge masking --- desktop/scripts/check-px-text.mjs | 2 +- .../agents/ui/AgentCreationPreview.tsx | 184 +++++++++--------- 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 2abc670c04..d21708aeae 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:681", + "src/features/agents/ui/AgentCreationPreview.tsx:685", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 2984b673c0..9ac7361b29 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -4,6 +4,7 @@ import * as React from "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, @@ -63,7 +64,6 @@ 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(""); @@ -200,13 +200,6 @@ export function AgentCreationPreview({ setIsCustomColorPickerOpen(false); } - const avatarClipStyle = React.useMemo( - () => ({ - clipPath: `url(#${avatarEditClipId})`, - transform: "translateZ(0)", - }), - [avatarEditClipId], - ); const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0; const emojiAvatarPreview = React.useMemo( () => parseEmojiAvatarDataUrl(avatarUrl ?? ""), @@ -646,61 +639,8 @@ export function AgentCreationPreview({
{hasAvatar ? ( - <> - - -
- {emojiAvatarPreview ? ( -
- 0 && "buzz-avatar-squish", - )} - key={squishKey} - > - {emojiAvatarPreview.emoji} - -
- ) : ( - - )} -
- -
+ {avatarMenuContent} -
- - ) : ( - - - - - {avatarMenuContent} - + )} + + + {avatarMenuContent} + + } + badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} + className="h-36 w-36" + cutout={{ cx: 123, cy: 123, r: 24 }} + size={144} + > +
+ {isUploading ? ( + + ) : ( +
+ )}
From d0154228cf29bca5c48e5ebdd7c041aad6c05ec8 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 21:26:52 +0100 Subject: [PATCH 06/10] fix(desktop): fix emoji scroll + remove emoji nudge on change - Always render the emoji picker (hidden when image tab active) so the shadow CSS injection has time to set up before the user sees it. Previously the picker remounted on every tab switch, racing the style injection. - Enable useEmojiMartStyles when the popover is open (not just when emoji tab is active) so styles are ready before tab switch. - Set --buzz-avatar-emoji-offset-y to 0px so the emoji stays centered on change instead of nudging down then back up. The squish animation now only scales (no translate), which feels squishy without movement. --- desktop/scripts/check-px-text.mjs | 2 +- .../agents/ui/AgentCreationPreview.tsx | 419 +++++++++--------- 2 files changed, 212 insertions(+), 209 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index d21708aeae..8134efcd9e 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:685", + "src/features/agents/ui/AgentCreationPreview.tsx:688", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 9ac7361b29..78f76f04b4 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -102,7 +102,7 @@ export function AgentCreationPreview({ }, }); - useEmojiMartStyles(emojiPickerContainerRef, activeTab === "emoji"); + useEmojiMartStyles(emojiPickerContainerRef, isAvatarMenuOpen); const customColorDraft = React.useMemo( () => hsvToHex(customHue, customSaturation, customValue), @@ -392,224 +392,227 @@ export function AgentCreationPreview({ - {activeTab === "image" ? ( -
- {/* Click to browse zone */} +
+ {/* Click to browse zone */} + + + {/* URL input */} +
+ + applyAvatarUrl()} + onChange={(event) => setAvatarUrlDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyAvatarUrl(); + } + }} + placeholder="Paste a URL" + spellCheck={false} + type="url" + value={avatarUrlDraft} + /> + + {avatarUrlDraft.trim().length > 0 ? ( + + + + ) : null} + +
+ + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null} + + {hasAvatar && onClearAvatar ? ( + ) : null} +
- {/* URL input */} -
- - applyAvatarUrl()} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - applyAvatarUrl(); - } - }} - placeholder="Paste a URL" - spellCheck={false} - type="url" - value={avatarUrlDraft} - /> - - {avatarUrlDraft.trim().length > 0 ? ( - - - - ) : null} - -
- - {uploadErrorMessage ? ( -

- {uploadErrorMessage} -

- ) : null} - - {hasAvatar && onClearAvatar ? ( - - ) : null} -
- ) : ( -
- {/* Emoji picker — no overflow-hidden so internal scroll works */} -
- { - if (disabled) { - return; - } - if (!emoji.native) { - return; - } - const nextColor = - selectedEmoji === null - ? (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); +
+
+ { + if (disabled) { + return; + } + if (!emoji.native) { + return; + } + const nextColor = + selectedEmoji === null + ? (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); }} - saturation={customSaturation} - testIdPrefix="agent-avatar" - value={customValue} - visible={isCustomColorPickerVisible} + previewPosition="none" + searchPosition="none" + set="native" + skinTonePosition="none" + theme="auto" /> +
- {hasAvatar && onClearAvatar ? ( - - ) : null} + {/* 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} +
); @@ -689,7 +692,7 @@ export function AgentCreationPreview({ style={ { "--buzz-avatar-emoji-offset-x": "0px", - "--buzz-avatar-emoji-offset-y": "4px", + "--buzz-avatar-emoji-offset-y": "0px", } as React.CSSProperties } > From cbf3d46b97dc18fbefba2a31ccae36e3480bbcf6 Mon Sep 17 00:00:00 2001 From: npub1n6taw59js7ztz8pt9vma9uqx6g3gcvrwg37nmx9m8lq88yla0tjqfy2ncn <9e97d750b28784b11c2b2b37d2f006d2228c306e447d3d98bb3fc07393fd7ae4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 10:06:36 +0100 Subject: [PATCH 07/10] fix(desktop): reliable emoji-picker scroll + unified avatar popover - Scroll: wheel events retarget to the emoji-mart host outside its shadow root, so the browser did not apply default scroll to .scroll. Add a wheel handler (delta-mode normalization, clamped scrollTop) installed on the shadow-root scroll region, reconnected via MutationObserver. - Gate emoji-mart style injection on the Emoji tab being active so the shadow CSS lands right after the Picker mounts (matches profile editor). - Unify the empty/filled avatar states into one Popover with a stable PopoverAnchor so it no longer jumps when an emoji is selected. - Sliding-pill segment control motion matching the profile editor. - Centered emoji glyph (zeroed offset) and MaskedAvatarBadgeFrame pencil. - Add e2e coverage for emoji-picker scroll inside the popover. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-px-text.mjs | 2 +- .../agents/ui/AgentCreationPreview.tsx | 650 +++++++++--------- .../profile/ui/ProfileAvatarEditor.utils.ts | 84 +++ desktop/tests/e2e/agents.spec.ts | 43 ++ 4 files changed, 446 insertions(+), 333 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 8134efcd9e..2ae8dfbc28 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:688", + "src/features/agents/ui/AgentCreationPreview.tsx:698", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 78f76f04b4..5330e6e8f7 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -30,7 +30,12 @@ import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; @@ -102,7 +107,10 @@ export function AgentCreationPreview({ }, }); - useEmojiMartStyles(emojiPickerContainerRef, isAvatarMenuOpen); + useEmojiMartStyles( + emojiPickerContainerRef, + isAvatarMenuOpen && activeTab === "emoji", + ); const customColorDraft = React.useMemo( () => hsvToHex(customHue, customSaturation, customValue), @@ -376,15 +384,23 @@ export function AgentCreationPreview({ }} value={activeTab} > - + + + + {/* 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} ); @@ -640,131 +654,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} - - } - badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} - className="h-36 w-36" - cutout={{ cx: 123, cy: 123, r: 24 }} - size={144} - > - {emojiAvatarPreview ? ( -
- + )} + + ) : ( + +
- ) : ( - + {isUploading ? ( + + ) : ( +
- ) : ( - - - - - {avatarMenuContent} - - } - badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} - className="h-36 w-36" - cutout={{ cx: 123, cy: 123, r: 24 }} - size={144} - > -
- {isUploading ? ( - - ) : ( -
-
- )} -
+
+ + {avatarMenuContent} + {uploadErrorMessage ? (

diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts index 65acde7b19..b7e506f61a 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 35303470c5..bf704f57f2 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, }) => { From 5d86d0c386ba52fbe8fc702395601778648767ca Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 10:22:08 +0100 Subject: [PATCH 08/10] fix(desktop): preserve emoji avatars on agent create + clear stale emoji state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Codex review comments on the agent emoji-avatar picker: - P1: resolveManagedAgentAvatarUrl ran atob() on every data:image/ URL, but emoji avatars are percent-encoded SVG data URLs (no ;base64), so atob threw and the emoji was silently dropped to the default avatar on create. Pass non-base64 (inline SVG) data URLs through unchanged — the same self-contained form profile persists — and only upload base64 payloads. Adds a regression test. - P2: opening the popover on a non-emoji avatar left the previous selectedEmoji/selectedColor in state, so tapping a color swatch before choosing a new emoji re-applied the stale emoji over the image avatar. Reset emoji selection in the non-emoji branch, matching the profile editor. Bump the text-[4rem] glyph allowlist line in check-px-text.mjs to track the shifted line. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-px-text.mjs | 2 +- .../src/features/agents/ui/AgentCreationPreview.tsx | 5 +++++ .../features/agents/ui/managedAgentAvatar.test.mjs | 10 ++++++++++ .../src/features/agents/ui/managedAgentAvatar.ts | 13 +++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 2ae8dfbc28..9b0965da81 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:698", + "src/features/agents/ui/AgentCreationPreview.tsx:703", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 5330e6e8f7..8e848e5cae 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -137,6 +137,11 @@ export function AgentCreationPreview({ setSelectedColor(parsed.color); 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); setActiveTab("image"); } } diff --git a/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs b/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs index 11f8ad36ea..4495872d07 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 f699b57073..971cf07646 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; From c03f908e98ac503cd4a00e3d2e7546ad1cde80e9 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 12:05:26 +0100 Subject: [PATCH 09/10] fix(desktop): preserve emoji avatars on channel deploy + don't apply URL draft on blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two follow-up review comments (P2): - managedAgentAvatar/channelAgents: createChannelManagedAgent duplicated the base64-only atob handling, so deploying a persona/team with an emoji SVG avatar into a channel silently dropped the emoji. Route it through the shared resolveManagedAgentAvatarUrl resolver so emoji SVG data URLs pass through and base64 PNGs still upload — identical to the agent-create path. - AgentCreationPreview: the URL input committed the draft on every blur (which also closes the popover), so clicking the Emoji tab, Remove, or the drop zone after typing/pasting a URL applied the draft before the intended click. Commit only on Apply/Enter now. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-px-text.mjs | 2 +- desktop/src/features/agents/channelAgents.ts | 23 ++++++------------- .../agents/ui/AgentCreationPreview.tsx | 1 - 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 9b0965da81..54ff9f7390 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:703", + "src/features/agents/ui/AgentCreationPreview.tsx:702", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 020db363a3..c9b1cd68d6 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 8e848e5cae..94e39b4f01 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -446,7 +446,6 @@ export function AgentCreationPreview({ autoCorrect="off" className="min-w-0 flex-1 bg-transparent text-xs font-medium text-foreground outline-none placeholder:text-muted-foreground/50" disabled={disabled || isUploading} - onBlur={() => applyAvatarUrl()} onChange={(event) => setAvatarUrlDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { From 2d1ad85d2c4ee29a5e1c159931dd9474c8ee2677 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 12:21:00 +0100 Subject: [PATCH 10/10] fix(desktop): honor preselected color for the first emoji avatar The agent avatar picker keeps the color swatches visible even before an emoji is chosen, so a user can pick a background color first. The first emoji click took the `selectedEmoji === null` branch and always applied a random color, discarding the user's explicit swatch choice (the selected ring moved but the saved avatar got a different background). Track whether the user has explicitly chosen a color (`hasChosenColor`): set on swatch tap and custom-color commit, and adopted from the saved color when reopening on an existing emoji avatar; reset when the popover opens on a non-emoji/empty avatar. The first emoji now honors a chosen color and only falls back to random when no color was picked. Addresses the third-pass P2 review comment. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-px-text.mjs | 2 +- .../src/features/agents/ui/AgentCreationPreview.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 54ff9f7390..8bfd69ded9 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -25,7 +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:702", + "src/features/agents/ui/AgentCreationPreview.tsx:711", ]); await runPxTextCheck({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 94e39b4f01..441cb93671 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -77,6 +77,11 @@ export function AgentCreationPreview({ 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, @@ -135,6 +140,7 @@ export function AgentCreationPreview({ if (parsed) { setSelectedEmoji(parsed.emoji); setSelectedColor(parsed.color); + setHasChosenColor(true); setActiveTab("emoji"); } else { // Non-emoji avatar (image/URL or empty): clear any stale emoji @@ -142,6 +148,7 @@ export function AgentCreationPreview({ // over the current avatar. setSelectedEmoji(null); setSelectedColor(DEFAULT_EMOJI_AVATAR_COLOR); + setHasChosenColor(false); setActiveTab("image"); } } @@ -192,6 +199,7 @@ export function AgentCreationPreview({ return; } setSelectedColor(swatch); + setHasChosenColor(true); if (selectedEmoji) { applyEmojiAvatar(selectedEmoji, swatch); } @@ -207,6 +215,7 @@ export function AgentCreationPreview({ function commitCustomColor() { setSelectedColor(customColorDraft); + setHasChosenColor(true); if (selectedEmoji) { applyEmojiAvatar(selectedEmoji, customColorDraft); } @@ -528,7 +537,7 @@ export function AgentCreationPreview({ return; } const nextColor = - selectedEmoji === null + selectedEmoji === null && !hasChosenColor ? (AVATAR_COLORS[ Math.floor(Math.random() * AVATAR_COLORS.length) ] ?? DEFAULT_EMOJI_AVATAR_COLOR)