From 9d589b92d3e3206a4552fc0ff39345bc0fdb98ad Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:36:29 -0500 Subject: [PATCH 1/3] refactor(desktop): one hook for a message's reactions The assistant footer and the user bubble each carried the same block: two metadata reads, the three-store merge, and a local-first toggle that paints before it persists. Same code, two files, and the next surface that wants to react would have been a third copy. useMessageReactions owns it now, with commitReaction as the single write path so every caller applies identical tapback semantics. --- .../assistant-ui/thread/assistant-message.tsx | 42 ++--------- .../thread/use-message-reactions.ts | 69 +++++++++++++++++++ .../assistant-ui/thread/user-message.tsx | 42 ++--------- 3 files changed, 81 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts 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..bc523c7f4b1 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 } 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 @@ -144,38 +137,15 @@ const AssistantActionBar: FC = ({ 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 +194,7 @@ const AssistantActionBar: FC = ({ messageId, getMessageText, {(reactionsEnabled || shownReactions.length > 0) && ( reaction.author === 'user')?.emoji} > 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..8dff6b820fa --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -0,0 +1,69 @@ +import { useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { useCallback } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { 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[] = [] + +/** 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 and the user bubble's picker so both + * 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]) + } +} 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..627a150fb19 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -6,23 +6,16 @@ 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 +147,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 +273,7 @@ export const UserMessage: FC<{
reaction.author === 'user')?.emoji} > From b69d4e5a78b622df59994c23cc9ad728cc709430 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:37:39 -0500 Subject: [PATCH 2/3] 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 +} From 2c71a37ad11475f4d564faede56a94dd93481389 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 03:16:41 -0500 Subject: [PATCH 3/3] fix(desktop): clear double-click heart lint errors --- .../assistant-ui/thread/double-click-reaction.test.tsx | 10 ++++++---- .../assistant-ui/thread/use-message-reactions.ts | 1 + .../components/assistant-ui/thread/user-message.tsx | 1 - 3 files changed, 7 insertions(+), 5 deletions(-) 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 index 5bca43fd494..20ee08f1c61 100644 --- 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 @@ -4,11 +4,13 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Thread } from '.' +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 { $localReactions } from '@/store/reactions-local' -import { $reactionsEnabled } from '@/store/reactions-enabled' +import { Thread } from '.' const createdAt = new Date('2026-05-01T00:00:00.000Z') @@ -29,7 +31,7 @@ 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()), + ...(await importOriginal()), toggleMessageReaction: vi.fn(async () => {}) })) 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 cb6002348aa..75b011c5555 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 @@ -123,6 +123,7 @@ export function useTapbackDoubleClick( reactions?: MessageReaction[] rowId?: number } + const reactions = custom.reactions ?? EMPTY_REACTIONS // Same toggle semantics as the picker: a second double-click retracts. 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 627a150fb19..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,5 +1,4 @@ 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'