Merge pull request #74533 from NousResearch/bb/emoji-reactions

feat: iMessage-style emoji reactions on desktop — opt-in, two-way, persistent, model-aware
This commit is contained in:
brooklyn! 2026-07-30 01:02:14 -05:00 committed by GitHub
commit fa183062e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2118 additions and 125 deletions

146
.plans/message-reactions.md Normal file
View file

@ -0,0 +1,146 @@
# Message reactions (desktop tapbacks)
Two-way emoji reactions on individual messages in the desktop transcript: the
user reacts to any message, the agent reacts to a user message, and both sides
read the other's reactions as conversational signal.
## What already exists
Hermes already models reactions on the **platform** side — the desktop is the
only surface without them.
| Surface | Reaction support | Where |
|---|---|---|
| Agent → platform message | `send_message(action="react"/"unreact")` | `tools/send_message_tool.py:266` `_handle_react()` |
| Photon / iMessage | tapbacks in + out, routed only for messages we sent | `plugins/platforms/photon/adapter.py:1240-1283` |
| Telegram | `setMessageReaction`, config-gated | `plugins/platforms/telegram/adapter.py:9669+` |
| Slack / Matrix / Feishu / Discord | inbound reaction events → hooks | `gateway/run.py:4688` `_handle_reaction_event()``HookRegistry.emit("reaction:added")` |
| Adapter contract | `add_reaction()` / `remove_reaction()` coroutines, `set_reaction_handler()` | `gateway/platforms/base.py:3330` |
| Core "affection" detector | regex on user text → `vibe`, drives CLI pet / TUI heart / desktop hearts | `agent/reactions.py`, `agent/turn_context.py:592-604` |
Two things follow from that table:
1. **The agent-facing verb already exists.** `send_message(action="react")` is
the established shape. A desktop reaction should extend that tool, not add a
new core tool — every new tool ships on every API call (AGENTS.md footprint
ladder).
2. **The inbound convention already exists.** Photon turns a tapback into a
normal message event with `reply_to_message_id` + `reply_to_is_own_message`,
and the gateway prefixes `[Replying to your previous message: "…"]`
(`gateway/run.py:13125-13132`). Desktop reactions should read the same way to
the model.
Nothing exists on the desktop side: `grep -ri reaction` across `apps/desktop`
finds only the pet-overlay hearts.
## Prior art
**iOS Tapback** ([Apple](https://support.apple.com/guide/iphone/react-with-tapbacks-iph018d3c336/ios)):
double-tap or touch-and-hold a message → floating pill above the bubble with
heart / thumbs-up / thumbs-down / haha / ‼️ / ❓, swipe left for suggested emoji
and stickers, or tap the emoji button for the full keyboard. **One tapback per
message per person** — tapping the same one again removes it, tapping a
different one replaces it. Multiple people's tapbacks stack on the badge.
**Platform data models** converge on the same shape:
| Platform | Model | Add / remove |
|---|---|---|
| Slack | `{name, count, users[]}` | [`reactions.add`](https://docs.slack.dev/reference/methods/reactions.add) / `reactions.remove`, emits `reaction_added` |
| Discord | `{emoji, count, me}` on the message object | `PUT`/`DELETE .../reactions/{emoji}/@me` |
| Telegram | `reaction: [{type:"emoji", emoji:"👍"}]` — replaces the whole set | `setMessageReaction`, `is_big` for the big animation |
Telegram's "set the whole array" is the closest match to iOS semantics and the
simplest thing to persist.
**assistant-ui has no reaction primitive.** `@assistant-ui/react` 0.14.24 (MIT,
vendored at `apps/desktop/node_modules`): zero hits for "reaction" in `core/src`,
`react/src`, `dist/`, or the 2.2 MB `llms-full.txt` docs dump. What exists is a
hard-coded binary `FeedbackAdapter` (`"positive" | "negative"`,
`core/src/adapters/feedback.ts`) that throws when unconfigured and only writes
back onto assistant messages. Not usable for emoji, not usable on user messages.
**But `metadata.custom` is the supported extension channel** and this repo
already uses it: `ThreadUserMessage`/`ThreadAssistantMessage`/`ThreadSystemMessage`
all carry `metadata.custom: Record<string, unknown>` (`core/src/types/message.ts:319-366`),
and `chat-runtime.ts:397` already ships `custom: { attachmentRefs }` through it.
**Emoji picker survey** (npm week of 2026-07-22, sizes measured from the
published ESM entry):
| Library | License | Weekly DL | gzip | Headless | Latest |
|---|---|---|---|---|---|
| **frimousse** | MIT | 573k | **8.5 kB** | ✅ fully unstyled, composable parts | 0.3.0 · 2025-07-15 |
| emoji-picker-react | MIT | 1.31M | 87 kB | ❌ own CSS-in-JS (flairup) | 4.19.1 · 2026-04-27 |
| emoji-mart | MIT | 2.22M | ~120 kB w/ data | ❌ Preact + shadow styling | 5.6.0 · **2024-04-25**, 217 open issues |
| emoji-picker-element | Apache-2.0 | 183k | — | ❌ Web Component / Shadow DOM | 1.29.1 · 2026-03-01 |
No picker is currently a dependency (only `emoji-regex`, transitive). Already
paid for and reusable: `radix-ui` (Popover), `motion`, `@tanstack/react-virtual`,
Tailwind v4.
## Recommendation
**Hand-roll the tapback pill; add frimousse only behind the "+".** Six fixed
emoji in a pill is ~40 lines of JSX against existing tokens — pulling 87 kB of
`emoji-picker-react` to render six buttons, plus a CSS engine that fights
`DESIGN.md`, is backwards. frimousse is headless, dependency-free, 10× smaller,
and exposes `emojibaseUrl` so the data can be bundled as a Vite asset instead of
hitting jsDelivr (Electron must work offline).
### Data model
One reaction per author per message, Telegram-style whole-set replacement:
```ts
type MessageReaction = { emoji: string; author: 'user' | 'agent'; at: number }
```
Persisted in the existing `messages.display_metadata` JSON column
(`hermes_state_common.py:215`) — no new table. It already survives insert,
compaction, and every read projection, and
`set_latest_matching_message_display_kind()` (`hermes_state.py:5292`) is the
precedent for stamping metadata onto an already-persisted row.
### Model context
Reactions must reach the model **without breaking prompt caching**. The
`api_messages` build loop strips `display_metadata` from every outgoing copy
(`agent/conversation_loop.py:1443-1446`) precisely so display state never
becomes a provider field. Two candidate paths:
| Path | Cache impact | Notes |
|---|---|---|
| Rewrite the reacted-to message's content to carry the annotation | **Breaks the cached prefix** — mutates past context | Rejected. AGENTS.md: prompt caching is sacred. |
| Deliver the reaction as the *next* turn's leading annotation, mirroring photon | Prefix untouched; only the new turn carries it | Matches `[Replying to your previous message: "…"]` (`gateway/run.py:13125`), which the agent already understands |
The second is the same trick the platform adapters already use, so the model
sees a familiar shape and no existing conversation is rewritten.
### Attach points
| Concern | File | Lines |
|---|---|---|
| Assistant hover bar | `apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx` | 134175 |
| User hover cluster | `apps/desktop/src/components/assistant-ui/thread/user-message.tsx` | 296336 |
| Callback threading (ref caveat 7999) | `apps/desktop/src/components/assistant-ui/thread/index.tsx` | 109133 |
| `metadata.custom` → runtime | `apps/desktop/src/lib/chat-runtime.ts` | 384432 |
| RPC client ↔ server pattern | `sidebar/session-actions-menu.tsx:62-89``tui_gateway/server.py:8322` | — |
| Persistence | `hermes_state_common.py:192-216`, `hermes_state.py:5292-5324` | — |
| Prompt injection / strip | `agent/conversation_loop.py` | 14301529 |
### Known gaps to solve first
- **No durable message id crosses the gateway RPC path.** `_history_to_messages()`
(`tui_gateway/server.py:6545`) builds `{"role", "text"}` and drops the id. The
REST path carries `messages.id` incidentally via `SELECT *` but TS
`SessionMessage` (`types/hermes.ts:513-533`) doesn't declare it. Renderer ids
are ephemeral and change shape between rehydrated (`<ts>-<i>-<role>`), live
(`assistant-<ms>`), and optimistic (`user-<ms>-<rand>`) messages. A reaction
needs a stable key — this is the first thing to fix.
- **WeakMap identity cache** in `apps/desktop/src/app/chat/runtime-repository.ts:26-66`
keys normalized `ThreadMessage` by `ChatMessage` identity. A reaction change
must produce a **new** `ChatMessage` object or the UI renders stale.
- **Rewind rewrites rows** (`replace_messages`), so anything keyed by row id
needs cascade handling — an argument for keeping reactions in
`display_metadata` on the row itself rather than a side table.

View file

@ -1445,6 +1445,12 @@ def run_conversation(
api_msg.pop("display_kind", None)
api_msg.pop("display_metadata", None)
# Durable row identity stamped by _rows_to_conversation so the
# desktop can address a specific persisted message (reactions).
# Bookkeeping, never a provider field — only the chat-completions
# transport strips underscore keys, so drop it centrally here.
api_msg.pop("_row_id", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are

View file

@ -101,7 +101,9 @@
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"emojibase-data": "^16.0.3",
"fflate": "^0.8.3",
"frimousse": "^0.3.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",

View file

@ -30,6 +30,8 @@ interface UseComposerTriggerOptions {
at: CompletionSource
draftRef: MutableRefObject<string>
editorRef: RefObject<HTMLDivElement | null>
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
emoji?: CompletionSource
/** Bank the pre-commit state so a popover pick is a single undo step. */
recordUndoPoint?: () => void
requestMainFocus: () => void
@ -50,6 +52,7 @@ export function useComposerTrigger({
at,
draftRef,
editorRef,
emoji,
recordUndoPoint,
requestMainFocus,
setComposerText,
@ -91,7 +94,7 @@ export function useComposerTrigger({
// is present do we pay the cost of the full walk + DOM range work.
const rawText = editor.textContent ?? ''
if (!rawText.includes('@') && !rawText.includes('/')) {
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
if (trigger) {
setTrigger(null)
resetTriggerActive()
@ -128,7 +131,13 @@ export function useComposerTrigger({
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? (emoji?.adapter ?? null)
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@ -146,7 +155,14 @@ export function useComposerTrigger({
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
}, [trigger, triggerAdapter])
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? (emoji?.loading ?? false)
: false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on

View file

@ -0,0 +1,122 @@
import { useCallback } from 'react'
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
/**
* `:shortcode:` completions for the composers, Slack-style (`:joy` 😂).
*
* Draws from the same bundled emojibase-data the reaction picker uses (served
* at ./emojibase by the `hermes:emojibase-assets` vite plugin offline, no
* CDN). The index lazy-loads on the first `:` trigger, then every query is
* answered from memory, so `isCached` skips the debounce and loading state
* after that first load.
*
* A pick inserts the emoji CHARACTER as plain text not a chip. Directive
* chips exist to carry machine-readable references the backend resolves
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
* `rawText` path and lands inline.
*/
interface EmojiEntry {
emoji: string
/** Primary shortcode, e.g. "joy". */
code: string
/** Every shortcode, tag, and label that should match a search. */
haystack: string[]
}
let indexPromise: Promise<EmojiEntry[]> | null = null
let indexLoaded = false
async function loadIndex(): Promise<EmojiEntry[]> {
const [dataRes, codesRes] = await Promise.all([
fetch('./emojibase/en/data.json'),
fetch('./emojibase/en/shortcodes/emojibase.json')
])
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
const codes: Record<string, string | string[]> = await codesRes.json()
const entries: EmojiEntry[] = []
for (const item of data) {
const raw = codes[item.hexcode]
if (!raw) {
continue
}
const shortcodes = Array.isArray(raw) ? raw : [raw]
entries.push({
emoji: item.emoji,
code: shortcodes[0],
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
})
}
indexLoaded = true
return entries
}
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
const index = await (indexPromise ??= loadIndex())
const q = query.toLowerCase()
const prefix: EmojiEntry[] = []
const loose: EmojiEntry[] = []
for (const entry of index) {
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
prefix.push(entry)
} else if (entry.haystack.some(h => h.includes(q))) {
loose.push(entry)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...loose].slice(0, limit)
}
export function useEmojiCompletions() {
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
const entries = await searchEmoji(query)
return {
query,
items: entries.map(entry => ({
text: entry.emoji,
display: `${entry.emoji} :${entry.code}:`,
meta: ''
}))
}
}, [])
const toItem = useCallback(
(entry: CompletionEntry, index: number) => ({
id: `${entry.text}|${index}`,
type: 'emoji',
label: typeof entry.display === 'string' ? entry.display : entry.text,
metadata: {
display: typeof entry.display === 'string' ? entry.display : entry.text,
// The formatter's serialize() returns rawText verbatim → the emoji
// character lands as plain inline text, no chip.
rawText: entry.text,
meta: '',
group: '',
action: ''
}
}),
[]
)
return useLiveCompletionAdapter({
enabled: true,
fetcher,
isCached: () => indexLoaded,
toItem
})
}

View file

@ -49,6 +49,7 @@ import { useComposerTrigger } from './hooks/use-composer-trigger'
import { useComposerUndo } from './hooks/use-composer-undo'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
import { useComposerVoice } from './hooks/use-composer-voice'
import { useEmojiCompletions } from './hooks/use-emoji-completions'
import { useComposerMicroActions } from './hooks/use-micro-actions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
@ -184,6 +185,7 @@ export function ChatBar({
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const emoji = useEmojiCompletions()
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
@ -346,7 +348,7 @@ export function ChatBar({
triggerItems,
triggerKeyConsumedRef,
triggerLoading
} = useComposerTrigger({ at, draftRef, editorRef, recordUndoPoint, requestMainFocus, setComposerText, slash })
} = useComposerTrigger({ at, draftRef, editorRef, emoji, recordUndoPoint, requestMainFocus, setComposerText, slash })
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input

View file

@ -1,10 +1,11 @@
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { $reactionsEnabled } from '@/store/reactions-enabled'
export interface TriggerState {
/** True for a `/` typed mid-message — an inline skill/command reference in
* prose rather than a command invocation. Arg completion doesn't apply. */
inline?: boolean
kind: '@' | '/'
kind: '@' | '/' | ':'
query: string
tokenLength: number
}
@ -44,6 +45,10 @@ export interface TriggerState {
const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
// `:joy` → emoji completions, Slack-style. Boundary-anchored so a mid-word
// colon (`localhost:8080`, `note:`) never fires; two chars minimum so a bare
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@ -180,5 +185,14 @@ export function detectTrigger(textBefore: string): TriggerState | null {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
// Rides the reactions opt-in (Settings → Appearance) — both are one
// "emoji features" surface, off by default.
const emoji = $reactionsEnabled.get() ? EMOJI_TRIGGER_RE.exec(textBefore) : null
if (emoji) {
return { kind: ':', query: emoji[2], tokenLength: 1 + emoji[2].length }
}
return null
}

View file

@ -50,7 +50,7 @@ const ROW_BASE_CLASS = [
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
kind: '@' | '/'
kind: '@' | '/' | ':'
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
@ -93,6 +93,10 @@ export function ComposerTriggerPopover({
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : kind === ':' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
@ -154,6 +158,9 @@ export function ComposerTriggerPopover({
</span>
)}
</>
) : kind === ':' ? (
// Just the emoji + :shortcode:, Slack-style — no icon column.
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">

View file

@ -41,6 +41,7 @@ import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import { recordAgentReaction } from '@/store/reactions-local'
import {
$currentCwd,
$currentModel,
@ -53,6 +54,7 @@ import {
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setMessages,
setSessions,
setTurnStartedAt,
setYoloActive
@ -994,6 +996,53 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (isActiveEvent) {
revealDesktopPane(payload?.pane ?? '')
}
} else if (event.type === 'message.reaction') {
// The agent reacted to a message via the desktop-gated
// react_to_message tool. Already persisted — this only paints it now
// instead of at the next resume. Fresh ChatMessage object per change:
// the runtime repository caches normalized ThreadMessages in a WeakMap
// keyed by ChatMessage identity.
const reactedRowId = payload?.row_id
if (typeof reactedRowId === 'number') {
const nextReactions = Array.isArray(payload?.reactions) ? payload.reactions : []
const reactedRole = payload?.role === 'assistant' ? 'assistant' : 'user'
setMessages(messages => {
// Preferred leg: the message already knows its durable row id
// (rehydrated transcript, or a live row that has round-tripped).
const byRowId = messages.find(message => message.rowId === reactedRowId)
if (byRowId) {
// Overlay survives the end-of-turn resume, which rebuilds from
// in-memory history that doesn't carry this mid-turn DB write.
recordAgentReaction(reactedRowId, nextReactions)
return messages.map(message =>
message.rowId === reactedRowId ? { ...message, reactions: nextReactions } : message
)
}
// Live leg: the targeted message is still optimistic (no rowId —
// it hasn't round-tripped through a resume). The agent's default
// target is the newest message of that role, so stamp the reaction
// AND the now-known row id onto it. Without this the event matches
// nothing and the reaction only appears after a reload.
const lastIndex = messages.findLastIndex(
message => message.role === reactedRole && message.rowId === undefined
)
if (lastIndex === -1) {
return messages
}
recordAgentReaction(reactedRowId, nextReactions)
return messages.map((message, index) =>
index === lastIndex ? { ...message, rowId: reactedRowId, reactions: nextReactions } : message
)
})
}
} else if (event.type === 'status.update') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)

View file

@ -91,6 +91,7 @@ function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): C
// or reference identity the runtime already guarantees.
// timestamp — presentation-only (sort/age display), never affects transcript equality
// attachmentRefs — composer-side metadata; already reconciled in reconcileResumeMessages
// rowId — durable backend identity; stable for a given row, never changes what's painted
//
// If your new field affects what the user sees in the transcript, add it to
// COMPARED. If it's metadata that shouldn't trigger a re-render, add it to
@ -99,8 +100,17 @@ const _chatMessageFieldsExhaustive: {
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
} = {}
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
const COMPARED_FIELDS = [
'id',
'role',
'pending',
'error',
'hidden',
'branchGroupId',
'interim',
'reactions'
] as const
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
// Compile-time check: every ChatMessagePart discriminant must be handled by
// chatPartsEquivalent. If @assistant-ui adds a new part type, this fails tsc.
@ -173,6 +183,23 @@ export function chatPartsEquivalent(aPart: ChatMessage['parts'][number], bPart:
return aKeys.every(k => aPrimitive[k] === bPrimitive[k])
}
export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMessage['reactions']): boolean {
const aList = a ?? []
const bList = b ?? []
if (aList === bList) {
return true
}
return (
aList.length === bList.length &&
aList.every(
(reaction, index) =>
reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
)
)
}
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
if (
a.id !== b.id ||
@ -183,7 +210,8 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
a.branchGroupId !== b.branchGroupId ||
// Interim gates the action footer, so flipping it must repaint (e.g. a
// previewed final settling onto a sealed interim bubble restores the bar).
(a.interim ?? false) !== (b.interim ?? false)
(a.interim ?? false) !== (b.interim ?? false) ||
!chatReactionsEquivalent(a.reactions, b.reactions)
) {
return false
}
@ -260,6 +288,19 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
}
// Reactions and the row id come from the same authoritative rows as the
// text, but a live/optimistic row that hasn't round-tripped yet carries
// neither. Carry the cached copy forward so a reaction doesn't blink off
// mid-turn. NEW object every time — the runtime repository's WeakMap
// caches normalized ThreadMessages by ChatMessage identity.
if (sameTurn && preserved.rowId === undefined && previous.rowId !== undefined) {
preserved = { ...preserved, rowId: previous.rowId }
}
if (sameTurn && preserved.reactions === undefined && previous.reactions?.length) {
preserved = { ...preserved, reactions: [...previous.reactions] }
}
const previousImages = embeddedImageUrls(previousText)
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {

View file

@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
@ -250,6 +251,7 @@ export function AppearanceSettings() {
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const reactionsEnabled = useStore($reactionsEnabled)
const backdrop = useStore($backdrop)
const installs = useStore($marketplaceInstalls)
const profiles = useStore($profiles)
@ -472,6 +474,24 @@ export function AppearanceSettings() {
title={a.backdropTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setReactionsEnabled(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={reactionsEnabled ? 'on' : 'off'}
/>
}
description={a.reactionsDesc}
title={a.reactionsTitle}
/>
<ListRow
action={
<SegmentedControl

View file

@ -7,7 +7,7 @@ import {
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useMemo } from 'react'
import { type FC, useCallback, useMemo, useState } from 'react'
import {
contentHasVisibleText,
@ -15,6 +15,7 @@ import {
pickPrimaryPreviewTarget
} from '@/components/assistant-ui/thread/content'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
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 { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
@ -22,15 +23,23 @@ 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, VolumeXIcon, XIcon } from '@/lib/icons'
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { formatAgo } from '@/lib/time'
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
@ -135,8 +144,42 @@ 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 shownReactions = mergeReactions(
reactions,
localAll[messageId],
rowId !== undefined ? agentLive[rowId] : undefined
)
const react = 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)
},
[messageId, reactions, rowId]
)
return (
<div className="relative flex w-full shrink-0 justify-end">
<div className="relative flex w-full shrink-0 items-center justify-end gap-1.5">
<ActionBarPrimitive.Root
className={
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
@ -170,6 +213,42 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
{/* ONE slot, Slack-style: the picker trigger and the landed reaction are
the same element, so reacting never shifts layout. Empty , hidden
until hover like its action-bar neighbors (state lives in styles.css
the aui_msg-reactions rules outweigh Tailwind utilities here).
Reacted the emoji itself, always visible at full strength, and
clicking it reopens the picker to switch or retract. Outside
ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's
hover opacity. */}
{(reactionsEnabled || shownReactions.length > 0) && (
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={react}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
<TooltipIconButton
data-reacted={shownReactions.length > 0 || undefined}
data-slot="aui_msg-reactions"
data-state={pickerOpen ? 'open' : undefined}
onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined}
tooltip={copy.react}
>
{shownReactions.length > 0 ? (
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
{shownReactions.map(reaction => (
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
{reaction.emoji}
</span>
))}
</span>
) : (
<SmilePlusIcon className="size-3.5" />
)}
</TooltipIconButton>
</ReactionPicker>
)}
</div>
)
}

View file

@ -53,6 +53,13 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
return null
}
// A reaction's UI is the emoji landing on the bubble (message.reaction
// event) — a "React To Message" tool block next to it would be the agent
// narrating its own tapback. Failures still render so they're debuggable.
if (props.toolName === 'react_to_message' && !props.isError) {
return null
}
if (props.toolName === 'delegate_task') {
return <DelegateToolPart {...props} />
}

View file

@ -0,0 +1,211 @@
import { EmojiPicker } from 'frimousse'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { triggerHaptic } from '@/lib/haptics'
import { Plus } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { QUICK_REACTIONS } from '@/store/reactions'
import type { MessageReaction } from '@/types/hermes'
// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets`
// plugin bundles emojibase-data): Electron must work offline, and the app
// should never phone a CDN to draw a picker.
const EMOJIBASE_URL = './emojibase'
// Slack tints its picker cells in a repeating palette (green, blue, yellow,
// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the
// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns).
// Keyed off the emoji's codepoint — deterministic, and stable under
// frimousse's virtualized rows (an index cycle would reshuffle on scroll).
const CELL_TINTS = [
'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20',
'hover:bg-sky-500/15 data-[active]:bg-sky-500/20',
'hover:bg-amber-500/15 data-[active]:bg-amber-500/20',
'hover:bg-pink-500/15 data-[active]:bg-pink-500/20',
'hover:bg-orange-500/15 data-[active]:bg-orange-500/20',
'hover:bg-violet-500/15 data-[active]:bg-violet-500/20'
] as const
const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length]
/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */
const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => (
<EmojiPicker.Root
className="flex h-72 w-76 flex-col"
emojibaseUrl={EMOJIBASE_URL}
onEmojiSelect={emoji => onSelect(emoji.emoji)}
>
{/* Borderless, underline-on-focus the app's SearchField idiom (DESIGN.md),
not a boxed search bar. Search matches labels AND emojibase tags
("lol" 😂), which frimousse handles natively. */}
<EmojiPicker.Search
autoFocus
className="mx-1 border-b border-(--ui-stroke-tertiary) bg-transparent px-1 pb-1 text-sm outline-hidden focus:border-(--ui-stroke-secondary)"
placeholder="Search…"
/>
<EmojiPicker.Viewport className="relative flex-1 outline-hidden">
<EmojiPicker.Loading className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
Loading emoji
</EmojiPicker.Loading>
<EmojiPicker.Empty className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
No emoji found.
</EmojiPicker.Empty>
<EmojiPicker.List
className="select-none pb-1"
components={{
CategoryHeader: ({ category, ...props }) => (
<div
className="bg-(--ui-bg-elevated) px-1.5 pt-2 pb-1 text-[0.6875rem] text-(--ui-text-tertiary)"
{...props}
>
{category.label}
</div>
),
Emoji: ({ emoji, ...props }) => (
<button
className={cn('grid size-8 shrink-0 place-items-center rounded-md text-lg', cellTint(emoji.emoji))}
{...props}
>
{emoji.emoji}
</button>
),
Row: ({ children, ...props }) => (
<div className="flex px-1" {...props}>
{children}
</div>
)
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
)
/**
* The reaction picker six quick emoji, then "+" for the full set.
*
* Rides the shared Popover, so it inherits the app's menu/popover surface
* treatment rather than inventing a floating pill (DESIGN.md: popovers get one
* shared shadow + hairline; call sites don't reinvent elevation).
*/
export const ReactionPicker: FC<{
align?: 'end' | 'start'
children: React.ReactNode
onOpenChange: (open: boolean) => void
onSelect: (emoji: string) => void
open: boolean
selected?: string
}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => {
const [expanded, setExpanded] = useState(false)
return (
<Popover
onOpenChange={next => {
onOpenChange(next)
if (!next) {
// Always reopen on the quick row.
setExpanded(false)
}
}}
open={open}
>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
align={align}
// Opt this one surface out of the shared popover glass: emoji hover
// tints at 15% alpha are unreadable over blurred transcript text.
// Overriding the local surface var keeps the arrow matched for free.
className={cn(
'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
!expanded && 'flex gap-0.5'
)}
onCloseAutoFocus={event => event.preventDefault()}
side="top"
>
{expanded ? (
<FullEmojiPicker onSelect={onSelect} />
) : (
<>
{QUICK_REACTIONS.map(emoji => (
<Button
aria-label={emoji}
aria-pressed={selected === emoji}
className={cn('text-base', selected === emoji && 'bg-(--chrome-action-hover)')}
key={emoji}
onClick={() => {
triggerHaptic('selection')
onSelect(emoji)
}}
size="icon-sm"
variant="ghost"
>
{emoji}
</Button>
))}
<Button aria-label="More emoji" onClick={() => setExpanded(true)} size="icon-sm" variant="ghost">
<Plus />
</Button>
</>
)}
</PopoverContent>
</Popover>
)
}
/**
* The reactions a message carries.
*
* Flat by design (DESIGN.md: "Flat, not boxed") no pill, no border, no fill.
* It reads as quiet metadata in the same register as the message age and the
* checkpoint row it sits beside. Your own reaction is clickable to retract;
* the agent's is display-only.
*/
export const ReactionBadge: FC<{
className?: string
onRetract?: () => void
reactions: MessageReaction[]
}> = ({ className, onRetract, reactions }) => {
if (!reactions.length) {
return null
}
return (
<span
className={cn('flex items-center gap-1 text-[0.8125rem] leading-none', className)}
data-slot="aui_msg-reactions"
>
{reactions.map(reaction =>
reaction.author === 'user' && onRetract ? (
<button
aria-label={`Remove ${reaction.emoji} reaction`}
className="reaction-pop cursor-pointer leading-none transition-transform hover:scale-110 active:scale-95"
key={`${reaction.author}-${reaction.emoji}`}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRetract()
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
{reaction.emoji}
</button>
) : (
<span
className="reaction-pop leading-none"
key={`${reaction.author}-${reaction.emoji}`}
title="Reacted by Hermes"
>
{reaction.emoji}
</span>
)
)}
</span>
)
}

View file

@ -24,6 +24,7 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
@ -112,6 +113,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
const emoji = useEmojiCompletions()
// This is the one composer that routinely unmounts, so it is where the focus
// bus leaks: confirming or cancelling an edit tears the composer down while
@ -282,7 +284,13 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}, [])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
trigger?.kind === '@'
? at.adapter
: trigger?.kind === '/'
? slash.adapter
: trigger?.kind === ':'
? emoji.adapter
: null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@ -298,7 +306,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
const triggerLoading =
trigger?.kind === '@'
? at.loading
: trigger?.kind === '/'
? slash.loading
: trigger?.kind === ':'
? emoji.loading
: false
const replaceTriggerWithChip = useCallback(
(item: Unstable_TriggerItem) => {

View file

@ -1,18 +1,28 @@
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 { 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,
@ -144,6 +154,40 @@ 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 shownReactions = mergeReactions(
reactions,
localAll[messageId],
rowId !== undefined ? agentLive[rowId] : undefined
)
const react = 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)
},
[messageId, reactions, rowId]
)
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
// doesn't dominate the viewport while the response streams underneath; the
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
@ -257,84 +301,110 @@ export const UserMessage: FC<{
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
if (!bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
<ActionBarPrimitive.Edit asChild>
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={react}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
<div
className="relative w-full"
onContextMenu={
// Right-click is the desktop stand-in for iOS touch-and-hold.
readOnly || !reactionsEnabled
? undefined
: event => {
event.preventDefault()
setPickerOpen(true)
}
}
>
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
if (!bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
{StopGlyph}
{bubbleContent}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
type="button"
>
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
</ReactionPicker>
{/* Below the bubble, same register as the assistant action row:
same emoji size, same vertical padding, right-aligned to the
sent bubble. Overlaying the corner read badly in practice. */}
<ReactionBadge
className="justify-end gap-1.5 py-1.5 pr-1.5"
onRetract={() => react(null)}
reactions={shownReactions}
/>
<BranchPickerPrimitive.Root
className={cn(
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',

View file

@ -397,6 +397,8 @@ export const ar = defineLocale({
translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل. متاح على macOS وWindows فقط.',
backdropTitle: 'خلفية النافذة',
backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.',
reactionsTitle: 'تفاعلات الرسائل',
reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.',
embedsTitle: 'التضمينات المضمّنة',
embedsDesc:
'تُحمّل المعاينات الغنية من مواقع طرف ثالث (YouTube، X، …). "اسأل" يعرض عنصرا نائبا حتى تسمح لكل واحد؛ "دائما" يحمّلها تلقائيا؛ "إيقاف" يبقي الروابط عادية.',
@ -2284,6 +2286,7 @@ export const ar = defineLocale({
refresh: 'تحديث',
moreActions: 'إجراءات إضافية',
branchNewChat: 'تفريع إلى محادثة جديدة',
react: 'تفاعل',
dismissError: 'تجاهل الخطأ',
readAloudFailed: 'فشلت القراءة بصوت عال',
preparingAudio: 'جار تجهيز الصوت',

View file

@ -441,6 +441,8 @@ export const en: Translations = {
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
backdropTitle: 'Chat Backdrop',
backdropDesc: 'The faint statue image behind the conversation.',
reactionsTitle: 'Message Reactions',
reactionsDesc: 'iMessage-style emoji tapbacks — react to messages, and Hermes can react to yours.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
@ -2703,6 +2705,7 @@ export const en: Translations = {
refresh: 'Refresh',
moreActions: 'More actions',
branchNewChat: 'Branch in new chat',
react: 'React',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',

View file

@ -318,6 +318,9 @@ export const ja = defineLocale({
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
backdropTitle: 'チャット背景',
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
reactionsTitle: 'メッセージリアクション',
reactionsDesc:
'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイトYouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
@ -2533,6 +2536,7 @@ export const ja = defineLocale({
refresh: '更新',
moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ',
react: 'リアクション',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',

View file

@ -351,6 +351,8 @@ export interface Translations {
translucencyDesc: string
backdropTitle: string
backdropDesc: string
reactionsTitle: string
reactionsDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string
@ -2301,6 +2303,7 @@ export interface Translations {
refresh: string
moreActions: string
branchNewChat: string
react: string
dismissError: string
readAloudFailed: string
preparingAudio: string

View file

@ -310,6 +310,8 @@ export const zhHant = defineLocale({
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '對話後方那張淡淡的雕像圖片。',
reactionsTitle: '訊息回應',
reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應Hermes 也能回應你的訊息。',
embedsTitle: '內嵌預覽',
embedsDesc:
'豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
@ -2452,6 +2454,7 @@ export const zhHant = defineLocale({
refresh: '重新整理',
moreActions: '更多動作',
branchNewChat: '在新聊天中分支',
react: '回應',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',

View file

@ -433,6 +433,8 @@ export const zh: Translations = {
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '对话后方那张淡淡的雕像图片。',
reactionsTitle: '消息回应',
reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应Hermes 也能回应你的消息。',
embedsTitle: '内嵌预览',
embedsDesc:
'富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
@ -2879,6 +2881,7 @@ export const zh: Translations = {
refresh: '刷新',
moreActions: '更多操作',
branchNewChat: '在新对话中分支',
react: '回应',
dismissError: '关闭错误',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',

View file

@ -6,7 +6,7 @@ import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { normalize } from '@/lib/text'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage, UsageStats } from '@/types/hermes'
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
@ -25,6 +25,10 @@ export type ChatMessage = {
interim?: boolean
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
attachmentRefs?: string[]
/** Durable backend `messages.id`. Absent until the row is persisted. */
rowId?: number
/** Emoji reactions on this message — one per author (see MessageReaction). */
reactions?: MessageReaction[]
}
export type GatewayEventPayload = {
@ -84,6 +88,12 @@ export type GatewayEventPayload = {
kind?: string
// pane.reveal (agent focusing a desktop pane via the focus_pane tool)
pane?: string
// message.reaction (agent reacting via the react_to_message tool) — the
// durable messages.id, that row's full reaction list after the write, and
// the row's role so a live (not-yet-round-tripped) message can be matched.
row_id?: number
reactions?: MessageReaction[]
role?: string
// session.title (live auto-title push) — stored session id + generated title
session_id?: string
title?: string
@ -334,26 +344,38 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
// A remote backend older than this app serves display_metadata as raw JSON text,
// and `in` throws on a primitive — which used to fail the whole session resume.
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record<string, unknown> {
let parsed: unknown = metadata
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
return undefined
return null
}
}
if (!parsed || typeof parsed !== 'object') {
return undefined
}
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
}
const count = (parsed as { task_count?: unknown }).task_count
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
const count = parseDisplayMetadata(metadata)?.task_count
return typeof count === 'number' ? count : undefined
}
export function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] {
const reactions = parseDisplayMetadata(metadata)?.reactions
if (!Array.isArray(reactions)) {
return []
}
return reactions.filter(
(r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string'
)
}
function timelineDisplayContent(message: SessionMessage, content: string): string {
if (message.display_kind === 'model_switch') {
return 'model changed'
@ -1044,11 +1066,19 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
flushPendingTools(index)
}
const reactions = messageReactions(message.display_metadata)
// Gateway resume names the durable row id `row_id`; the REST transcript
// prefetch ships the same messages.id as a numeric `id`. Either one lets
// reactions address this exact row later.
const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined)
result.push({
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
role: displayRole,
parts,
timestamp: message.timestamp,
...(rowId !== undefined ? { rowId } : {}),
...(reactions.length ? { reactions } : {}),
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
})

View file

@ -387,6 +387,13 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
const createdAt = messageCreatedAt(message)
// Reactions and the durable row id ride metadata.custom for every role — the
// established channel for per-message extras (attachmentRefs below).
const reactionMeta = {
...(message.rowId !== undefined ? { rowId: message.rowId } : {}),
...(message.reactions?.length ? { reactions: message.reactions } : {})
}
if (role === 'user') {
return {
id: message.id,
@ -394,7 +401,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
attachments: [],
createdAt,
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta } }
} as ThreadMessage
}
@ -426,7 +433,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
unstable_data: [],
steps: [],
// Carries ChatMessage.interim to AssistantMessage's footer gate.
custom: message.interim ? { interim: true } : {}
custom: { ...(message.interim ? { interim: true } : {}), ...reactionMeta }
}
} as ThreadMessage
}

View file

@ -101,6 +101,7 @@ import {
IconSettings as Settings,
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconMoodPlus as SmilePlusIcon,
IconSquare as Square,
IconChartDots3 as Starmap,
IconSteeringWheel as SteeringWheel,
@ -226,6 +227,7 @@ export {
Settings,
Settings2,
SlidersHorizontal,
SmilePlusIcon,
Square,
Starmap,
SteeringWheel,

View file

@ -0,0 +1,40 @@
/**
* Message reactions (iMessage-style tapbacks) opt-in.
*
* Off by default: reactions add affordances to every message row (the slot,
* right-click pickers, :shortcode: completions), and the agent gains a tool
* that reacts to your messages. Presentation-scoped, so the renderer owns it
* (desktop AGENTS.md: state lives with its authority).
*
* Gates the UI only persisted reactions still render if the data exists
* (a reaction you set before turning it off shouldn't vanish from history).
*/
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { activeGateway } from '@/store/gateway'
const KEY = 'hermes.desktop.reactions.v1'
export const $reactionsEnabled = atom<boolean>(typeof window === 'undefined' ? false : storedString(KEY) === 'on')
export function setReactionsEnabled(enabled: boolean): void {
$reactionsEnabled.set(enabled)
}
if (typeof window !== 'undefined') {
// listen, not subscribe: fire on CHANGE only, so app startup doesn't write
// config.set (or clobber a profile's setting with another window's default).
$reactionsEnabled.listen(enabled => {
persistString(KEY, enabled ? 'on' : 'off')
// Mirror into gateway config: the backend gates the agent's
// react_to_message tool and the model-context annotation on
// display.message_reactions, so the renderer toggle is the one lever.
void activeGateway()
?.request('config.set', { key: 'display.message_reactions', value: enabled ? 'true' : 'false' })
.catch(() => {
// Not connected yet — the next toggle (or default-off) still holds.
})
})
}

View file

@ -0,0 +1,67 @@
import { atom } from 'nanostores'
import { applyReaction } from '@/store/reactions'
import type { MessageReaction } from '@/types/hermes'
/**
* Reactions the user has set in THIS window, keyed by renderer message id.
*
* The UI owns this outright. A tapback is a direct manipulation it flips the
* instant you click it, with no round-trip, no gateway, and no dependency on a
* message having been persisted yet. Durable state (and the agent's own
* reactions) still arrive through `metadata.custom.reactions`; this layer sits
* on top of it so the interaction never waits on the backend to feel alive.
*/
export const $localReactions = atom<Record<string, MessageReaction[]>>({})
/**
* Agent reactions announced live (`message.reaction` events), keyed by the
* DURABLE row id never the renderer message id, which the end-of-turn
* resume regenerates (an overlay keyed on the old id would orphan the instant
* the transcript rebuilds; "identity is not incidental", AGENTS.md). The
* resume also rebuilds from the gateway's in-memory history, which doesn't
* carry a reaction written to the DB mid-turn this overlay outlives that
* clobber and a real reload hydrates the same reaction from disk.
*/
export const $agentReactions = atom<Record<number, MessageReaction[]>>({})
/** Record an agent reaction painted from a live gateway event. */
export function recordAgentReaction(rowId: number, reactions: MessageReaction[]): void {
$agentReactions.set({
...$agentReactions.get(),
[rowId]: reactions.filter(reaction => reaction.author === 'agent')
})
}
/**
* Merge the durable reaction list with anything this window knows live.
*
* The user's slot: local wins (they just clicked it newer by definition).
* The agent's slot: the live-event overlay wins over persisted (a mid-turn
* reaction reaches the DB before the in-memory history the next resume
* projects from), falling back to what the transcript carried.
*/
export function mergeReactions(
persisted: MessageReaction[] | undefined,
local: MessageReaction[] | undefined,
agentLive?: MessageReaction[]
): MessageReaction[] {
const persistedList = persisted ?? []
const userSide = local
? local.filter(reaction => reaction.author === 'user')
: persistedList.filter(reaction => reaction.author === 'user')
const agentSide = agentLive ?? persistedList.filter(reaction => reaction.author === 'agent')
return [...userSide, ...agentSide]
}
/** Toggle the user's reaction on a message — instant, local, no round-trip. */
export function setLocalReaction(messageId: string, emoji: null | string): MessageReaction[] {
const next = applyReaction($localReactions.get()[messageId], emoji, 'user')
$localReactions.set({ ...$localReactions.get(), [messageId]: next })
return next
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { applyReaction, QUICK_REACTIONS } from '@/store/reactions'
import type { MessageReaction } from '@/types/hermes'
const at = 1_700_000_000
function reaction(emoji: string, author: MessageReaction['author']): MessageReaction {
return { emoji, author, at }
}
describe('applyReaction', () => {
it('adds a reaction to an empty message', () => {
expect(applyReaction(undefined, '❤️', 'user')).toMatchObject([{ emoji: '❤️', author: 'user' }])
})
it('replaces the same authors existing reaction (one per author)', () => {
const next = applyReaction([reaction('❤️', 'user')], '😂', 'user')
expect(next).toHaveLength(1)
expect(next[0].emoji).toBe('😂')
})
it('retracts when the live reaction is re-sent', () => {
expect(applyReaction([reaction('👍', 'user')], '👍', 'user')).toEqual([])
})
it('clears on an explicit null', () => {
expect(applyReaction([reaction('👍', 'user')], null, 'user')).toEqual([])
})
it('keeps authors independent', () => {
const next = applyReaction([reaction('🔥', 'agent')], '❤️', 'user')
expect(next.map(r => r.author).sort()).toEqual(['agent', 'user'])
})
it('retracting one author leaves the other intact', () => {
const next = applyReaction([reaction('🔥', 'agent'), reaction('❤️', 'user')], null, 'user')
expect(next).toMatchObject([{ emoji: '🔥', author: 'agent' }])
})
it('never mutates the input array', () => {
const before = [reaction('❤️', 'user')]
const snapshot = [...before]
applyReaction(before, '😂', 'user')
expect(before).toEqual(snapshot)
})
})
describe('QUICK_REACTIONS', () => {
it('is the six iOS Tapback defaults, each distinct', () => {
expect(QUICK_REACTIONS).toHaveLength(6)
expect(new Set(QUICK_REACTIONS).size).toBe(6)
})
})

View file

@ -0,0 +1,93 @@
import type { ChatMessage } from '@/lib/chat-messages'
import { activeGateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $activeSessionId, $messages, setMessages } from '@/store/session'
import type { MessageReaction } from '@/types/hermes'
/** The six iOS Tapback defaults, in Apple's order. */
export const QUICK_REACTIONS = ['❤️', '👍', '👎', '😂', '‼️', '❓'] as const
interface MessageReactResponse {
row_id: number
reactions: MessageReaction[]
}
/** Apply the local half of a tapback: one reaction per author, re-tap retracts. */
export function applyReaction(
reactions: MessageReaction[] | undefined,
emoji: null | string,
author: MessageReaction['author']
): MessageReaction[] {
const current = reactions ?? []
const previous = current.find(reaction => reaction.author === author)
const without = current.filter(reaction => reaction.author !== author)
if (!emoji || previous?.emoji === emoji) {
return without
}
return [...without, { emoji, author, at: Date.now() / 1000 }]
}
function writeReactions(messageId: string, reactions: MessageReaction[], rowId?: number) {
// A NEW ChatMessage object per change is load-bearing: the runtime
// repository caches normalized ThreadMessages in a WeakMap keyed by
// ChatMessage identity, so a mutation in place renders stale.
// Keyed by the renderer id, not rowId: a live message has no rowId yet.
setMessages(messages =>
messages.map(message =>
message.id === messageId ? { ...message, reactions, ...(rowId === undefined ? {} : { rowId }) } : message
)
)
}
/**
* Toggle *author*'s reaction on a persisted message.
*
* Optimistic: paints immediately, then lets the backend's returned list win.
* A failed write rolls back to the snapshot (desktop AGENTS.md "be optimistic,
* then honest").
*/
export async function toggleMessageReaction(
message: ChatMessage,
emoji: null | string,
author: MessageReaction['author'] = 'user'
): Promise<void> {
// A live message hasn't round-tripped through a resume yet, so it carries no
// rowId. Rather than disable the affordance (which made reactions invisible
// in any active conversation), let the backend resolve the newest row of
// this role — which is exactly the message being reacted to.
const rowId = message.rowId
const sessionId = $activeSessionId.get()
const gateway = activeGateway()
if (!sessionId || !gateway) {
notifyError(
new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
'Could not react'
)
return
}
const snapshot = $messages.get().find(m => m.id === message.id)?.reactions
writeReactions(message.id, applyReaction(snapshot, emoji, author))
try {
const result = await gateway.request<MessageReactResponse>('message.react', {
session_id: sessionId,
...(rowId === undefined ? { newest_role: message.role } : { row_id: rowId }),
emoji,
author
})
// Learn the row id from the response so later toggles address it directly.
writeReactions(message.id, result?.reactions ?? [], result?.row_id)
} catch (err) {
// Be optimistic, THEN honest: a rejected write rolls back visibly and says
// why, instead of the reaction quietly vanishing (desktop AGENTS.md).
writeReactions(message.id, snapshot ?? [])
notifyError(err, 'Could not react')
}
}

View file

@ -1638,18 +1638,21 @@ text-* variant utilities. */ .btn-arc {
an open diff is excluded it keeps the full gap, same as in-flow. */
[data-slot='aui_turn-pair']
> [data-slot='aui_assistant-message-root']:has(
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
):not(:has([data-file-edit]))
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
):not(:has([data-file-edit]))
+ [data-slot='aui_assistant-message-root']:has(
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
):not(:has([data-file-edit])) {
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
):not(:has([data-file-edit])) {
margin-top: calc(var(--scaffold-block-gap) - var(--conversation-turn-gap));
}
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
[data-slot='aui_msg-actions'] button {
/* Message action bars flat icon hits with default dim; only the hovered/focused control is full-strength.
The reaction slot lives OUTSIDE the bar (a landed emoji must not ride the
bar's hover fade) but is still one of these controls visually. */
[data-slot='aui_msg-actions'] button,
button[data-slot='aui_msg-reactions'] {
border: 0;
border-radius: 0;
background: transparent;
@ -1666,25 +1669,65 @@ text-* variant utilities. */ .btn-arc {
opacity: 0.5;
}
/* A landed reaction is content, not a dimmed affordance emoji render at
full strength everywhere, always (color dim would gray them anyway; they're
glyphs, not icons). */
button[data-slot='aui_msg-reactions'][data-reacted] {
opacity: 1;
color: inherit;
}
/* The EMPTY slot is an affordance and follows its action-bar neighbors
exactly: hidden until the message is hovered, then the same 0.5 dim, full
strength under the pointer. Stylesheet-owned because the base rule above
outweighs Tailwind's opacity utilities (0,1,1 vs 0,1,0). */
button[data-slot='aui_msg-reactions']:not([data-reacted]) {
opacity: 0;
pointer-events: none;
}
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]),
button[data-slot='aui_msg-reactions']:not([data-reacted]):focus-visible,
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
opacity: 0.5;
pointer-events: auto;
}
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]):hover,
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
opacity: 1;
}
/* Emoji in ANY reaction surface (badge under a user bubble, footer slot,
picker rows) never inherit a translucent treatment from their container. */
[data-slot='aui_msg-reactions'] .reaction-pop,
span[data-slot='aui_msg-reactions'] {
opacity: 1;
}
[data-slot='aui_msg-actions'] button:disabled {
cursor: default;
}
[data-slot='aui_msg-actions'] button:hover {
[data-slot='aui_msg-actions'] button:hover,
button[data-slot='aui_msg-reactions']:hover {
background: transparent;
color: var(--color-foreground);
opacity: 1;
}
[data-slot='aui_msg-actions'] button:active {
[data-slot='aui_msg-actions'] button:active,
button[data-slot='aui_msg-reactions']:active {
background: transparent;
}
[data-slot='aui_msg-actions'] button:focus-visible {
[data-slot='aui_msg-actions'] button:focus-visible,
button[data-slot='aui_msg-reactions']:focus-visible {
opacity: 1;
}
[data-slot='aui_msg-actions'] button svg {
[data-slot='aui_msg-actions'] button svg,
button[data-slot='aui_msg-reactions'] svg {
width: 0.875rem;
height: 0.875rem;
}
@ -2014,6 +2057,26 @@ text-* variant utilities. */ .btn-arc {
}
}
@keyframes reaction-pop {
0% {
opacity: 0;
transform: scale(0.4);
}
60% {
opacity: 1;
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
/* Landing a reaction should feel like something same pop shape as
pet-reveal-pop, scaled down for an inline glyph. */
.reaction-pop {
animation: reaction-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.pet-egg,
.pet-egg__glow,
@ -2021,6 +2084,9 @@ text-* variant utilities. */ .btn-arc {
.pet-reveal {
animation: none;
}
.reaction-pop {
animation: none;
}
.pet-reveal {
opacity: 1;
transform: none;

View file

@ -524,6 +524,15 @@ export type TimelineDisplayMetadata =
failed_count?: number
duration_seconds?: number
}
| { reactions: MessageReaction[] }
/** One emoji reaction on a message. One per author, iOS-Tapback style. */
export interface MessageReaction {
emoji: string
author: 'agent' | 'user'
/** Epoch seconds. */
at: number
}
export interface SessionMessage {
codex_reasoning_items?: unknown
@ -540,6 +549,17 @@ export interface SessionMessage {
*/
display_metadata?: string | TimelineDisplayMetadata
role: 'assistant' | 'system' | 'tool' | 'user'
/**
* Durable `messages.id` from the backend. The renderer's own message ids are
* ephemeral (derived from timestamp+index, and a different shape for live vs
* rehydrated vs optimistic rows), so anything addressing a specific persisted
* message reactions keys off this. Absent on a backend older than this app.
*
* The gateway resume path names it `row_id`; the REST transcript path
* (`SELECT *`) ships the same value as a numeric `id`. Read both.
*/
row_id?: number
id?: number
text?: unknown
timestamp?: number
tool_call_id?: null | string

View file

@ -36,9 +36,47 @@ const debugEntry = (command: string, env: Record<string, string>) =>
? path.resolve(__dirname, './src/debug/dev-only.ts')
: path.resolve(__dirname, './src/debug/dev-only.noop.ts')
// The emoji picker (frimousse) fetches `<emojibaseUrl>/<locale>/data.json` at
// runtime. Its default is a CDN; Electron must work offline, so serve the
// bundled emojibase-data package at a stable local path instead — middleware
// in dev, emitted assets in the build. Only the files a locale actually needs.
const emojibaseDir =
real(path.resolve(__dirname, 'node_modules/emojibase-data')) ??
real(path.resolve(__dirname, '../../node_modules/emojibase-data'))
const EMOJIBASE_PATH = /^[a-z-]+\/(data|messages|shortcodes\/emojibase)\.json$/
const emojibaseAssets = () => ({
name: 'hermes:emojibase-assets',
configureServer(server: {
middlewares: { use: (route: string, handler: (req: any, res: any, next: () => void) => void) => void }
}) {
server.middlewares.use('/emojibase', (req, res, next) => {
const rel = (req.url ?? '').split('?')[0].replace(/^\/+/, '')
if (!emojibaseDir || !EMOJIBASE_PATH.test(rel)) return next()
fs.readFile(path.join(emojibaseDir, rel), (err: unknown, buf: Buffer) => {
if (err) return next()
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
res.end(buf)
})
})
},
generateBundle(this: { emitFile: (asset: { type: 'asset'; fileName: string; source: Uint8Array }) => void }) {
if (!emojibaseDir) return
for (const rel of ['en/data.json', 'en/messages.json', 'en/shortcodes/emojibase.json']) {
this.emitFile({
type: 'asset',
fileName: `emojibase/${rel}`,
source: fs.readFileSync(path.join(emojibaseDir, rel))
})
}
}
})
export default defineConfig(({ command }) => ({
base: './',
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), emojibaseAssets()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and

View file

@ -5647,6 +5647,214 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
return bool(self._execute_write(_do))
#: Key under which message reactions live inside ``display_metadata``.
#: Reactions share the existing per-message JSON column rather than a side
#: table so they survive rewind/compaction row rewrites with the row itself.
REACTIONS_METADATA_KEY = "reactions"
def set_message_reaction(
self,
session_id: str,
message_row_id: int,
emoji: Optional[str],
*,
author: str = "user",
) -> Optional[List[Dict[str, Any]]]:
"""Set (or with ``emoji=None`` clear) *author*'s reaction on one message.
iOS Tapback semantics: one reaction per author per message. Re-sending
the same emoji clears it, a different emoji replaces it. Returns the
message's full reaction list after the write, or ``None`` when the row
doesn't exist or isn't part of *session_id*.
"""
if not session_id or message_row_id is None:
return None
def _do(conn):
row = conn.execute(
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
(message_row_id, session_id),
).fetchone()
if row is None:
return None
meta = self._decode_display_metadata(row[0]) or {}
existing = meta.get(self.REACTIONS_METADATA_KEY)
reactions = [
r
for r in (existing if isinstance(existing, list) else [])
if isinstance(r, dict) and r.get("author") != author
]
previous = next(
(
r
for r in (existing if isinstance(existing, list) else [])
if isinstance(r, dict) and r.get("author") == author
),
None,
)
# Tapping the live reaction again retracts it.
toggling_off = (
emoji is not None and previous is not None and previous.get("emoji") == emoji
)
if emoji and not toggling_off:
reactions.append(
{"emoji": _scrub_surrogates(emoji), "author": author, "at": time.time()}
)
if reactions:
meta[self.REACTIONS_METADATA_KEY] = reactions
else:
meta.pop(self.REACTIONS_METADATA_KEY, None)
conn.execute(
"UPDATE messages SET display_metadata = ? WHERE id = ?",
(self._encode_display_metadata(meta) if meta else None, message_row_id),
)
return reactions
return self._execute_write(_do)
def get_message_reactions(
self, session_id: str, message_row_id: int
) -> List[Dict[str, Any]]:
"""Return the reaction list persisted on one message row (never ``None``)."""
if not session_id or message_row_id is None:
return []
with self._lock:
row = self._conn.execute(
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
(message_row_id, session_id),
).fetchone()
if row is None:
return []
meta = self._decode_display_metadata(row[0]) or {}
reactions = meta.get(self.REACTIONS_METADATA_KEY)
return [r for r in reactions if isinstance(r, dict)] if isinstance(reactions, list) else []
def take_unseen_reactions(
self, session_id: str, *, author: str = "user"
) -> List[Dict[str, Any]]:
"""Return *author*'s not-yet-surfaced reactions and mark them seen.
Powers the cache-safe model-context path: reactions are announced on the
NEXT user turn (never by rewriting the message that was reacted to), and
the ``seen`` stamp guarantees each one is announced exactly once.
"""
if not session_id:
return []
def _do(conn):
rows = conn.execute(
"SELECT id, role, content, display_metadata FROM messages "
"WHERE session_id = ? AND active = 1 AND display_metadata IS NOT NULL "
"ORDER BY id",
(session_id,),
).fetchall()
pending = []
for row in rows:
meta = self._decode_display_metadata(row["display_metadata"])
if not meta:
continue
reactions = meta.get(self.REACTIONS_METADATA_KEY)
if not isinstance(reactions, list):
continue
changed = False
for reaction in reactions:
if (
not isinstance(reaction, dict)
or reaction.get("author") != author
or reaction.get("seen")
):
continue
reaction["seen"] = True
changed = True
content = self._decode_content(row["content"])
pending.append(
{
"row_id": row["id"],
"role": row["role"],
"emoji": reaction.get("emoji") or "",
"text": content if isinstance(content, str) else "",
}
)
if changed:
conn.execute(
"UPDATE messages SET display_metadata = ? WHERE id = ?",
(self._encode_display_metadata(meta), row["id"]),
)
return pending
return self._execute_write(_do) or []
def latest_message_row_id(
self, session_id: str, *, role: str = "user", offset: int = 0, require_text: bool = True
) -> Optional[int]:
"""Row id of the most recent active message with *role*, or ``None``.
Two callers, same need "the message I mean, without an id": the agent
defaulting to the turn that triggered it, and the desktop reacting to a
live message that hasn't round-tripped through a resume yet.
``offset`` steps to earlier turns (1 = the one before the latest) so a
reaction can land retroactively "two messages ago" is how the caller
thinks about it.
``require_text`` (default) skips rows with no plain-text content
tool-call-only assistant turns and attachment stubs don't render as
bubbles, so "the latest message" as a HUMAN means it must never
resolve to one (a reaction landing on an invisible row looks dropped,
and its annotation quotes an empty string).
"""
if not session_id or role not in {"user", "assistant"} or offset < 0:
return None
text_filter = (
"AND content IS NOT NULL AND TRIM(content) != '' " if require_text else ""
)
with self._lock:
row = self._conn.execute(
"SELECT id FROM messages WHERE session_id = ? AND role = ? "
f"AND active = 1 {text_filter}ORDER BY id DESC LIMIT 1 OFFSET ?",
(session_id, role, int(offset)),
).fetchone()
return row[0] if row else None
def latest_user_message_row_id(self, session_id: str) -> Optional[int]:
"""Row id of the most recent active user message, or ``None``.
The agent's default reaction target: "the message that triggered me",
so the model never has to thread row ids through a tool call (mirrors
the photon adapter's ``_record_last_inbound``).
"""
return self.latest_message_row_id(session_id, role="user")
def get_message_role(self, session_id: str, row_id: int) -> Optional[str]:
"""Role of the active message at *row_id* in *session_id*, or ``None``.
Lets a reaction event carry the target's role so a renderer can match
a live message that doesn't know its durable row id yet.
"""
if not session_id:
return None
with self._lock:
row = self._conn.execute(
"SELECT role FROM messages WHERE id = ? AND session_id = ? AND active = 1",
(int(row_id), session_id),
).fetchone()
return row[0] if row else None
def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, Any]]) -> tuple[int, int]:
"""Insert *messages* as fresh active rows for *session_id*.
@ -6127,6 +6335,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
include_ancestors: bool = False,
include_inactive: bool = False,
repair_alternation: bool = False,
include_row_ids: bool = False,
) -> List[Dict[str, Any]]:
"""
Load messages in the OpenAI conversation format (role + content dicts).
@ -6154,10 +6363,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
with self._lock:
placeholders = ",".join("?" for _ in session_ids)
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, "
"api_content, display_kind, display_metadata "
f"SELECT {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders})"
# Order by AUTOINCREMENT id (true insertion order), NOT timestamp:
# append_message stamps rows with time.time(), which is not
@ -6176,13 +6382,14 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
session_id=session_id,
include_ancestors=include_ancestors,
repair_alternation=repair_alternation,
include_row_ids=include_row_ids,
)
# Columns every conversation projection decodes. Shared by
# get_messages_as_conversation and get_resume_conversations so a single
# SELECT can feed both the model-fed and display views.
_CONVERSATION_ROW_COLUMNS = (
"role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, "
"api_content, display_kind, display_metadata"
@ -6195,6 +6402,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
session_id: str,
include_ancestors: bool,
repair_alternation: bool,
include_row_ids: bool = False,
) -> List[Dict[str, Any]]:
"""Decode fetched message rows into the OpenAI conversation format.
@ -6209,6 +6417,14 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
# Durable per-message identity for surfaces that need to address a
# specific row later (desktop reactions). OPT-IN: only the gateway
# asks for it — every other consumer (ACP restore, export,
# inspection) gets the transcript in its historical shape.
# Underscore-prefixed so every transport's convert_messages()
# strips it before the wire.
if include_row_ids and row["id"] is not None:
msg["_row_id"] = row["id"]
# api_content is the byte-fidelity sidecar: the exact string sent
# to the API when it differed from the clean content. Returned
# VERBATIM — no sanitize_context, no strip — because the replay
@ -6345,12 +6561,14 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
session_id=session_id,
include_ancestors=False,
repair_alternation=True,
include_row_ids=True,
)
display_history = self._rows_to_conversation(
rows,
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
include_row_ids=True,
)
return model_history, display_history

48
package-lock.json generated
View file

@ -120,7 +120,9 @@
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"emojibase-data": "^16.0.3",
"fflate": "^0.8.3",
"frimousse": "^0.3.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@ -19784,6 +19786,52 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/emojibase": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz",
"integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.12.0"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
}
},
"node_modules/emojibase-data": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-16.0.3.tgz",
"integrity": "sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==",
"license": "MIT",
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
},
"peerDependencies": {
"emojibase": "*"
}
},
"node_modules/frimousse": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz",
"integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==",
"license": "MIT",
"workspaces": [
".",
"site"
],
"peerDependencies": {
"react": "^18 || ^19",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,175 @@
"""Message reactions: persistence, tapback semantics, and cache safety.
Behavior contracts, not snapshots these assert how reactions must RELATE to
the transcript (one per author, announced once, never mutating history), so
they survive refactors of where reactions are stored.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
return SessionDB(db_path=tmp_path / "state.db")
@pytest.fixture
def session(db):
key = db.create_session("react-test", "test")
db.append_message(key, "user", "how do i center a div")
db.append_message(key, "assistant", "use flexbox")
rows = [m["_row_id"] for m in db.get_messages_as_conversation(key, include_row_ids=True)]
return key, rows
def test_conversation_rows_carry_durable_row_id(session, db):
"""Every projected message exposes its messages.id — reactions key off it."""
key, rows = session
assert all(isinstance(r, int) for r in rows)
assert rows == sorted(rows), "row ids must follow insertion order"
assert len(set(rows)) == len(rows), "row ids must be unique"
def test_one_reaction_per_author(session, db):
"""A second emoji from the same author REPLACES the first (iOS Tapback)."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f602", author="user")
assert [r["emoji"] for r in reactions] == ["\U0001f602"]
def test_repeating_an_emoji_retracts_it(session, db):
"""Tapping the live reaction again clears it."""
key, rows = session
db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
assert reactions == []
assert db.get_message_reactions(key, rows[0]) == []
def test_authors_are_independent(session, db):
"""User and agent each hold their own slot on the same message."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f525", author="agent")
assert {r["author"] for r in reactions} == {"user", "agent"}
remaining = db.set_message_reaction(key, rows[0], None, author="user")
assert [r["author"] for r in remaining] == ["agent"]
def test_rejects_rows_outside_the_session(session, db):
"""A row id from another conversation is never writable."""
key, rows = session
other = db.create_session("other", "test")
db.append_message(other, "user", "elsewhere")
assert db.set_message_reaction(key, 9999, "\u2764\ufe0f") is None
assert db.set_message_reaction("no-such-session", rows[0], "\u2764\ufe0f") is None
def test_clearing_every_reaction_leaves_no_metadata(session, db):
"""An empty reaction set removes the key instead of persisting `[]`."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
db.set_message_reaction(key, rows[0], None, author="user")
message = db.get_messages_as_conversation(key)[0]
assert "display_metadata" not in message
def test_reactions_survive_reload(session, db):
"""Reactions are durable, not in-memory display state."""
key, rows = session
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
reopened = SessionDB(db_path=db.db_path)
assert [r["emoji"] for r in reopened.get_message_reactions(key, rows[1])] == ["\U0001f525"]
def test_unseen_reactions_are_taken_exactly_once(session, db):
"""The model is told about a reaction on ONE turn, never twice."""
key, rows = session
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
first = db.take_unseen_reactions(key, author="user")
assert [e["emoji"] for e in first] == ["\u2764\ufe0f"]
assert first[0]["row_id"] == rows[1]
assert first[0]["text"] == "use flexbox"
assert db.take_unseen_reactions(key, author="user") == []
def test_a_new_reaction_becomes_unseen_again(session, db):
"""Replacing a seen reaction re-arms the announcement."""
key, rows = session
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
db.take_unseen_reactions(key, author="user")
db.set_message_reaction(key, rows[1], "\U0001f525", author="user")
assert [e["emoji"] for e in db.take_unseen_reactions(key, author="user")] == ["\U0001f525"]
def test_take_unseen_filters_by_author(session, db):
"""The agent's own reactions are never fed back to it as user input."""
key, rows = session
db.set_message_reaction(key, rows[0], "\U0001f60a", author="agent")
assert db.take_unseen_reactions(key, author="user") == []
def test_reacting_never_mutates_message_content(session, db):
"""CACHE SAFETY: reacting must not rewrite any already-sent message.
Rewriting a past message would invalidate the provider's cached prefix for
the whole conversation the reason reactions ride display_metadata and are
announced on the NEXT turn instead.
"""
key, rows = session
before = [m["content"] for m in db.get_messages_as_conversation(key)]
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
db.take_unseen_reactions(key, author="user")
after = [m["content"] for m in db.get_messages_as_conversation(key)]
assert before == after
def test_latest_user_message_is_the_agents_default_target(session, db):
"""The agent reacts to "the message that triggered me" without an id."""
key, rows = session
assert db.latest_user_message_row_id(key) == rows[0]
db.append_message(key, "user", "thanks!")
newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"]
assert db.latest_user_message_row_id(key) == newest
def test_row_id_is_opt_in_and_never_reaches_the_provider(session, db):
"""Only include_row_ids=True consumers see _row_id — and it's underscore-
prefixed so transports strip it before the wire even for them. Default
consumers (ACP restore, export) get the transcript in its historical shape.
"""
key, _rows = session
for message in db.get_messages_as_conversation(key):
assert "_row_id" not in message
for message in db.get_messages_as_conversation(key, include_row_ids=True):
assert "_row_id" in message
assert all(not k.startswith("_") or k == "_row_id" for k in message)

View file

@ -2405,7 +2405,7 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False, **_kwargs):
captured.setdefault("history_calls", []).append((target, include_ancestors))
return (
[
@ -2475,7 +2475,7 @@ def test_live_visible_history_prefers_db_display_with_candidate():
class DB:
def get_messages_as_conversation(
self, key, include_ancestors=False, repair_alternation=False
self, key, include_ancestors=False, repair_alternation=False, **_kwargs
):
assert key == "s1"
assert include_ancestors is True
@ -2539,7 +2539,7 @@ def test_live_visible_history_keeps_candidate_and_fresh_tail():
]
class DB:
def get_messages_as_conversation(self, key, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, key, include_ancestors=False, repair_alternation=False, **_kwargs):
return list(db_display)
result = server._live_visible_history({"session_key": "s1"}, DB(), in_memory)
@ -2777,7 +2777,7 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False, **_kwargs):
return [{"role": "user", "content": "hello"}]
def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
@ -2846,7 +2846,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False, **_kwargs):
return [{"role": "user", "content": "hello"}]
def update_session_cwd(self, *_args):
@ -7398,7 +7398,7 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False):
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False, **_kwargs):
assert key == "session-key"
assert include_ancestors is True
return list(history_from_db)
@ -10677,7 +10677,7 @@ def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
seen: dict = {}
class LaunchDB:
def get_messages_as_conversation(self, _key, include_ancestors=True):
def get_messages_as_conversation(self, _key, include_ancestors=True, **_kwargs):
seen["launch"] = True
return [{"role": "user", "content": "launch"}]
@ -10685,7 +10685,7 @@ def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
def __init__(self, db_path=None):
seen["db_path"] = db_path
def get_messages_as_conversation(self, _key, include_ancestors=True):
def get_messages_as_conversation(self, _key, include_ancestors=True, **_kwargs):
seen["profile"] = True
return [{"role": "user", "content": "from-profile"}]

View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Let the agent react to a message with an emoji in the Hermes desktop app.
The conversational counterpart to the user's tapback: the same reaction store,
the same one-per-author semantics, just written with ``author="agent"``.
Gated on ``HERMES_DESKTOP`` (like the other GUI affordances) so it costs nothing
on every other surface the platform adapters already expose reactions through
``send_message(action="react")``, and this is the desktop's equivalent.
Defaults to the message that triggered this turn (the photon precedent: the
model shouldn't have to thread row ids through tool calls), and emits
``message.reaction`` so the renderer paints it without waiting for a resume.
"""
import json
from gateway.session_context import get_session_env
from tools import desktop_ui
from tools.registry import registry, tool_error
from utils import env_var_enabled
def _open_session_db():
"""Open the SessionDB for the profile owning this turn, or ``None``."""
try:
from hermes_state import SessionDB
return SessionDB()
except Exception:
return None
def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -> str:
"""Attach (or with an empty ``emoji`` retract) the agent's reaction."""
emoji = (emoji or "").strip()
session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env(
"HERMES_SESSION_ID", ""
)
if not session_key:
return tool_error("No active session — reactions need a persisted conversation.")
db = _open_session_db()
if db is None:
return tool_error("Session storage is unavailable.")
row_id = message_row_id
target_role = "user"
if row_id is None:
# Default target: the latest user message. `messages_back` steps to
# earlier user turns (1 = the one before, etc.) for retroactive
# reactions — quoting text would be ambiguous, ids aren't visible to
# the model, but "two messages ago" is how a person thinks about it.
back = max(0, int(messages_back or 0))
row_id = db.latest_message_row_id(session_key, role="user", offset=back)
if row_id is None:
return tool_error(
f"No user message found {back} back." if back else "No user message to react to yet."
)
else:
row = db.get_message_role(session_key, int(row_id))
target_role = row or "user"
try:
reactions = db.set_message_reaction(
session_key, int(row_id), emoji or None, author="agent"
)
except Exception as exc:
return tool_error(f"Failed to set the reaction: {exc}")
if reactions is None:
return tool_error(f"Message {row_id} is not part of this conversation.")
# Paint it live. A missing bridge (non-desktop surface) is not an error —
# the reaction is persisted either way and shows on the next load.
# `role` lets the renderer match a live message that doesn't know its
# durable row id yet (it only learns rowId on resume).
try:
desktop_ui.emit(
"message.reaction",
{"row_id": int(row_id), "reactions": reactions, "role": target_role},
)
except Exception:
pass
return json.dumps(
{"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False
)
def check_react_requirements() -> bool:
"""Desktop GUI only, and opt-in.
HERMES_DESKTOP is set on the gateway the app spawns; the feature itself is
off by default and enabled from Settings Appearance (the desktop mirrors
the toggle into ``display.message_reactions``).
"""
if not env_var_enabled("HERMES_DESKTOP"):
return False
try:
from hermes_cli.config import load_config_readonly
display = load_config_readonly().get("display")
except Exception:
return False
return isinstance(display, dict) and bool(display.get("message_reactions", False))
REACT_TO_MESSAGE_SCHEMA = {
"name": "react_to_message",
"description": (
"React to a message with a single emoji, the way you'd tapback in iMessage. "
"Reach for it when a reaction is what a person would do: something funny gets "
"a 😂, warmth gets a ❤️, a plan you're on board with gets a 👍 — then just "
"carry on with whatever the message actually needs. If a reaction says it "
"all, it can BE the reply (skip the redundant 'sounds good!' turn). Use it "
"like a person would: occasionally, when felt — not on every message, and "
"never as a status signal. NEVER narrate or explain a reaction ('I reacted "
"with...', 'Reacting now') — the emoji appearing on the bubble is the whole "
"point, and commentary kills it. Defaults to the user's most recent message. "
"One reaction per message: a different emoji replaces yours, an empty string "
"retracts it."
),
"parameters": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"description": (
"The emoji to react with (e.g. '❤️', '😂', '👍'). Pass an empty "
"string to remove your reaction."
),
},
"message_row_id": {
"type": "integer",
"description": (
"Optional. The specific message to react to. Omit to react to the "
"user's latest message, which is almost always what you want."
),
},
"messages_back": {
"type": "integer",
"description": (
"Optional. React to an EARLIER user message: 1 = the one before "
"the latest, 2 = two before, and so on. For when something lands "
"late — the joke you only got after answering."
),
},
},
"required": ["emoji"],
},
}
registry.register(
name="react_to_message",
toolset="terminal",
schema=REACT_TO_MESSAGE_SCHEMA,
handler=lambda args, **kw: react_to_message_tool(
emoji=args.get("emoji", ""),
message_row_id=args.get("message_row_id"),
messages_back=args.get("messages_back"),
),
check_fn=check_react_requirements,
emoji="💛",
)

View file

@ -34,9 +34,10 @@ _HERMES_CORE_TOOLS = [
# Terminal + process management
"terminal", "process",
# Desktop GUI affordances: read the embedded terminal pane, close an agent's
# read-only terminal tab, open a URL/file in the preview pane, and focus a
# pane (all gated on HERMES_DESKTOP via check_fn — hidden outside the GUI).
"read_terminal", "close_terminal", "open_preview", "focus_pane",
# read-only terminal tab, open a URL/file in the preview pane, focus a
# pane, and react to a message with an emoji (all gated on HERMES_DESKTOP
# via check_fn — hidden outside the GUI).
"read_terminal", "close_terminal", "open_preview", "focus_pane", "react_to_message",
# File manipulation
"read_file", "write_file", "patch", "search_files",
# Vision + image generation

View file

@ -6,11 +6,64 @@ are rebound onto server.py's globals at install time — see method_ctx.py.
from .method_ctx import HandlerRegistry
import types
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
def _pending_reaction_notes(session: dict) -> str:
"""Note block describing reactions the user added since the last turn, or "".
Applied to the MODEL INPUT only (``run_message``, beside the
speech-interrupted note) never to the text that gets persisted. Prefixing
the persisted prompt bakes scaffolding into the transcript, which every
surface then renders as a garbled user message on reload. Each reaction is
announced once the row is stamped ``seen`` on read.
"""
session_key = str(session.get("session_key") or "")
if not session_key:
return ""
# Feature-gated (off by default, Settings → Appearance): when disabled the
# model hears nothing, even about reactions set while it was on.
try:
display = _load_cfg().get("display")
if not (isinstance(display, dict) and bool(display.get("message_reactions", False))):
return ""
except Exception:
return ""
try:
with _session_db(session) as db:
if db is None:
return ""
pending = db.take_unseen_reactions(session_key, author="user")
except Exception:
logger.debug("Failed to read pending reactions", exc_info=True)
return ""
if not pending:
return ""
notes = []
for entry in pending:
snippet = (entry.get("text") or "").strip().replace("\n", " ")
if len(snippet) > 120:
snippet = snippet[:120] + ""
emoji = entry.get("emoji") or ""
whose = "their own" if entry.get("role") == "user" else "your"
if snippet:
notes.append(f'[The user reacted {emoji} to {whose} message: "{snippet}"]')
else:
# A row with no plain text (attachment-only, or a tool-call-only
# assistant turn) — an empty quote reads worse than no quote.
notes.append(f"[The user reacted {emoji} to {whose} earlier message]")
return "\n".join(notes)
@method("prompt.submit")
def _(rid, params: dict) -> dict:
from hermes_cli.input_sanitize import sanitize_user_prompt_text
@ -833,3 +886,13 @@ def _(rid, params: dict) -> dict:
def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server)
# Module-level helpers aren't @method handlers, so install() doesn't see
# them — but server.py's run path calls this one (run_message enrichment,
# beside the speech-interrupted note). Rebind and publish it the same way.
server._pending_reaction_notes = types.FunctionType(
_pending_reaction_notes.__code__,
vars(server),
_pending_reaction_notes.__name__,
_pending_reaction_notes.__defaults__,
_pending_reaction_notes.__closure__,
)

View file

@ -444,7 +444,7 @@ def _(rid, params: dict) -> dict:
# feeds live replay. Fall back to it if the display read fails.
try:
display_history = db.get_messages_as_conversation(
target, repair_alternation=False
target, repair_alternation=False, include_row_ids=True
)
except Exception:
logger.debug("child-watch display projection read failed", exc_info=True)
@ -919,6 +919,59 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5007, str(e))
@method("message.react")
def _(rid, params: dict) -> dict:
"""Set or clear one author's emoji reaction on a persisted message.
iOS Tapback semantics, enforced in the DB layer: one reaction per author
per message, re-sending the same emoji retracts it. ``emoji: null`` clears
unconditionally. ``row_id`` is the durable ``messages.id`` forwarded by
``_history_to_messages`` the renderer's own message ids are ephemeral.
"""
session, err = _sess_nowait(params, rid)
if err:
return err
# A live message hasn't round-tripped through a resume, so the desktop has
# no durable row id for it yet. It can instead name the ROLE whose newest
# row it means — which is the message the user just reacted to.
newest_role = str(params.get("newest_role") or "").strip()
row_id = params.get("row_id")
if row_id is None and newest_role not in {"user", "assistant"}:
return _err(rid, 4023, "row_id or newest_role required")
emoji = params.get("emoji")
if emoji is not None:
emoji = str(emoji).strip()
if not emoji:
return _err(rid, 4024, "emoji must be a non-empty string or null")
author = str(params.get("author") or "user").strip()
if author not in {"user", "agent"}:
return _err(rid, 4025, "author must be 'user' or 'agent'")
with _session_db(session) as db:
if db is None:
return _db_unavailable_error(rid, code=5007)
try:
if row_id is None:
row_id = db.latest_message_row_id(
session["session_key"], role=newest_role
)
if row_id is None:
return _err(rid, 4040, "no message to react to yet")
reactions = db.set_message_reaction(
session["session_key"], int(row_id), emoji, author=author
)
except Exception as e:
return _err(rid, 5007, str(e))
if reactions is None:
return _err(rid, 4040, "message not found in this session")
return _ok(rid, {"row_id": int(row_id), "reactions": reactions})
@method("llm.oneshot")
def _(rid, params: dict) -> dict:
"""Run a single stateless LLM request outside any conversation.

View file

@ -6655,6 +6655,13 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
if not content_text.strip() and not has_reasoning:
continue
msg = {"role": role, "text": content_text}
# Durable row identity, stamped by _rows_to_conversation. The renderer's
# own message ids are ephemeral (timestamp+index derived, and a
# different shape for live vs rehydrated vs optimistic rows), so
# anything that addresses a specific persisted message later — message
# reactions — needs this instead.
if m.get("_row_id") is not None:
msg["row_id"] = m["_row_id"]
if role == "user":
invocation = _skill_scaffold_projection(content_text)
if invocation:
@ -7499,7 +7506,7 @@ def _live_visible_history(session: dict, db, in_memory_fallback: list[dict]) ->
key = session.get("session_key")
if db is not None and key:
try:
display = db.get_messages_as_conversation(key, include_ancestors=True)
display = db.get_messages_as_conversation(key, include_ancestors=True, include_row_ids=True)
return _reconcile_display_with_live(display, in_memory_fallback)
except Exception:
logger.debug("live display projection read failed", exc_info=True)
@ -9132,6 +9139,17 @@ def _run_prompt_submit(
elif isinstance(run_message, list):
run_message = [{"type": "text", "text": SPEECH_INTERRUPTED_NOTE}, *run_message]
# Reactions the user added since the last turn ride the MODEL INPUT
# only (same enrichment channel as the speech-interrupted note);
# persist_user_message below stays the clean prompt, so no
# scaffolding reaches the transcript. Cache-safe: annotating the
# NEW turn never rewrites an already-sent message.
if reaction_notes := _pending_reaction_notes(session):
if isinstance(run_message, str):
run_message = f"{reaction_notes}\n\n{run_message}"
elif isinstance(run_message, list):
run_message = [{"type": "text", "text": reaction_notes}, *run_message]
def _stream(delta):
with session["history_lock"]:
_append_inflight_delta(session, delta)
@ -11671,7 +11689,7 @@ def _format_live_history_output(session: dict) -> str:
if db is not None and session.get("session_key"):
try:
history = db.get_messages_as_conversation(
session["session_key"], include_ancestors=True
session["session_key"], include_ancestors=True, include_row_ids=True
)
except Exception:
pass
@ -11711,7 +11729,9 @@ def _format_live_context_output(session: dict) -> str:
if db is not None and session.get("session_key"):
try:
messages = _history_to_messages(
db.get_messages_as_conversation(session["session_key"], include_ancestors=True)
db.get_messages_as_conversation(
session["session_key"], include_ancestors=True, include_row_ids=True
)
)
except Exception:
messages = []