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 61deaffd12b..bc523c7f4b1 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -18,12 +18,12 @@ import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/messa 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 { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' 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, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' @@ -32,14 +32,7 @@ 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 { $reactionsEnabled } from '@/store/reactions-enabled' -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 @@ -144,38 +137,15 @@ 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 reactionsEnabled = useStore($reactionsEnabled) - const localAll = useStore($localReactions) - const agentLive = useStore($agentReactions) + const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'assistant') - const shownReactions = mergeReactions( - reactions, - localAll[messageId], - rowId !== undefined ? agentLive[rowId] : undefined - ) - - const react = useCallback( + const pickEmoji = 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) + react(emoji) }, - [messageId, reactions, rowId] + [react] ) return ( @@ -224,7 +194,7 @@ const AssistantActionBar: FC = ({ messageId, getMessageText, {(reactionsEnabled || shownReactions.length > 0) && ( reaction.author === 'user')?.emoji} > diff --git a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts new file mode 100644 index 00000000000..8dff6b820fa --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -0,0 +1,69 @@ +import { useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { useCallback } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { toggleMessageReaction } from '@/store/reactions' +import { $reactionsEnabled } from '@/store/reactions-enabled' +import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local' +import type { MessageReaction } from '@/types/hermes' + +// Stable empty identity — a fresh [] per render would re-run every consumer. +const EMPTY_REACTIONS: MessageReaction[] = [] + +/** Paint the tapback locally, then persist behind it. */ +function commitReaction( + messageId: string, + role: ChatMessage['role'], + rowId: number | undefined, + reactions: MessageReaction[], + emoji: null | string +): void { + // 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, rowId, reactions } as ChatMessage, emoji) +} + +/** + * A message's reactions and the one way to change them. + * + * Reads the durable list off `metadata.custom`, layers this window's live + * overlays on top (the user's own click, the agent's mid-turn event), and + * hands back a `react` that paints locally first and persists behind it. + * Shared by the assistant footer slot and the user bubble's picker so both + * apply identical tapback semantics. + */ +export function useMessageReactions( + messageId: string, + role: ChatMessage['role'] +): { + enabled: boolean + react: (emoji: null | string) => void + reactions: MessageReaction[] +} { + 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 enabled = useStore($reactionsEnabled) + const localAll = useStore($localReactions) + const agentLive = useStore($agentReactions) + + return { + enabled, + react: useCallback( + (emoji: null | string) => commitReaction(messageId, role, rowId, reactions, emoji), + [messageId, reactions, role, rowId] + ), + reactions: mergeReactions(reactions, localAll[messageId], rowId === undefined ? undefined : agentLive[rowId]) + } +} 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 00a024aa974..627a150fb19 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -6,23 +6,16 @@ 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 { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions' 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 { $reactionsEnabled } from '@/store/reactions-enabled' -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, @@ -154,38 +147,15 @@ 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 reactionsEnabled = useStore($reactionsEnabled) - const localAll = useStore($localReactions) - const agentLive = useStore($agentReactions) + const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'user') - const shownReactions = mergeReactions( - reactions, - localAll[messageId], - rowId !== undefined ? agentLive[rowId] : undefined - ) - - const react = useCallback( + const pickEmoji = 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) + react(emoji) }, - [messageId, reactions, rowId] + [react] ) // Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt @@ -303,7 +273,7 @@ export const UserMessage: FC<{
reaction.author === 'user')?.emoji} >