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 +}