import { useEffect, useRef, useState } from 'react' import { IconArrowsShuffle, IconCircleOff, IconLoader2, IconMoodSad, IconUpload } from '@tabler/icons-react' import { EmojiPicker, type EmojiPickerListCategoryHeaderProps, type EmojiPickerListEmojiProps, type EmojiPickerListRowProps } from 'frimousse' import { useResetWorkspaceIcon, useSaveWorkspaceIcon } from '@/client/features/settings/api' import { workspaceProviderIcon } from '@/client/features/home/workspace-presentation' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { APP_ICON_CHOICES } from '@/client/lib/app-icon-registry' import { cn } from '@/client/lib/cn' import { GRADIENT_PRESETS, type IconGradient, gradientCss, randomGradient, renderEmojiIcon, renderGlyphIcon } from '@/client/features/settings/render-icon' import { EMOJI_FONT, FAVORITE_EMOJI } from './icon-picker-options' type Mode = 'emoji' | 'icon' | 'upload' // The selected background: a preset id (or 'shuffle'), and its gradient — null // means transparent. Only the rasterized result is persisted. type IconBg = { id: string; gradient: IconGradient | null } // ── Frimousse list parts (module-level so the virtualized list keeps stable // component identities across re-renders) ─────────────────────────────────── function EmojiCategoryHeader({ category, ...props }: EmojiPickerListCategoryHeaderProps) { return (
{category.label}
) } function EmojiRow({ children, ...props }: EmojiPickerListRowProps) { return (
{children}
) } function EmojiButton({ emoji, ...props }: EmojiPickerListEmojiProps) { return ( ) } type IconPickerProps = { // The currently-saved icon data URL, or null to show the provider default. icon: string | null } export function IconPicker({ icon }: IconPickerProps) { const { workspaceId, provider } = useWorkspaceLayoutCtx() const { isPending: savePending, mutateAsync: saveIcon } = useSaveWorkspaceIcon(workspaceId) const { isPending: resetPending, mutate: resetIcon } = useResetWorkspaceIcon(workspaceId) const [mode, setMode] = useState('emoji') const [bg, setBg] = useState({ id: 'sunrise', gradient: GRADIENT_PRESETS[0].gradient }) // The last shuffled gradient sticks around as its own swatch, so switching to // a preset and back doesn't lose a roll you liked. const [shuffled, setShuffled] = useState(null) const [emoji, setEmoji] = useState(null) // Mirrors the emoji search input so the pinned favorites hide while the list // is showing filtered results. const [emojiSearch, setEmojiSearch] = useState('') const [iconId, setIconId] = useState(null) const [uploadPreview, setUploadPreview] = useState(null) const [dragOver, setDragOver] = useState(false) const [error, setError] = useState(null) const fileRef = useRef(null) const glyphRef = useRef(null) const uploadBlob = useRef(null) const selectedIcon = APP_ICON_CHOICES.find(c => c.id === iconId) const onGradient = bg.gradient !== null const saving = savePending || resetPending const shuffle = () => { const gradient = randomGradient() setShuffled(gradient) setBg({ id: 'shuffle', gradient }) } const onFile = (file: File) => { setError(null) uploadBlob.current = file setMode('upload') setUploadPreview(URL.createObjectURL(file)) } useEffect(() => { let cancelled = false const save = async () => { let blob: Blob if (mode === 'upload') { if (!uploadBlob.current) return blob = uploadBlob.current } else if (mode === 'emoji') { if (!emoji) return blob = await renderEmojiIcon(emoji, bg.gradient) } else { const svg = glyphRef.current?.querySelector('svg') if (!svg) return blob = await renderGlyphIcon(new XMLSerializer().serializeToString(svg), bg.gradient) } if (!cancelled) await saveIcon(blob) } setError(null) // Collapse quick swatch/emoji changes before rasterizing. Requests that have // already started are serialized by the shared mutation scope in the API // hooks, so an older image can never overwrite the final choice. const timer = window.setTimeout(() => { void save().catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to save icon') }) }, 150) return () => { cancelled = true window.clearTimeout(timer) } }, [bg.gradient, emoji, mode, saveIcon, selectedIcon, uploadPreview]) useEffect(() => { return () => { if (uploadPreview) URL.revokeObjectURL(uploadPreview) } }, [uploadPreview]) const reset = () => { uploadBlob.current = null setEmoji(null) setIconId(null) setUploadPreview(null) setError(null) resetIcon() } // The live preview reflects the current selection, falling back to the saved // icon (or provider default) when the current tab has no selection. const previewKind: 'emoji' | 'glyph' | 'image' = mode === 'emoji' && emoji ? 'emoji' : mode === 'icon' && selectedIcon ? 'glyph' : 'image' const previewImage = mode === 'upload' && uploadPreview ? uploadPreview : (icon ?? workspaceProviderIcon[provider ?? 'claude-code']) return (
{/* Live preview */}
{previewKind === 'emoji' ? ( {emoji} ) : previewKind === 'glyph' && selectedIcon ? ( ) : ( )}
{/* Controls */}
{/* Mode tabs */}
{(['emoji', 'icon', 'upload'] as const).map(m => ( ))}
{/* Background swatches — disabled (not hidden) on the upload tab so the layout doesn't jump between tabs. */}
Background
{GRADIENT_PRESETS.map(preset => (
{/* Picker body — fixed height across tabs so the dialog never jumps. */} {mode === 'emoji' ? ( setEmoji(picked.emoji)} // Same-origin emojibase data (vendored under client/vendor/emojibase, // served by server/vendor.ts) — the picker works fully offline. emojibaseUrl="/vendor/emojibase" columns={13} className="isolate flex h-72 flex-col overflow-hidden rounded-xl border border-border bg-background" >
setEmojiSearch(e.target.value)} className="h-8 min-w-0 flex-1 appearance-none rounded-lg bg-muted px-2.5 text-sm outline-none placeholder:text-muted-foreground" />
Loading emoji… {/* Pinned workspace favorites — rendered inside the scroll area so they read as the first category; hidden while searching so results stay on top. */} {emojiSearch.trim() === '' && (

Favorites

{/* Fixed 13 columns to mirror the frimousse rows below — the 26 favorites always land as two clean rows. */}
{FAVORITE_EMOJI.map(e => ( ))}
)} No emoji found
) : mode === 'icon' ? (
{APP_ICON_CHOICES.map(({ id, Icon }) => ( ))}
) : ( )}
{error ? (

{error}

) : saving ? (

Saving…

) : null}
{/* Hidden file input + hidden glyph render used for rasterization. */} { const file = e.target.files?.[0] if (file) onFile(file) e.target.value = '' }} /> {selectedIcon && }
) }