refactor(desktop): one hook for a message's reactions

The assistant footer and the user bubble each carried the same block: two
metadata reads, the three-store merge, and a local-first toggle that paints
before it persists. Same code, two files, and the next surface that wants to
react would have been a third copy.

useMessageReactions owns it now, with commitReaction as the single write path
so every caller applies identical tapback semantics.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 01:36:29 -05:00
parent d437d3e54d
commit 9d589b92d3
3 changed files with 81 additions and 72 deletions

View file

@ -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<MessageActionProps> = ({ 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<MessageActionProps> = ({ messageId, getMessageText,
{(reactionsEnabled || shownReactions.length > 0) && (
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={react}
onSelect={pickEmoji}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>

View file

@ -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])
}
}

View file

@ -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<{
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={react}
onSelect={pickEmoji}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>