feat(desktop): double-click a message to heart it

The iMessage gesture, on the same opt-in toggle as the rest of reactions —
double-click any message and it gets a heart; double-click again and it comes
off. Off by default, and while it's off the message root carries no listener
at all.

The gesture is deliberately narrow about what it claims: only a true
double-click (detail === 2, so a triple-click to select the paragraph doesn't
re-toggle), and never over an element where a double-click already means
something — links, buttons, inputs, code blocks. It clears the word selection
the browser just made, since the tapback is what the gesture meant.

Reaction state for the handler is read lazily off the message runtime at
event time rather than subscribed to, mirroring how the footer already reads
its text: the handler renders nothing, so subscribing the message root to
every reaction change would be cost for no paint.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 01:37:39 -05:00
parent 9d589b92d3
commit b69d4e5a78
3 changed files with 203 additions and 6 deletions

View file

@ -18,7 +18,7 @@ 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 { 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'
@ -85,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

View file

@ -0,0 +1,116 @@
// 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 { Thread } from '.'
import { isTapbackDoubleClick } from './use-message-reactions'
import { $localReactions } from '@/store/reactions-local'
import { $reactionsEnabled } from '@/store/reactions-enabled'
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 import('@/store/reactions')>()),
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

@ -1,9 +1,10 @@
import { useAuiState } from '@assistant-ui/react'
import { useAuiState, useMessageRuntime } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useCallback } from 'react'
import { type MouseEvent, useCallback } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { toggleMessageReaction } from '@/store/reactions'
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'
@ -11,6 +12,29 @@ 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,
@ -31,8 +55,8 @@ function commitReaction(
* 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.
* 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,
@ -67,3 +91,55 @@ export function useMessageReactions(
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
}