Merge pull request #74644 from NousResearch/bb/double-tap-heart

feat(desktop): double-click a message to heart it
This commit is contained in:
brooklyn! 2026-07-30 03:56:01 -05:00 committed by GitHub
commit dd241cf0cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 281 additions and 73 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, useTapbackDoubleClick } 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
@ -92,12 +85,17 @@ export const AssistantMessage: FC<{
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
// Double-click the reply to heart it (iMessage). Undefined while reactions
// are off, so the root carries no listener at all.
const onDoubleClick = useTapbackDoubleClick(messageId, 'assistant')
return (
<MessagePrimitive.Root
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
data-streaming={isRunning ? 'true' : undefined}
onDoubleClick={onDoubleClick}
ref={enterRef}
>
<div
@ -144,38 +142,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 +199,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,118 @@
// Double-click an assistant reply to heart it (the iMessage gesture), gated on
// the same opt-in toggle as the rest of message reactions.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactionsStore from '@/store/reactions'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { $localReactions } from '@/store/reactions-local'
import { isTapbackDoubleClick } from './use-message-reactions'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
// The gesture persists through the gateway; this suite is about the local
// paint, which is what the user actually sees on the click.
vi.mock('@/store/reactions', async importOriginal => ({
...(await importOriginal<typeof ReactionsStore>()),
toggleMessageReaction: vi.fn(async () => {})
}))
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as ThreadMessage
}
function Harness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMessage()],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$localReactions.set({})
$reactionsEnabled.set(false)
})
afterEach(() => {
cleanup()
})
describe('isTapbackDoubleClick', () => {
it('claims a plain double-click on message body', () => {
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('span') })).toBe(true)
})
it('ignores a triple-click, so selecting a paragraph does not re-toggle', () => {
expect(isTapbackDoubleClick({ detail: 3, target: document.createElement('span') })).toBe(false)
})
it('leaves double-click alone where it already means something', () => {
const code = document.createElement('pre')
const inner = document.createElement('code')
code.append(inner)
expect(isTapbackDoubleClick({ detail: 2, target: inner })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('a') })).toBe(false)
expect(isTapbackDoubleClick({ detail: 2, target: document.createElement('button') })).toBe(false)
})
})
describe('double-click to heart an assistant message', () => {
it('hearts the message, and a second double-click retracts it', async () => {
$reactionsEnabled.set(true)
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
expect(message).toBeTruthy()
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']?.[0]?.emoji).toBe('❤️'))
fireEvent.doubleClick(message!, { detail: 2 })
await waitFor(() => expect($localReactions.get()['assistant-1']).toEqual([]))
})
it('does nothing while reactions are off', async () => {
render(<Harness />)
const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]')
fireEvent.doubleClick(message!, { detail: 2 })
expect($localReactions.get()['assistant-1']).toBeUndefined()
})
})

View file

@ -0,0 +1,146 @@
import { useAuiState, useMessageRuntime } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type MouseEvent, useCallback } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { QUICK_REACTIONS, 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[] = []
/** The tapback a double-click lands: Apple's first Tapback, and ours. */
export const DOUBLE_CLICK_REACTION = QUICK_REACTIONS[0]
// Double-click means something else on these: links and controls act, inputs
// and code blocks select. The gesture only claims plain message body.
const NOT_A_TAPBACK = 'a, button, input, pre, select, textarea, [contenteditable="true"], [role="button"]'
/**
* Is this double-click the "heart it" gesture?
*
* `detail === 2` keeps a triple-click (select-the-paragraph) from re-firing,
* and anything the browser already gives a double-click meaning keeps it.
*/
export function isTapbackDoubleClick(event: { detail: number; target: EventTarget | null }): boolean {
if (event.detail !== 2) {
return false
}
const target = event.target
return target instanceof Element ? !target.closest(NOT_A_TAPBACK) : true
}
/** 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, the user bubble's picker, and the
* double-click gesture so all three 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])
}
}
/**
* Double-click a message to heart it the iMessage gesture.
*
* Reads the message's reaction state lazily at event time (the same trick the
* footer uses for its text): the gesture renders nothing, so subscribing the
* perf-sensitive message root to every reaction change would be pure cost.
* Returns `undefined` while reactions are off, so the element carries no
* listener at all.
*/
export function useTapbackDoubleClick(
messageId: string,
role: ChatMessage['role']
): ((event: MouseEvent<HTMLElement>) => void) | undefined {
const enabled = useStore($reactionsEnabled)
const messageRuntime = useMessageRuntime()
const onDoubleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
if (!isTapbackDoubleClick(event)) {
return
}
// Double-click has already selected the word underneath — the tapback,
// not a stray selection, is what the gesture meant.
window.getSelection()?.removeAllRanges()
triggerHaptic('selection')
const custom = (messageRuntime.getState().metadata?.custom ?? {}) as {
reactions?: MessageReaction[]
rowId?: number
}
const reactions = custom.reactions ?? EMPTY_REACTIONS
// Same toggle semantics as the picker: a second double-click retracts.
const mine = mergeReactions(reactions, $localReactions.get()[messageId]).find(
reaction => reaction.author === 'user'
)
commitReaction(
messageId,
role,
custom.rowId,
reactions,
mine?.emoji === DOUBLE_CLICK_REACTION ? null : DOUBLE_CLICK_REACTION
)
},
[messageId, messageRuntime, role]
)
return enabled ? onDoubleClick : undefined
}

View file

@ -1,28 +1,20 @@
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 { 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 +146,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 +272,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}
>