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 61deaffd12b..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,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 (
= ({ 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 = ({ messageId, getMessageText, {(reactionsEnabled || shownReactions.length > 0) && ( reaction.author === 'user')?.emoji} > diff --git a/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx b/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx new file mode 100644 index 00000000000..20ee08f1c61 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/double-click-reaction.test.tsx @@ -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()), + 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 new file mode 100644 index 00000000000..75b011c5555 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -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) => 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 +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 00a024aa974..16c6963e248 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -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<{
reaction.author === 'user')?.emoji} >