From b69d4e5a78b622df59994c23cc9ad728cc709430 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:37:39 -0500 Subject: [PATCH] feat(desktop): double-click a message to heart it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/thread/assistant-message.tsx | 7 +- .../thread/double-click-reaction.test.tsx | 116 ++++++++++++++++++ .../thread/use-message-reactions.ts | 86 ++++++++++++- 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx 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 bc523c7f4b1..78f86338648 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -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 (
+ 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()), + 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({ + messages: [assistantMessage()], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +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() + + 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() + + const message = (await screen.findByText('done')).closest('[data-slot="aui_assistant-message-root"]') + + fireEvent.doubleClick(message!, { detail: 2 }) + + expect($localReactions.get()['assistant-1']).toBeUndefined() + }) +}) 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 index 8dff6b820fa..cb6002348aa 100644 --- a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -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) => void) | undefined { + const enabled = useStore($reactionsEnabled) + const messageRuntime = useMessageRuntime() + + const onDoubleClick = useCallback( + (event: MouseEvent) => { + 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 +}