mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
parent
d437d3e54d
commit
9d589b92d3
3 changed files with 81 additions and 72 deletions
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue