diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 01f0e45eb7b..8d93b7d4d41 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,9 +12,9 @@ }, "scripts": { "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", - "clean:e2e":"tsc --build tsconfig.e2e.json --clean", - "clean:renderer":"tsc --build tsconfig.json --clean ", - "clean:electron":"tsc --build tsconfig.electron.json --clean", + "clean:e2e": "tsc --build tsconfig.e2e.json --clean", + "clean:renderer": "tsc --build tsconfig.json --clean ", + "clean:electron": "tsc --build tsconfig.electron.json --clean", "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", "dev:mock": "node scripts/dev-mock.mjs", @@ -101,7 +101,9 @@ "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "emojibase-data": "^16.0.3", "fflate": "^0.8.3", + "frimousse": "^0.3.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 9ee6a2feb9b..5970b8a0720 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -41,6 +41,7 @@ import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { followActiveSessionCwd } from '@/store/projects' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { recordAgentReaction } from '@/store/reactions-local' import { $currentCwd, $currentModel, @@ -53,6 +54,7 @@ import { setCurrentReasoningEffort, setCurrentServiceTier, setCurrentUsage, + setMessages, setSessions, setTurnStartedAt, setYoloActive @@ -994,6 +996,53 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (isActiveEvent) { revealDesktopPane(payload?.pane ?? '') } + } else if (event.type === 'message.reaction') { + // The agent reacted to a message via the desktop-gated + // react_to_message tool. Already persisted — this only paints it now + // instead of at the next resume. Fresh ChatMessage object per change: + // the runtime repository caches normalized ThreadMessages in a WeakMap + // keyed by ChatMessage identity. + const reactedRowId = payload?.row_id + + if (typeof reactedRowId === 'number') { + const nextReactions = Array.isArray(payload?.reactions) ? payload.reactions : [] + const reactedRole = payload?.role === 'assistant' ? 'assistant' : 'user' + + setMessages(messages => { + // Preferred leg: the message already knows its durable row id + // (rehydrated transcript, or a live row that has round-tripped). + const byRowId = messages.find(message => message.rowId === reactedRowId) + + if (byRowId) { + // Overlay survives the end-of-turn resume, which rebuilds from + // in-memory history that doesn't carry this mid-turn DB write. + recordAgentReaction(reactedRowId, nextReactions) + + return messages.map(message => + message.rowId === reactedRowId ? { ...message, reactions: nextReactions } : message + ) + } + + // Live leg: the targeted message is still optimistic (no rowId — + // it hasn't round-tripped through a resume). The agent's default + // target is the newest message of that role, so stamp the reaction + // AND the now-known row id onto it. Without this the event matches + // nothing and the reaction only appears after a reload. + const lastIndex = messages.findLastIndex( + message => message.role === reactedRole && message.rowId === undefined + ) + + if (lastIndex === -1) { + return messages + } + + recordAgentReaction(reactedRowId, nextReactions) + + return messages.map((message, index) => + index === lastIndex ? { ...message, rowId: reactedRowId, reactions: nextReactions } : message + ) + }) + } } else if (event.type === 'status.update') { if (sessionId && payload?.kind === 'compacting') { setSessionCompacting(sessionId, true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index ffc799c432f..019328d21c5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -91,6 +91,7 @@ function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): C // or reference identity the runtime already guarantees. // timestamp — presentation-only (sort/age display), never affects transcript equality // attachmentRefs — composer-side metadata; already reconciled in reconcileResumeMessages +// rowId — durable backend identity; stable for a given row, never changes what's painted // // If your new field affects what the user sees in the transcript, add it to // COMPARED. If it's metadata that shouldn't trigger a re-render, add it to @@ -99,8 +100,17 @@ const _chatMessageFieldsExhaustive: { [K in Exclude]: never } = {} -const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const -const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const +const COMPARED_FIELDS = [ + 'id', + 'role', + 'pending', + 'error', + 'hidden', + 'branchGroupId', + 'interim', + 'reactions' +] as const +const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const // Compile-time check: every ChatMessagePart discriminant must be handled by // chatPartsEquivalent. If @assistant-ui adds a new part type, this fails tsc. @@ -173,6 +183,23 @@ export function chatPartsEquivalent(aPart: ChatMessage['parts'][number], bPart: return aKeys.every(k => aPrimitive[k] === bPrimitive[k]) } +export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMessage['reactions']): boolean { + const aList = a ?? [] + const bList = b ?? [] + + if (aList === bList) { + return true + } + + return ( + aList.length === bList.length && + aList.every( + (reaction, index) => + reaction.emoji === bList[index].emoji && reaction.author === bList[index].author + ) + ) +} + export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { if ( a.id !== b.id || @@ -183,7 +210,8 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean a.branchGroupId !== b.branchGroupId || // Interim gates the action footer, so flipping it must repaint (e.g. a // previewed final settling onto a sealed interim bubble restores the bar). - (a.interim ?? false) !== (b.interim ?? false) + (a.interim ?? false) !== (b.interim ?? false) || + !chatReactionsEquivalent(a.reactions, b.reactions) ) { return false } @@ -260,6 +288,19 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] } } + // Reactions and the row id come from the same authoritative rows as the + // text, but a live/optimistic row that hasn't round-tripped yet carries + // neither. Carry the cached copy forward so a reaction doesn't blink off + // mid-turn. NEW object every time — the runtime repository's WeakMap + // caches normalized ThreadMessages by ChatMessage identity. + if (sameTurn && preserved.rowId === undefined && previous.rowId !== undefined) { + preserved = { ...preserved, rowId: previous.rowId } + } + + if (sameTurn && preserved.reactions === undefined && previous.reactions?.length) { + preserved = { ...preserved, reactions: [...previous.reactions] } + } + const previousImages = embeddedImageUrls(previousText) if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) { diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 3b7d0c8310e..4a93de59922 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -7,7 +7,7 @@ import { useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, useCallback, useMemo } from 'react' +import { type FC, useCallback, useMemo, useState } from 'react' import { contentHasVisibleText, @@ -15,6 +15,7 @@ import { pickPrimaryPreviewTarget } from '@/components/assistant-ui/thread/content' import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts' +import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions' import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status' import { formatMessageTimestamp } from '@/components/assistant-ui/thread/timestamp' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' @@ -22,15 +23,22 @@ import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { useI18n } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons' +import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { formatAgo } from '@/lib/time' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' +import { toggleMessageReaction } from '@/store/reactions' +import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local' import { $voicePlayback } from '@/store/voice-playback' +import type { MessageReaction } from '@/types/hermes' + +// Stable empty identity — a fresh [] per render would re-run every consumer. +const EMPTY_REACTIONS: MessageReaction[] = [] interface MessageActionProps { messageId: string @@ -135,8 +143,41 @@ const AssistantActionBar: FC = ({ messageId, getMessageText, const { t } = useI18n() const copy = t.assistant.thread + const reactions = useAuiState(s => { + const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] } + + return custom.reactions ?? EMPTY_REACTIONS + }) + + const rowId = useAuiState(s => { + const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number } + + return custom.rowId + }) + + const [pickerOpen, setPickerOpen] = useState(false) + const localAll = useStore($localReactions) + const agentLive = useStore($agentReactions) + + const shownReactions = mergeReactions( + reactions, + localAll[messageId], + rowId !== undefined ? agentLive[rowId] : undefined + ) + + const react = useCallback( + (emoji: null | string) => { + setPickerOpen(false) + // Flip the UI immediately — a tapback is direct manipulation and must + // never wait on a round-trip. Persistence follows in the background. + setLocalReaction(messageId, emoji) + void toggleMessageReaction({ id: messageId, role: 'assistant', rowId, reactions } as ChatMessage, emoji) + }, + [messageId, reactions, rowId] + ) + return ( -
+
= ({ messageId, getMessageText, + {/* ONE slot, Slack-style: the picker trigger and the landed reaction are + the same element, so reacting never shifts layout. Empty → ☺, hidden + until hover like its action-bar neighbors (state lives in styles.css + — the aui_msg-reactions rules outweigh Tailwind utilities here). + Reacted → the emoji itself, always visible at full strength, and + clicking it reopens the picker to switch or retract. Outside + ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's + hover opacity. */} + reaction.author === 'user')?.emoji} + > + 0 || undefined} + data-slot="aui_msg-reactions" + data-state={pickerOpen ? 'open' : undefined} + onClick={() => setPickerOpen(open => !open)} + tooltip={copy.react} + > + {shownReactions.length > 0 ? ( + + {shownReactions.map(reaction => ( + + {reaction.emoji} + + ))} + + ) : ( + + )} + +
) } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 77b79caa8ae..8bcc9464538 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -53,6 +53,13 @@ const ChainToolFallback: FC = props => { return null } + // A reaction's UI is the emoji landing on the bubble (message.reaction + // event) — a "React To Message" tool block next to it would be the agent + // narrating its own tapback. Failures still render so they're debuggable. + if (props.toolName === 'react_to_message' && !props.isError) { + return null + } + if (props.toolName === 'delegate_task') { return } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx new file mode 100644 index 00000000000..749335b1c38 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx @@ -0,0 +1,211 @@ +import { EmojiPicker } from 'frimousse' +import { type FC, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { triggerHaptic } from '@/lib/haptics' +import { Plus } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { QUICK_REACTIONS } from '@/store/reactions' +import type { MessageReaction } from '@/types/hermes' + +// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets` +// plugin bundles emojibase-data): Electron must work offline, and the app +// should never phone a CDN to draw a picker. +const EMOJIBASE_URL = './emojibase' + +// Slack tints its picker cells in a repeating palette (green, blue, yellow, +// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the +// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns). +// Keyed off the emoji's codepoint — deterministic, and stable under +// frimousse's virtualized rows (an index cycle would reshuffle on scroll). +const CELL_TINTS = [ + 'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20', + 'hover:bg-sky-500/15 data-[active]:bg-sky-500/20', + 'hover:bg-amber-500/15 data-[active]:bg-amber-500/20', + 'hover:bg-pink-500/15 data-[active]:bg-pink-500/20', + 'hover:bg-orange-500/15 data-[active]:bg-orange-500/20', + 'hover:bg-violet-500/15 data-[active]:bg-violet-500/20' +] as const + +const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length] + +/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */ +const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => ( + onSelect(emoji.emoji)} + > + {/* Borderless, underline-on-focus — the app's SearchField idiom (DESIGN.md), + not a boxed search bar. Search matches labels AND emojibase tags + ("lol" → 😂), which frimousse handles natively. */} + + + + Loading emoji… + + + No emoji found. + + ( +
+ {category.label} +
+ ), + Emoji: ({ emoji, ...props }) => ( + + ), + Row: ({ children, ...props }) => ( +
+ {children} +
+ ) + }} + /> +
+
+) + +/** + * The reaction picker — six quick emoji, then "+" for the full set. + * + * Rides the shared Popover, so it inherits the app's menu/popover surface + * treatment rather than inventing a floating pill (DESIGN.md: popovers get one + * shared shadow + hairline; call sites don't reinvent elevation). + */ +export const ReactionPicker: FC<{ + align?: 'end' | 'start' + children: React.ReactNode + onOpenChange: (open: boolean) => void + onSelect: (emoji: string) => void + open: boolean + selected?: string +}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => { + const [expanded, setExpanded] = useState(false) + + return ( + { + onOpenChange(next) + + if (!next) { + // Always reopen on the quick row. + setExpanded(false) + } + }} + open={open} + > + {children} + event.preventDefault()} + side="top" + > + {expanded ? ( + + ) : ( + <> + {QUICK_REACTIONS.map(emoji => ( + + ))} + + + )} + + + ) +} + +/** + * The reactions a message carries. + * + * Flat by design (DESIGN.md: "Flat, not boxed") — no pill, no border, no fill. + * It reads as quiet metadata in the same register as the message age and the + * checkpoint row it sits beside. Your own reaction is clickable to retract; + * the agent's is display-only. + */ +export const ReactionBadge: FC<{ + className?: string + onRetract?: () => void + reactions: MessageReaction[] +}> = ({ className, onRetract, reactions }) => { + if (!reactions.length) { + return null + } + + return ( + + {reactions.map(reaction => + reaction.author === 'user' && onRetract ? ( + + ) : ( + + {reaction.emoji} + + ) + )} + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 6b0a6248eb8..56d10349038 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -1,18 +1,27 @@ import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' import { type FC, type ReactNode, useCallback, useRef, useState } from 'react' import { DirectiveContent } from '@/components/assistant-ui/directive-text' import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content' +import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions' import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types' import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text' import { Codicon } from '@/components/ui/codicon' import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { StopFilled } from '@/lib/icons' import { cn } from '@/lib/utils' +import { toggleMessageReaction } from '@/store/reactions' +import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local' import { notifyThreadEditOpen } from '@/store/thread-scroll' import { isWatchWindow } from '@/store/windows' +import type { MessageReaction } from '@/types/hermes' + +// Stable empty identity — a fresh [] per render would re-run every consumer. +const EMPTY_REACTIONS: MessageReaction[] = [] export function StickyHumanMessageContainer({ attachments, @@ -144,6 +153,39 @@ export const UserMessage: FC<{ return messageAttachmentRefs(custom.attachmentRefs) }) + const reactions = useAuiState(s => { + const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] } + + return custom.reactions ?? EMPTY_REACTIONS + }) + + const rowId = useAuiState(s => { + const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number } + + return custom.rowId + }) + + const [pickerOpen, setPickerOpen] = useState(false) + const localAll = useStore($localReactions) + const agentLive = useStore($agentReactions) + + const shownReactions = mergeReactions( + reactions, + localAll[messageId], + rowId !== undefined ? agentLive[rowId] : undefined + ) + + const react = useCallback( + (emoji: null | string) => { + setPickerOpen(false) + // Flip the UI immediately — a tapback is direct manipulation and must + // never wait on a round-trip. Persistence follows in the background. + setLocalReaction(messageId, emoji) + void toggleMessageReaction({ id: messageId, role: 'user', rowId, reactions } as ChatMessage, emoji) + }, + [messageId, reactions, rowId] + ) + // Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt // doesn't dominate the viewport while the response streams underneath; the // clamp lifts on hover / focus (see styles.css). We measure the *unclamped* @@ -257,84 +299,111 @@ export const UserMessage: FC<{ >
-
- {readOnly ? ( - // Spectator transcript: clicking only toggles the clamp so the - // full prompt is readable — never opens an edit composer. - - ) : ( - // Always editable — clicking opens the edit composer even while a - // turn streams; sending the edit reverts (interrupt + rewind). - + reaction.author === 'user')?.emoji} + > +
{ + event.preventDefault() + setPickerOpen(true) + } + } + > + {readOnly ? ( + // Spectator transcript: clicking only toggles the clamp so the + // full prompt is readable — never opens an edit composer. - - )} - {(showStop || showRestore) && ( -
- {showStop ? ( + ) : ( + // Always editable — clicking opens the edit composer even while a + // turn streams; sending the edit reverts (interrupt + rewind). + - ) : ( - - )} -
- )} -
+
+ )} + {(showStop || showRestore) && ( +
+ {showStop ? ( + + ) : ( + + )} +
+ )} +
+ + {/* Below the bubble, same register as the assistant action row: + same emoji size, same vertical padding, right-aligned to the + sent bubble. Overlaying the corner read badly in practice. */} + react(null)} + reactions={shownReactions} + /> [number] @@ -25,6 +25,10 @@ export type ChatMessage = { interim?: boolean /** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */ attachmentRefs?: string[] + /** Durable backend `messages.id`. Absent until the row is persisted. */ + rowId?: number + /** Emoji reactions on this message — one per author (see MessageReaction). */ + reactions?: MessageReaction[] } export type GatewayEventPayload = { @@ -84,6 +88,12 @@ export type GatewayEventPayload = { kind?: string // pane.reveal (agent focusing a desktop pane via the focus_pane tool) pane?: string + // message.reaction (agent reacting via the react_to_message tool) — the + // durable messages.id, that row's full reaction list after the write, and + // the row's role so a live (not-yet-round-tripped) message can be matched. + row_id?: number + reactions?: MessageReaction[] + role?: string // session.title (live auto-title push) — stored session id + generated title session_id?: string title?: string @@ -334,26 +344,38 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content: // A remote backend older than this app serves display_metadata as raw JSON text, // and `in` throws on a primitive — which used to fail the whole session resume. -function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined { +function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record { let parsed: unknown = metadata if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { - return undefined + return null } } - if (!parsed || typeof parsed !== 'object') { - return undefined - } + return parsed && typeof parsed === 'object' ? (parsed as Record) : null +} - const count = (parsed as { task_count?: unknown }).task_count +function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined { + const count = parseDisplayMetadata(metadata)?.task_count return typeof count === 'number' ? count : undefined } +export function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] { + const reactions = parseDisplayMetadata(metadata)?.reactions + + if (!Array.isArray(reactions)) { + return [] + } + + return reactions.filter( + (r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string' + ) +} + function timelineDisplayContent(message: SessionMessage, content: string): string { if (message.display_kind === 'model_switch') { return 'model changed' @@ -1044,11 +1066,19 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { flushPendingTools(index) } + const reactions = messageReactions(message.display_metadata) + // Gateway resume names the durable row id `row_id`; the REST transcript + // prefetch ships the same messages.id as a numeric `id`. Either one lets + // reactions address this exact row later. + const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined) + result.push({ id: `${message.timestamp || Date.now()}-${index}-${displayRole}`, role: displayRole, parts, timestamp: message.timestamp, + ...(rowId !== undefined ? { rowId } : {}), + ...(reactions.length ? { reactions } : {}), ...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {}) }) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 0aab5277a70..99585a3c764 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -387,6 +387,13 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { const createdAt = messageCreatedAt(message) + // Reactions and the durable row id ride metadata.custom for every role — the + // established channel for per-message extras (attachmentRefs below). + const reactionMeta = { + ...(message.rowId !== undefined ? { rowId: message.rowId } : {}), + ...(message.reactions?.length ? { reactions: message.reactions } : {}) + } + if (role === 'user') { return { id: message.id, @@ -394,7 +401,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { content: message.parts.filter((part): part is Extract => part.type === 'text'), attachments: [], createdAt, - metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } } + metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta } } } as ThreadMessage } @@ -426,7 +433,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { unstable_data: [], steps: [], // Carries ChatMessage.interim to AssistantMessage's footer gate. - custom: message.interim ? { interim: true } : {} + custom: { ...(message.interim ? { interim: true } : {}), ...reactionMeta } } } as ThreadMessage } diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index bd5fc978bcf..550c2a4bc88 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -101,6 +101,7 @@ import { IconSettings as Settings, IconSettings2 as Settings2, IconAdjustmentsHorizontal as SlidersHorizontal, + IconMoodPlus as SmilePlusIcon, IconSquare as Square, IconChartDots3 as Starmap, IconSteeringWheel as SteeringWheel, @@ -226,6 +227,7 @@ export { Settings, Settings2, SlidersHorizontal, + SmilePlusIcon, Square, Starmap, SteeringWheel, diff --git a/apps/desktop/src/store/reactions-local.ts b/apps/desktop/src/store/reactions-local.ts new file mode 100644 index 00000000000..ec04526abb8 --- /dev/null +++ b/apps/desktop/src/store/reactions-local.ts @@ -0,0 +1,67 @@ +import { atom } from 'nanostores' + +import { applyReaction } from '@/store/reactions' +import type { MessageReaction } from '@/types/hermes' + +/** + * Reactions the user has set in THIS window, keyed by renderer message id. + * + * The UI owns this outright. A tapback is a direct manipulation — it flips the + * instant you click it, with no round-trip, no gateway, and no dependency on a + * message having been persisted yet. Durable state (and the agent's own + * reactions) still arrive through `metadata.custom.reactions`; this layer sits + * on top of it so the interaction never waits on the backend to feel alive. + */ +export const $localReactions = atom>({}) + +/** + * Agent reactions announced live (`message.reaction` events), keyed by the + * DURABLE row id — never the renderer message id, which the end-of-turn + * resume regenerates (an overlay keyed on the old id would orphan the instant + * the transcript rebuilds; "identity is not incidental", AGENTS.md). The + * resume also rebuilds from the gateway's in-memory history, which doesn't + * carry a reaction written to the DB mid-turn — this overlay outlives that + * clobber and a real reload hydrates the same reaction from disk. + */ +export const $agentReactions = atom>({}) + +/** Record an agent reaction painted from a live gateway event. */ +export function recordAgentReaction(rowId: number, reactions: MessageReaction[]): void { + $agentReactions.set({ + ...$agentReactions.get(), + [rowId]: reactions.filter(reaction => reaction.author === 'agent') + }) +} + +/** + * Merge the durable reaction list with anything this window knows live. + * + * The user's slot: local wins (they just clicked it — newer by definition). + * The agent's slot: the live-event overlay wins over persisted (a mid-turn + * reaction reaches the DB before the in-memory history the next resume + * projects from), falling back to what the transcript carried. + */ +export function mergeReactions( + persisted: MessageReaction[] | undefined, + local: MessageReaction[] | undefined, + agentLive?: MessageReaction[] +): MessageReaction[] { + const persistedList = persisted ?? [] + + const userSide = local + ? local.filter(reaction => reaction.author === 'user') + : persistedList.filter(reaction => reaction.author === 'user') + + const agentSide = agentLive ?? persistedList.filter(reaction => reaction.author === 'agent') + + return [...userSide, ...agentSide] +} + +/** Toggle the user's reaction on a message — instant, local, no round-trip. */ +export function setLocalReaction(messageId: string, emoji: null | string): MessageReaction[] { + const next = applyReaction($localReactions.get()[messageId], emoji, 'user') + + $localReactions.set({ ...$localReactions.get(), [messageId]: next }) + + return next +} diff --git a/apps/desktop/src/store/reactions.test.ts b/apps/desktop/src/store/reactions.test.ts new file mode 100644 index 00000000000..f922784c9fb --- /dev/null +++ b/apps/desktop/src/store/reactions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { applyReaction, QUICK_REACTIONS } from '@/store/reactions' +import type { MessageReaction } from '@/types/hermes' + +const at = 1_700_000_000 + +function reaction(emoji: string, author: MessageReaction['author']): MessageReaction { + return { emoji, author, at } +} + +describe('applyReaction', () => { + it('adds a reaction to an empty message', () => { + expect(applyReaction(undefined, '❤️', 'user')).toMatchObject([{ emoji: '❤️', author: 'user' }]) + }) + + it('replaces the same author’s existing reaction (one per author)', () => { + const next = applyReaction([reaction('❤️', 'user')], '😂', 'user') + + expect(next).toHaveLength(1) + expect(next[0].emoji).toBe('😂') + }) + + it('retracts when the live reaction is re-sent', () => { + expect(applyReaction([reaction('👍', 'user')], '👍', 'user')).toEqual([]) + }) + + it('clears on an explicit null', () => { + expect(applyReaction([reaction('👍', 'user')], null, 'user')).toEqual([]) + }) + + it('keeps authors independent', () => { + const next = applyReaction([reaction('🔥', 'agent')], '❤️', 'user') + + expect(next.map(r => r.author).sort()).toEqual(['agent', 'user']) + }) + + it('retracting one author leaves the other intact', () => { + const next = applyReaction([reaction('🔥', 'agent'), reaction('❤️', 'user')], null, 'user') + + expect(next).toMatchObject([{ emoji: '🔥', author: 'agent' }]) + }) + + it('never mutates the input array', () => { + const before = [reaction('❤️', 'user')] + const snapshot = [...before] + + applyReaction(before, '😂', 'user') + + expect(before).toEqual(snapshot) + }) +}) + +describe('QUICK_REACTIONS', () => { + it('is the six iOS Tapback defaults, each distinct', () => { + expect(QUICK_REACTIONS).toHaveLength(6) + expect(new Set(QUICK_REACTIONS).size).toBe(6) + }) +}) diff --git a/apps/desktop/src/store/reactions.ts b/apps/desktop/src/store/reactions.ts new file mode 100644 index 00000000000..c8c2b414a8a --- /dev/null +++ b/apps/desktop/src/store/reactions.ts @@ -0,0 +1,93 @@ +import type { ChatMessage } from '@/lib/chat-messages' +import { activeGateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $activeSessionId, $messages, setMessages } from '@/store/session' +import type { MessageReaction } from '@/types/hermes' + +/** The six iOS Tapback defaults, in Apple's order. */ +export const QUICK_REACTIONS = ['❤️', '👍', '👎', '😂', '‼️', '❓'] as const + +interface MessageReactResponse { + row_id: number + reactions: MessageReaction[] +} + +/** Apply the local half of a tapback: one reaction per author, re-tap retracts. */ +export function applyReaction( + reactions: MessageReaction[] | undefined, + emoji: null | string, + author: MessageReaction['author'] +): MessageReaction[] { + const current = reactions ?? [] + const previous = current.find(reaction => reaction.author === author) + const without = current.filter(reaction => reaction.author !== author) + + if (!emoji || previous?.emoji === emoji) { + return without + } + + return [...without, { emoji, author, at: Date.now() / 1000 }] +} + +function writeReactions(messageId: string, reactions: MessageReaction[], rowId?: number) { + // A NEW ChatMessage object per change is load-bearing: the runtime + // repository caches normalized ThreadMessages in a WeakMap keyed by + // ChatMessage identity, so a mutation in place renders stale. + // Keyed by the renderer id, not rowId: a live message has no rowId yet. + setMessages(messages => + messages.map(message => + message.id === messageId ? { ...message, reactions, ...(rowId === undefined ? {} : { rowId }) } : message + ) + ) +} + +/** + * Toggle *author*'s reaction on a persisted message. + * + * Optimistic: paints immediately, then lets the backend's returned list win. + * A failed write rolls back to the snapshot (desktop AGENTS.md — "be optimistic, + * then honest"). + */ +export async function toggleMessageReaction( + message: ChatMessage, + emoji: null | string, + author: MessageReaction['author'] = 'user' +): Promise { + // A live message hasn't round-tripped through a resume yet, so it carries no + // rowId. Rather than disable the affordance (which made reactions invisible + // in any active conversation), let the backend resolve the newest row of + // this role — which is exactly the message being reacted to. + const rowId = message.rowId + const sessionId = $activeSessionId.get() + const gateway = activeGateway() + + if (!sessionId || !gateway) { + notifyError( + new Error(!sessionId ? 'No active session' : 'Gateway not connected'), + 'Could not react' + ) + + return + } + + const snapshot = $messages.get().find(m => m.id === message.id)?.reactions + + writeReactions(message.id, applyReaction(snapshot, emoji, author)) + + try { + const result = await gateway.request('message.react', { + session_id: sessionId, + ...(rowId === undefined ? { newest_role: message.role } : { row_id: rowId }), + emoji, + author + }) + + // Learn the row id from the response so later toggles address it directly. + writeReactions(message.id, result?.reactions ?? [], result?.row_id) + } catch (err) { + // Be optimistic, THEN honest: a rejected write rolls back visibly and says + // why, instead of the reaction quietly vanishing (desktop AGENTS.md). + writeReactions(message.id, snapshot ?? []) + notifyError(err, 'Could not react') + } +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 18b52070a50..aaa00f9fc8c 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1638,18 +1638,21 @@ text-* variant utilities. */ .btn-arc { an open diff is excluded — it keeps the full gap, same as in-flow. */ [data-slot='aui_turn-pair'] > [data-slot='aui_assistant-message-root']:has( - > [data-slot='aui_assistant-message-content'] - > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child - ):not(:has([data-file-edit])) + > [data-slot='aui_assistant-message-content'] + > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child + ):not(:has([data-file-edit])) + [data-slot='aui_assistant-message-root']:has( - > [data-slot='aui_assistant-message-content'] - > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child - ):not(:has([data-file-edit])) { + > [data-slot='aui_assistant-message-content'] + > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child + ):not(:has([data-file-edit])) { margin-top: calc(var(--scaffold-block-gap) - var(--conversation-turn-gap)); } -/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */ -[data-slot='aui_msg-actions'] button { +/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. + The reaction slot lives OUTSIDE the bar (a landed emoji must not ride the + bar's hover fade) but is still one of these controls visually. */ +[data-slot='aui_msg-actions'] button, +button[data-slot='aui_msg-reactions'] { border: 0; border-radius: 0; background: transparent; @@ -1666,25 +1669,65 @@ text-* variant utilities. */ .btn-arc { opacity: 0.5; } +/* A landed reaction is content, not a dimmed affordance — emoji render at + full strength everywhere, always (color dim would gray them anyway; they're + glyphs, not icons). */ +button[data-slot='aui_msg-reactions'][data-reacted] { + opacity: 1; + color: inherit; +} + +/* The EMPTY slot is an affordance and follows its action-bar neighbors + exactly: hidden until the message is hovered, then the same 0.5 dim, full + strength under the pointer. Stylesheet-owned because the base rule above + outweighs Tailwind's opacity utilities (0,1,1 vs 0,1,0). */ +button[data-slot='aui_msg-reactions']:not([data-reacted]) { + opacity: 0; + pointer-events: none; +} + +.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]), +button[data-slot='aui_msg-reactions']:not([data-reacted]):focus-visible, +button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] { + opacity: 0.5; + pointer-events: auto; +} + +.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]):hover, +button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] { + opacity: 1; +} + +/* Emoji in ANY reaction surface (badge under a user bubble, footer slot, + picker rows) never inherit a translucent treatment from their container. */ +[data-slot='aui_msg-reactions'] .reaction-pop, +span[data-slot='aui_msg-reactions'] { + opacity: 1; +} + [data-slot='aui_msg-actions'] button:disabled { cursor: default; } -[data-slot='aui_msg-actions'] button:hover { +[data-slot='aui_msg-actions'] button:hover, +button[data-slot='aui_msg-reactions']:hover { background: transparent; color: var(--color-foreground); opacity: 1; } -[data-slot='aui_msg-actions'] button:active { +[data-slot='aui_msg-actions'] button:active, +button[data-slot='aui_msg-reactions']:active { background: transparent; } -[data-slot='aui_msg-actions'] button:focus-visible { +[data-slot='aui_msg-actions'] button:focus-visible, +button[data-slot='aui_msg-reactions']:focus-visible { opacity: 1; } -[data-slot='aui_msg-actions'] button svg { +[data-slot='aui_msg-actions'] button svg, +button[data-slot='aui_msg-reactions'] svg { width: 0.875rem; height: 0.875rem; } @@ -2014,6 +2057,26 @@ text-* variant utilities. */ .btn-arc { } } +@keyframes reaction-pop { + 0% { + opacity: 0; + transform: scale(0.4); + } + 60% { + opacity: 1; + transform: scale(1.18); + } + 100% { + transform: scale(1); + } +} + +/* Landing a reaction should feel like something — same pop shape as + pet-reveal-pop, scaled down for an inline glyph. */ +.reaction-pop { + animation: reaction-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + @media (prefers-reduced-motion: reduce) { .pet-egg, .pet-egg__glow, @@ -2021,6 +2084,9 @@ text-* variant utilities. */ .btn-arc { .pet-reveal { animation: none; } + .reaction-pop { + animation: none; + } .pet-reveal { opacity: 1; transform: none; diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 46536226153..0b23acbe46a 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -524,6 +524,15 @@ export type TimelineDisplayMetadata = failed_count?: number duration_seconds?: number } + | { reactions: MessageReaction[] } + +/** One emoji reaction on a message. One per author, iOS-Tapback style. */ +export interface MessageReaction { + emoji: string + author: 'agent' | 'user' + /** Epoch seconds. */ + at: number +} export interface SessionMessage { codex_reasoning_items?: unknown @@ -540,6 +549,17 @@ export interface SessionMessage { */ display_metadata?: string | TimelineDisplayMetadata role: 'assistant' | 'system' | 'tool' | 'user' + /** + * Durable `messages.id` from the backend. The renderer's own message ids are + * ephemeral (derived from timestamp+index, and a different shape for live vs + * rehydrated vs optimistic rows), so anything addressing a specific persisted + * message — reactions — keys off this. Absent on a backend older than this app. + * + * The gateway resume path names it `row_id`; the REST transcript path + * (`SELECT *`) ships the same value as a numeric `id`. Read both. + */ + row_id?: number + id?: number text?: unknown timestamp?: number tool_call_id?: null | string diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9c2cc123809..d6c14e7008e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -36,9 +36,47 @@ const debugEntry = (command: string, env: Record) => ? path.resolve(__dirname, './src/debug/dev-only.ts') : path.resolve(__dirname, './src/debug/dev-only.noop.ts') +// The emoji picker (frimousse) fetches `//data.json` at +// runtime. Its default is a CDN; Electron must work offline, so serve the +// bundled emojibase-data package at a stable local path instead — middleware +// in dev, emitted assets in the build. Only the files a locale actually needs. +const emojibaseDir = + real(path.resolve(__dirname, 'node_modules/emojibase-data')) ?? + real(path.resolve(__dirname, '../../node_modules/emojibase-data')) + +const EMOJIBASE_PATH = /^[a-z-]+\/(data|messages|shortcodes\/emojibase)\.json$/ + +const emojibaseAssets = () => ({ + name: 'hermes:emojibase-assets', + configureServer(server: { + middlewares: { use: (route: string, handler: (req: any, res: any, next: () => void) => void) => void } + }) { + server.middlewares.use('/emojibase', (req, res, next) => { + const rel = (req.url ?? '').split('?')[0].replace(/^\/+/, '') + if (!emojibaseDir || !EMOJIBASE_PATH.test(rel)) return next() + fs.readFile(path.join(emojibaseDir, rel), (err: unknown, buf: Buffer) => { + if (err) return next() + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + res.end(buf) + }) + }) + }, + generateBundle(this: { emitFile: (asset: { type: 'asset'; fileName: string; source: Uint8Array }) => void }) { + if (!emojibaseDir) return + for (const rel of ['en/data.json', 'en/messages.json', 'en/shortcodes/emojibase.json']) { + this.emitFile({ + type: 'asset', + fileName: `emojibase/${rel}`, + source: fs.readFileSync(path.join(emojibaseDir, rel)) + }) + } + } +}) + export default defineConfig(({ command }) => ({ base: './', - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), emojibaseAssets()], css: { // Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by // `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and diff --git a/package-lock.json b/package-lock.json index c4275fb8fff..ff5c45cd76e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,9 @@ "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "emojibase-data": "^16.0.3", "fflate": "^0.8.3", + "frimousse": "^0.3.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -1927,448 +1929,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -10079,6 +9639,33 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/emojibase": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz", + "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + } + }, + "node_modules/emojibase-data": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-16.0.3.tgz", + "integrity": "sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -11197,6 +10784,25 @@ } } }, + "node_modules/frimousse": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz", + "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -18369,7 +17975,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc",