diff --git a/.plans/message-reactions.md b/.plans/message-reactions.md new file mode 100644 index 00000000000..377d29b1763 --- /dev/null +++ b/.plans/message-reactions.md @@ -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` (`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` | 134–175 | +| User hover cluster | `apps/desktop/src/components/assistant-ui/thread/user-message.tsx` | 296–336 | +| Callback threading (ref caveat 79–99) | `apps/desktop/src/components/assistant-ui/thread/index.tsx` | 109–133 | +| `metadata.custom` → runtime | `apps/desktop/src/lib/chat-runtime.ts` | 384–432 | +| 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` | 1430–1529 | + +### 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 (`--`), live + (`assistant-`), and optimistic (`user--`) 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. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 5d96b8e6512..3ca96898cf9 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 01f0e45eb7b..8a3f76e9f4d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index c59bcda6d7f..b178369c4c1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -30,6 +30,8 @@ interface UseComposerTriggerOptions { at: CompletionSource draftRef: MutableRefObject editorRef: RefObject + /** `: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 diff --git a/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts new file mode 100644 index 00000000000..0fb03526234 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-emoji-completions.ts @@ -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 | null = null +let indexLoaded = false + +async function loadIndex(): Promise { + 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 = 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 { + 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 => { + 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 + }) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 478e40c281f..c249a7a25a7 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index e471daecf5e..19dfa8ce0d9 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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 } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index da52f1dd088..0cf57700c14 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -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} @file: {copy.lookupOr}{' '} @folder:. + ) : kind === ':' ? ( + <> + {copy.lookupTry} :joy:. + ) : ( <> {copy.lookupTry} /help. @@ -154,6 +158,9 @@ export function ComposerTriggerPopover({ )} + ) : kind === ':' ? ( + // Just the emoji + :shortcode:, Slack-style — no icon column. + {display} ) : ( <> diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 9ee6a2feb9b..5970b8a0720 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index ffc799c432f..019328d21c5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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]: 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) { diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 530870d13b2..8149c13c90d 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -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} /> + { + 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} + /> + = ({ 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 ( -
+
= ({ messageId, getMessageText, + {/* 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) && ( + reaction.author === 'user')?.emoji} + > + 0 || undefined} + data-slot="aui_msg-reactions" + data-state={pickerOpen ? 'open' : undefined} + onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined} + tooltip={copy.react} + > + {shownReactions.length > 0 ? ( + + {shownReactions.map(reaction => ( + + {reaction.emoji} + + ))} + + ) : ( + + )} + + + )}
) } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 77b79caa8ae..8bcc9464538 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -53,6 +53,13 @@ const ChainToolFallback: FC = 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 } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx new file mode 100644 index 00000000000..749335b1c38 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/message-reactions.tsx @@ -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 }) => ( + 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. */} + + + + Loading emoji… + + + No emoji found. + + ( +
+ {category.label} +
+ ), + Emoji: ({ emoji, ...props }) => ( + + ), + Row: ({ children, ...props }) => ( +
+ {children} +
+ ) + }} + /> +
+
+) + +/** + * 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 ( + { + onOpenChange(next) + + if (!next) { + // Always reopen on the quick row. + setExpanded(false) + } + }} + open={open} + > + {children} + event.preventDefault()} + side="top" + > + {expanded ? ( + + ) : ( + <> + {QUICK_REACTIONS.map(emoji => ( + + ))} + + + )} + + + ) +} + +/** + * 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 ( + + {reactions.map(reaction => + reaction.author === 'user' && onRetract ? ( + + ) : ( + + {reaction.emoji} + + ) + )} + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 72e9b8e1ddb..f27c5358af6 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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) => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 6b0a6248eb8..00a024aa974 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -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<{ >
-
- {readOnly ? ( - // Spectator transcript: clicking only toggles the clamp so the - // full prompt is readable — never opens an edit composer. - - ) : ( - // Always editable — clicking opens the edit composer even while a - // turn streams; sending the edit reverts (interrupt + rewind). - + reaction.author === 'user')?.emoji} + > +
{ + event.preventDefault() + setPickerOpen(true) + } + } + > + {readOnly ? ( + // Spectator transcript: clicking only toggles the clamp so the + // full prompt is readable — never opens an edit composer. - - )} - {(showStop || showRestore) && ( -
- {showStop ? ( + ) : ( + // Always editable — clicking opens the edit composer even while a + // turn streams; sending the edit reverts (interrupt + rewind). + - ) : ( - - )} -
- )} -
+
+ )} + {(showStop || showRestore) && ( +
+ {showStop ? ( + + ) : ( + + )} +
+ )} +
+ + {/* 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. */} + react(null)} + reactions={shownReactions} + /> [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 { 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) : 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 } : {}) }) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 0aab5277a70..99585a3c764 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -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 => 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 } diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index bd5fc978bcf..550c2a4bc88 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/store/reactions-enabled.ts b/apps/desktop/src/store/reactions-enabled.ts new file mode 100644 index 00000000000..66870760651 --- /dev/null +++ b/apps/desktop/src/store/reactions-enabled.ts @@ -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(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. + }) + }) +} diff --git a/apps/desktop/src/store/reactions-local.ts b/apps/desktop/src/store/reactions-local.ts new file mode 100644 index 00000000000..ec04526abb8 --- /dev/null +++ b/apps/desktop/src/store/reactions-local.ts @@ -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>({}) + +/** + * 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 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 +} diff --git a/apps/desktop/src/store/reactions.test.ts b/apps/desktop/src/store/reactions.test.ts new file mode 100644 index 00000000000..f922784c9fb --- /dev/null +++ b/apps/desktop/src/store/reactions.test.ts @@ -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 author’s 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) + }) +}) diff --git a/apps/desktop/src/store/reactions.ts b/apps/desktop/src/store/reactions.ts new file mode 100644 index 00000000000..c8c2b414a8a --- /dev/null +++ b/apps/desktop/src/store/reactions.ts @@ -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 { + // 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('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') + } +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 18b52070a50..aaa00f9fc8c 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 46536226153..0b23acbe46a 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9c2cc123809..d6c14e7008e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -36,9 +36,47 @@ const debugEntry = (command: string, env: Record) => ? path.resolve(__dirname, './src/debug/dev-only.ts') : path.resolve(__dirname, './src/debug/dev-only.noop.ts') +// The emoji picker (frimousse) fetches `//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 diff --git a/hermes_state.py b/hermes_state.py index c23487ca30e..7753ea69f81 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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 diff --git a/package-lock.json b/package-lock.json index c4275fb8fff..ca39c511e2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/tests/test_message_reactions.py b/tests/test_message_reactions.py new file mode 100644 index 00000000000..acc4aaac03d --- /dev/null +++ b/tests/test_message_reactions.py @@ -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) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 670f74d318a..b3714bbf12d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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"}] diff --git a/tools/react_to_message_tool.py b/tools/react_to_message_tool.py new file mode 100644 index 00000000000..5a6b52e5ef2 --- /dev/null +++ b/tools/react_to_message_tool.py @@ -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="💛", +) diff --git a/toolsets.py b/toolsets.py index 75219f719cf..4c7588b0abd 100644 --- a/toolsets.py +++ b/toolsets.py @@ -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 diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index cc311de6cb4..763de70ac8e 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -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__, + ) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index ea0e1720f66..5b00a42a51d 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -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. diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b3a0ffa6187..011940dc9f8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 = []