mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #74644 from NousResearch/bb/double-tap-heart
feat(desktop): double-click a message to heart it
This commit is contained in:
commit
dd241cf0cd
4 changed files with 281 additions and 73 deletions
|
|
@ -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 (
|
||||
<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
|
||||
|
|
@ -144,38 +142,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ 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<MessageActionProps> = ({ messageId, getMessageText,
|
|||
{(reactionsEnabled || shownReactions.length > 0) && (
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
onSelect={pickEmoji}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<typeof ReactionsStore>()),
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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
|
||||
}
|
||||
|
|
@ -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<{
|
|||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
onSelect={pickEmoji}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue