mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #74533 from NousResearch/bb/emoji-reactions
feat: iMessage-style emoji reactions on desktop — opt-in, two-way, persistent, model-aware
This commit is contained in:
commit
fa183062e4
41 changed files with 2118 additions and 125 deletions
146
.plans/message-reactions.md
Normal file
146
.plans/message-reactions.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Message reactions (desktop tapbacks)
|
||||
|
||||
Two-way emoji reactions on individual messages in the desktop transcript: the
|
||||
user reacts to any message, the agent reacts to a user message, and both sides
|
||||
read the other's reactions as conversational signal.
|
||||
|
||||
## What already exists
|
||||
|
||||
Hermes already models reactions on the **platform** side — the desktop is the
|
||||
only surface without them.
|
||||
|
||||
| Surface | Reaction support | Where |
|
||||
|---|---|---|
|
||||
| Agent → platform message | `send_message(action="react"/"unreact")` | `tools/send_message_tool.py:266` `_handle_react()` |
|
||||
| Photon / iMessage | tapbacks in + out, routed only for messages we sent | `plugins/platforms/photon/adapter.py:1240-1283` |
|
||||
| Telegram | `setMessageReaction`, config-gated | `plugins/platforms/telegram/adapter.py:9669+` |
|
||||
| Slack / Matrix / Feishu / Discord | inbound reaction events → hooks | `gateway/run.py:4688` `_handle_reaction_event()` → `HookRegistry.emit("reaction:added")` |
|
||||
| Adapter contract | `add_reaction()` / `remove_reaction()` coroutines, `set_reaction_handler()` | `gateway/platforms/base.py:3330` |
|
||||
| Core "affection" detector | regex on user text → `vibe`, drives CLI pet / TUI heart / desktop hearts | `agent/reactions.py`, `agent/turn_context.py:592-604` |
|
||||
|
||||
Two things follow from that table:
|
||||
|
||||
1. **The agent-facing verb already exists.** `send_message(action="react")` is
|
||||
the established shape. A desktop reaction should extend that tool, not add a
|
||||
new core tool — every new tool ships on every API call (AGENTS.md footprint
|
||||
ladder).
|
||||
2. **The inbound convention already exists.** Photon turns a tapback into a
|
||||
normal message event with `reply_to_message_id` + `reply_to_is_own_message`,
|
||||
and the gateway prefixes `[Replying to your previous message: "…"]`
|
||||
(`gateway/run.py:13125-13132`). Desktop reactions should read the same way to
|
||||
the model.
|
||||
|
||||
Nothing exists on the desktop side: `grep -ri reaction` across `apps/desktop`
|
||||
finds only the pet-overlay hearts.
|
||||
|
||||
## Prior art
|
||||
|
||||
**iOS Tapback** ([Apple](https://support.apple.com/guide/iphone/react-with-tapbacks-iph018d3c336/ios)):
|
||||
double-tap or touch-and-hold a message → floating pill above the bubble with
|
||||
heart / thumbs-up / thumbs-down / haha / ‼️ / ❓, swipe left for suggested emoji
|
||||
and stickers, or tap the emoji button for the full keyboard. **One tapback per
|
||||
message per person** — tapping the same one again removes it, tapping a
|
||||
different one replaces it. Multiple people's tapbacks stack on the badge.
|
||||
|
||||
**Platform data models** converge on the same shape:
|
||||
|
||||
| Platform | Model | Add / remove |
|
||||
|---|---|---|
|
||||
| Slack | `{name, count, users[]}` | [`reactions.add`](https://docs.slack.dev/reference/methods/reactions.add) / `reactions.remove`, emits `reaction_added` |
|
||||
| Discord | `{emoji, count, me}` on the message object | `PUT`/`DELETE .../reactions/{emoji}/@me` |
|
||||
| Telegram | `reaction: [{type:"emoji", emoji:"👍"}]` — replaces the whole set | `setMessageReaction`, `is_big` for the big animation |
|
||||
|
||||
Telegram's "set the whole array" is the closest match to iOS semantics and the
|
||||
simplest thing to persist.
|
||||
|
||||
**assistant-ui has no reaction primitive.** `@assistant-ui/react` 0.14.24 (MIT,
|
||||
vendored at `apps/desktop/node_modules`): zero hits for "reaction" in `core/src`,
|
||||
`react/src`, `dist/`, or the 2.2 MB `llms-full.txt` docs dump. What exists is a
|
||||
hard-coded binary `FeedbackAdapter` (`"positive" | "negative"`,
|
||||
`core/src/adapters/feedback.ts`) that throws when unconfigured and only writes
|
||||
back onto assistant messages. Not usable for emoji, not usable on user messages.
|
||||
|
||||
**But `metadata.custom` is the supported extension channel** and this repo
|
||||
already uses it: `ThreadUserMessage`/`ThreadAssistantMessage`/`ThreadSystemMessage`
|
||||
all carry `metadata.custom: Record<string, unknown>` (`core/src/types/message.ts:319-366`),
|
||||
and `chat-runtime.ts:397` already ships `custom: { attachmentRefs }` through it.
|
||||
|
||||
**Emoji picker survey** (npm week of 2026-07-22, sizes measured from the
|
||||
published ESM entry):
|
||||
|
||||
| Library | License | Weekly DL | gzip | Headless | Latest |
|
||||
|---|---|---|---|---|---|
|
||||
| **frimousse** | MIT | 573k | **8.5 kB** | ✅ fully unstyled, composable parts | 0.3.0 · 2025-07-15 |
|
||||
| emoji-picker-react | MIT | 1.31M | 87 kB | ❌ own CSS-in-JS (flairup) | 4.19.1 · 2026-04-27 |
|
||||
| emoji-mart | MIT | 2.22M | ~120 kB w/ data | ❌ Preact + shadow styling | 5.6.0 · **2024-04-25**, 217 open issues |
|
||||
| emoji-picker-element | Apache-2.0 | 183k | — | ❌ Web Component / Shadow DOM | 1.29.1 · 2026-03-01 |
|
||||
|
||||
No picker is currently a dependency (only `emoji-regex`, transitive). Already
|
||||
paid for and reusable: `radix-ui` (Popover), `motion`, `@tanstack/react-virtual`,
|
||||
Tailwind v4.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Hand-roll the tapback pill; add frimousse only behind the "+".** Six fixed
|
||||
emoji in a pill is ~40 lines of JSX against existing tokens — pulling 87 kB of
|
||||
`emoji-picker-react` to render six buttons, plus a CSS engine that fights
|
||||
`DESIGN.md`, is backwards. frimousse is headless, dependency-free, 10× smaller,
|
||||
and exposes `emojibaseUrl` so the data can be bundled as a Vite asset instead of
|
||||
hitting jsDelivr (Electron must work offline).
|
||||
|
||||
### Data model
|
||||
|
||||
One reaction per author per message, Telegram-style whole-set replacement:
|
||||
|
||||
```ts
|
||||
type MessageReaction = { emoji: string; author: 'user' | 'agent'; at: number }
|
||||
```
|
||||
|
||||
Persisted in the existing `messages.display_metadata` JSON column
|
||||
(`hermes_state_common.py:215`) — no new table. It already survives insert,
|
||||
compaction, and every read projection, and
|
||||
`set_latest_matching_message_display_kind()` (`hermes_state.py:5292`) is the
|
||||
precedent for stamping metadata onto an already-persisted row.
|
||||
|
||||
### Model context
|
||||
|
||||
Reactions must reach the model **without breaking prompt caching**. The
|
||||
`api_messages` build loop strips `display_metadata` from every outgoing copy
|
||||
(`agent/conversation_loop.py:1443-1446`) precisely so display state never
|
||||
becomes a provider field. Two candidate paths:
|
||||
|
||||
| Path | Cache impact | Notes |
|
||||
|---|---|---|
|
||||
| Rewrite the reacted-to message's content to carry the annotation | **Breaks the cached prefix** — mutates past context | Rejected. AGENTS.md: prompt caching is sacred. |
|
||||
| Deliver the reaction as the *next* turn's leading annotation, mirroring photon | Prefix untouched; only the new turn carries it | Matches `[Replying to your previous message: "…"]` (`gateway/run.py:13125`), which the agent already understands |
|
||||
|
||||
The second is the same trick the platform adapters already use, so the model
|
||||
sees a familiar shape and no existing conversation is rewritten.
|
||||
|
||||
### Attach points
|
||||
|
||||
| Concern | File | Lines |
|
||||
|---|---|---|
|
||||
| Assistant hover bar | `apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx` | 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 (`<ts>-<i>-<role>`), live
|
||||
(`assistant-<ms>`), and optimistic (`user-<ms>-<rand>`) messages. A reaction
|
||||
needs a stable key — this is the first thing to fix.
|
||||
- **WeakMap identity cache** in `apps/desktop/src/app/chat/runtime-repository.ts:26-66`
|
||||
keys normalized `ThreadMessage` by `ChatMessage` identity. A reaction change
|
||||
must produce a **new** `ChatMessage` object or the UI renders stale.
|
||||
- **Rewind rewrites rows** (`replace_messages`), so anything keyed by row id
|
||||
needs cascade handling — an argument for keeping reactions in
|
||||
`display_metadata` on the row itself rather than a side table.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ interface UseComposerTriggerOptions {
|
|||
at: CompletionSource
|
||||
draftRef: MutableRefObject<string>
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
/** `:joy` emoji completions — inserts the emoji character, never a chip. */
|
||||
emoji?: CompletionSource
|
||||
/** Bank the pre-commit state so a popover pick is a single undo step. */
|
||||
recordUndoPoint?: () => void
|
||||
requestMainFocus: () => void
|
||||
|
|
@ -50,6 +52,7 @@ export function useComposerTrigger({
|
|||
at,
|
||||
draftRef,
|
||||
editorRef,
|
||||
emoji,
|
||||
recordUndoPoint,
|
||||
requestMainFocus,
|
||||
setComposerText,
|
||||
|
|
@ -91,7 +94,7 @@ export function useComposerTrigger({
|
|||
// is present do we pay the cost of the full walk + DOM range work.
|
||||
const rawText = editor.textContent ?? ''
|
||||
|
||||
if (!rawText.includes('@') && !rawText.includes('/')) {
|
||||
if (!rawText.includes('@') && !rawText.includes('/') && !rawText.includes(':')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
resetTriggerActive()
|
||||
|
|
@ -128,7 +131,13 @@ export function useComposerTrigger({
|
|||
}, [editorRef, resetTriggerActive, trigger])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? (emoji?.adapter ?? null)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
|
|
@ -146,7 +155,14 @@ export function useComposerTrigger({
|
|||
setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items)
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? (emoji?.loading ?? false)
|
||||
: false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { type CompletionEntry, type CompletionPayload, useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
/**
|
||||
* `:shortcode:` completions for the composers, Slack-style (`:joy` → 😂).
|
||||
*
|
||||
* Draws from the same bundled emojibase-data the reaction picker uses (served
|
||||
* at ./emojibase by the `hermes:emojibase-assets` vite plugin — offline, no
|
||||
* CDN). The index lazy-loads on the first `:` trigger, then every query is
|
||||
* answered from memory, so `isCached` skips the debounce and loading state
|
||||
* after that first load.
|
||||
*
|
||||
* A pick inserts the emoji CHARACTER as plain text — not a chip. Directive
|
||||
* chips exist to carry machine-readable references the backend resolves
|
||||
* (@file:, /skill); a picked emoji is just text, so it rides the formatter's
|
||||
* `rawText` path and lands inline.
|
||||
*/
|
||||
|
||||
interface EmojiEntry {
|
||||
emoji: string
|
||||
/** Primary shortcode, e.g. "joy". */
|
||||
code: string
|
||||
/** Every shortcode, tag, and label that should match a search. */
|
||||
haystack: string[]
|
||||
}
|
||||
|
||||
let indexPromise: Promise<EmojiEntry[]> | null = null
|
||||
let indexLoaded = false
|
||||
|
||||
async function loadIndex(): Promise<EmojiEntry[]> {
|
||||
const [dataRes, codesRes] = await Promise.all([
|
||||
fetch('./emojibase/en/data.json'),
|
||||
fetch('./emojibase/en/shortcodes/emojibase.json')
|
||||
])
|
||||
|
||||
const data: { emoji: string; hexcode: string; label: string; tags?: string[] }[] = await dataRes.json()
|
||||
const codes: Record<string, string | string[]> = await codesRes.json()
|
||||
const entries: EmojiEntry[] = []
|
||||
|
||||
for (const item of data) {
|
||||
const raw = codes[item.hexcode]
|
||||
|
||||
if (!raw) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shortcodes = Array.isArray(raw) ? raw : [raw]
|
||||
|
||||
entries.push({
|
||||
emoji: item.emoji,
|
||||
code: shortcodes[0],
|
||||
haystack: [...shortcodes, ...(item.tags ?? []), item.label.toLowerCase()]
|
||||
})
|
||||
}
|
||||
|
||||
indexLoaded = true
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Prefix matches on shortcodes rank first, then tag/label substring hits. */
|
||||
async function searchEmoji(query: string, limit = 8): Promise<EmojiEntry[]> {
|
||||
const index = await (indexPromise ??= loadIndex())
|
||||
const q = query.toLowerCase()
|
||||
const prefix: EmojiEntry[] = []
|
||||
const loose: EmojiEntry[] = []
|
||||
|
||||
for (const entry of index) {
|
||||
if (entry.code.startsWith(q) || entry.haystack.some(h => h.startsWith(q))) {
|
||||
prefix.push(entry)
|
||||
} else if (entry.haystack.some(h => h.includes(q))) {
|
||||
loose.push(entry)
|
||||
}
|
||||
|
||||
if (prefix.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [...prefix, ...loose].slice(0, limit)
|
||||
}
|
||||
|
||||
export function useEmojiCompletions() {
|
||||
const fetcher = useCallback(async (query: string): Promise<CompletionPayload> => {
|
||||
const entries = await searchEmoji(query)
|
||||
|
||||
return {
|
||||
query,
|
||||
items: entries.map(entry => ({
|
||||
text: entry.emoji,
|
||||
display: `${entry.emoji} :${entry.code}:`,
|
||||
meta: ''
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toItem = useCallback(
|
||||
(entry: CompletionEntry, index: number) => ({
|
||||
id: `${entry.text}|${index}`,
|
||||
type: 'emoji',
|
||||
label: typeof entry.display === 'string' ? entry.display : entry.text,
|
||||
metadata: {
|
||||
display: typeof entry.display === 'string' ? entry.display : entry.text,
|
||||
// The formatter's serialize() returns rawText verbatim → the emoji
|
||||
// character lands as plain inline text, no chip.
|
||||
rawText: entry.text,
|
||||
meta: '',
|
||||
group: '',
|
||||
action: ''
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return useLiveCompletionAdapter({
|
||||
enabled: true,
|
||||
fetcher,
|
||||
isCached: () => indexLoaded,
|
||||
toItem
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const ROW_BASE_CLASS = [
|
|||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
kind: '@' | '/'
|
||||
kind: '@' | '/' | ':'
|
||||
loading: boolean
|
||||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
|
|
@ -93,6 +93,10 @@ export function ComposerTriggerPopover({
|
|||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">:joy:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
|
|
@ -154,6 +158,9 @@ export function ComposerTriggerPopover({
|
|||
</span>
|
||||
)}
|
||||
</>
|
||||
) : kind === ':' ? (
|
||||
// Just the emoji + :shortcode:, Slack-style — no icon column.
|
||||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): C
|
|||
// or reference identity the runtime already guarantees.
|
||||
// timestamp — presentation-only (sort/age display), never affects transcript equality
|
||||
// attachmentRefs — composer-side metadata; already reconciled in reconcileResumeMessages
|
||||
// rowId — durable backend identity; stable for a given row, never changes what's painted
|
||||
//
|
||||
// If your new field affects what the user sees in the transcript, add it to
|
||||
// COMPARED. If it's metadata that shouldn't trigger a re-render, add it to
|
||||
|
|
@ -99,8 +100,17 @@ const _chatMessageFieldsExhaustive: {
|
|||
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
|
||||
} = {}
|
||||
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
|
||||
const COMPARED_FIELDS = [
|
||||
'id',
|
||||
'role',
|
||||
'pending',
|
||||
'error',
|
||||
'hidden',
|
||||
'branchGroupId',
|
||||
'interim',
|
||||
'reactions'
|
||||
] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
|
||||
|
||||
// Compile-time check: every ChatMessagePart discriminant must be handled by
|
||||
// chatPartsEquivalent. If @assistant-ui adds a new part type, this fails tsc.
|
||||
|
|
@ -173,6 +183,23 @@ export function chatPartsEquivalent(aPart: ChatMessage['parts'][number], bPart:
|
|||
return aKeys.every(k => aPrimitive[k] === bPrimitive[k])
|
||||
}
|
||||
|
||||
export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMessage['reactions']): boolean {
|
||||
const aList = a ?? []
|
||||
const bList = b ?? []
|
||||
|
||||
if (aList === bList) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
aList.length === bList.length &&
|
||||
aList.every(
|
||||
(reaction, index) =>
|
||||
reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
if (
|
||||
a.id !== b.id ||
|
||||
|
|
@ -183,7 +210,8 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
|
|||
a.branchGroupId !== b.branchGroupId ||
|
||||
// Interim gates the action footer, so flipping it must repaint (e.g. a
|
||||
// previewed final settling onto a sealed interim bubble restores the bar).
|
||||
(a.interim ?? false) !== (b.interim ?? false)
|
||||
(a.interim ?? false) !== (b.interim ?? false) ||
|
||||
!chatReactionsEquivalent(a.reactions, b.reactions)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -260,6 +288,19 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
|||
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
|
||||
}
|
||||
|
||||
// Reactions and the row id come from the same authoritative rows as the
|
||||
// text, but a live/optimistic row that hasn't round-tripped yet carries
|
||||
// neither. Carry the cached copy forward so a reaction doesn't blink off
|
||||
// mid-turn. NEW object every time — the runtime repository's WeakMap
|
||||
// caches normalized ThreadMessages by ChatMessage identity.
|
||||
if (sameTurn && preserved.rowId === undefined && previous.rowId !== undefined) {
|
||||
preserved = { ...preserved, rowId: previous.rowId }
|
||||
}
|
||||
|
||||
if (sameTurn && preserved.reactions === undefined && previous.reactions?.length) {
|
||||
preserved = { ...preserved, reactions: [...previous.reactions] }
|
||||
}
|
||||
|
||||
const previousImages = embeddedImageUrls(previousText)
|
||||
|
||||
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'
|
|||
import { $backdrop, setBackdrop } from '@/store/backdrop'
|
||||
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
|
||||
|
|
@ -250,6 +251,7 @@ export function AppearanceSettings() {
|
|||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
const translucency = useStore($translucency)
|
||||
const reactionsEnabled = useStore($reactionsEnabled)
|
||||
const backdrop = useStore($backdrop)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const profiles = useStore($profiles)
|
||||
|
|
@ -472,6 +474,24 @@ export function AppearanceSettings() {
|
|||
title={a.backdropTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setReactionsEnabled(id === 'on')
|
||||
}}
|
||||
options={[
|
||||
{ id: 'off', label: t.common.off },
|
||||
{ id: 'on', label: t.common.on }
|
||||
]}
|
||||
value={reactionsEnabled ? 'on' : 'off'}
|
||||
/>
|
||||
}
|
||||
description={a.reactionsDesc}
|
||||
title={a.reactionsTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, useCallback, useMemo } from 'react'
|
||||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
pickPrimaryPreviewTarget
|
||||
} from '@/components/assistant-ui/thread/content'
|
||||
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
|
||||
import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
|
||||
import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status'
|
||||
import { formatMessageTimestamp } from '@/components/assistant-ui/thread/timestamp'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
|
|
@ -22,15 +23,23 @@ import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { formatAgo } from '@/lib/time'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { toggleMessageReaction } from '@/store/reactions'
|
||||
import { $reactionsEnabled } from '@/store/reactions-enabled'
|
||||
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Stable empty identity — a fresh [] per render would re-run every consumer.
|
||||
const EMPTY_REACTIONS: MessageReaction[] = []
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
|
|
@ -135,8 +144,42 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
|
|||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
const reactions = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
|
||||
|
||||
return custom.reactions ?? EMPTY_REACTIONS
|
||||
})
|
||||
|
||||
const rowId = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
|
||||
|
||||
return custom.rowId
|
||||
})
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const reactionsEnabled = useStore($reactionsEnabled)
|
||||
const localAll = useStore($localReactions)
|
||||
const agentLive = useStore($agentReactions)
|
||||
|
||||
const shownReactions = mergeReactions(
|
||||
reactions,
|
||||
localAll[messageId],
|
||||
rowId !== undefined ? agentLive[rowId] : undefined
|
||||
)
|
||||
|
||||
const react = useCallback(
|
||||
(emoji: null | string) => {
|
||||
setPickerOpen(false)
|
||||
// Flip the UI immediately — a tapback is direct manipulation and must
|
||||
// never wait on a round-trip. Persistence follows in the background.
|
||||
setLocalReaction(messageId, emoji)
|
||||
void toggleMessageReaction({ id: messageId, role: 'assistant', rowId, reactions } as ChatMessage, emoji)
|
||||
},
|
||||
[messageId, reactions, rowId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full shrink-0 justify-end">
|
||||
<div className="relative flex w-full shrink-0 items-center justify-end gap-1.5">
|
||||
<ActionBarPrimitive.Root
|
||||
className={
|
||||
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
|
||||
|
|
@ -170,6 +213,42 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
|
|||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
{/* ONE slot, Slack-style: the picker trigger and the landed reaction are
|
||||
the same element, so reacting never shifts layout. Empty → ☺, hidden
|
||||
until hover like its action-bar neighbors (state lives in styles.css
|
||||
— the aui_msg-reactions rules outweigh Tailwind utilities here).
|
||||
Reacted → the emoji itself, always visible at full strength, and
|
||||
clicking it reopens the picker to switch or retract. Outside
|
||||
ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's
|
||||
hover opacity. */}
|
||||
{(reactionsEnabled || shownReactions.length > 0) && (
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
<TooltipIconButton
|
||||
data-reacted={shownReactions.length > 0 || undefined}
|
||||
data-slot="aui_msg-reactions"
|
||||
data-state={pickerOpen ? 'open' : undefined}
|
||||
onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined}
|
||||
tooltip={copy.react}
|
||||
>
|
||||
{shownReactions.length > 0 ? (
|
||||
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
|
||||
{shownReactions.map(reaction => (
|
||||
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
|
||||
{reaction.emoji}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<SmilePlusIcon className="size-3.5" />
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
</ReactionPicker>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
|||
return null
|
||||
}
|
||||
|
||||
// A reaction's UI is the emoji landing on the bubble (message.reaction
|
||||
// event) — a "React To Message" tool block next to it would be the agent
|
||||
// narrating its own tapback. Failures still render so they're debuggable.
|
||||
if (props.toolName === 'react_to_message' && !props.isError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.toolName === 'delegate_task') {
|
||||
return <DelegateToolPart {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
import { EmojiPicker } from 'frimousse'
|
||||
import { type FC, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Plus } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { QUICK_REACTIONS } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets`
|
||||
// plugin bundles emojibase-data): Electron must work offline, and the app
|
||||
// should never phone a CDN to draw a picker.
|
||||
const EMOJIBASE_URL = './emojibase'
|
||||
|
||||
// Slack tints its picker cells in a repeating palette (green, blue, yellow,
|
||||
// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the
|
||||
// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns).
|
||||
// Keyed off the emoji's codepoint — deterministic, and stable under
|
||||
// frimousse's virtualized rows (an index cycle would reshuffle on scroll).
|
||||
const CELL_TINTS = [
|
||||
'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20',
|
||||
'hover:bg-sky-500/15 data-[active]:bg-sky-500/20',
|
||||
'hover:bg-amber-500/15 data-[active]:bg-amber-500/20',
|
||||
'hover:bg-pink-500/15 data-[active]:bg-pink-500/20',
|
||||
'hover:bg-orange-500/15 data-[active]:bg-orange-500/20',
|
||||
'hover:bg-violet-500/15 data-[active]:bg-violet-500/20'
|
||||
] as const
|
||||
|
||||
const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length]
|
||||
|
||||
/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */
|
||||
const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => (
|
||||
<EmojiPicker.Root
|
||||
className="flex h-72 w-76 flex-col"
|
||||
emojibaseUrl={EMOJIBASE_URL}
|
||||
onEmojiSelect={emoji => onSelect(emoji.emoji)}
|
||||
>
|
||||
{/* Borderless, underline-on-focus — the app's SearchField idiom (DESIGN.md),
|
||||
not a boxed search bar. Search matches labels AND emojibase tags
|
||||
("lol" → 😂), which frimousse handles natively. */}
|
||||
<EmojiPicker.Search
|
||||
autoFocus
|
||||
className="mx-1 border-b border-(--ui-stroke-tertiary) bg-transparent px-1 pb-1 text-sm outline-hidden focus:border-(--ui-stroke-secondary)"
|
||||
placeholder="Search…"
|
||||
/>
|
||||
<EmojiPicker.Viewport className="relative flex-1 outline-hidden">
|
||||
<EmojiPicker.Loading className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
|
||||
Loading emoji…
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
|
||||
No emoji found.
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List
|
||||
className="select-none pb-1"
|
||||
components={{
|
||||
CategoryHeader: ({ category, ...props }) => (
|
||||
<div
|
||||
className="bg-(--ui-bg-elevated) px-1.5 pt-2 pb-1 text-[0.6875rem] text-(--ui-text-tertiary)"
|
||||
{...props}
|
||||
>
|
||||
{category.label}
|
||||
</div>
|
||||
),
|
||||
Emoji: ({ emoji, ...props }) => (
|
||||
<button
|
||||
className={cn('grid size-8 shrink-0 place-items-center rounded-md text-lg', cellTint(emoji.emoji))}
|
||||
{...props}
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
),
|
||||
Row: ({ children, ...props }) => (
|
||||
<div className="flex px-1" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
)
|
||||
|
||||
/**
|
||||
* The reaction picker — six quick emoji, then "+" for the full set.
|
||||
*
|
||||
* Rides the shared Popover, so it inherits the app's menu/popover surface
|
||||
* treatment rather than inventing a floating pill (DESIGN.md: popovers get one
|
||||
* shared shadow + hairline; call sites don't reinvent elevation).
|
||||
*/
|
||||
export const ReactionPicker: FC<{
|
||||
align?: 'end' | 'start'
|
||||
children: React.ReactNode
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect: (emoji: string) => void
|
||||
open: boolean
|
||||
selected?: string
|
||||
}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
onOpenChange(next)
|
||||
|
||||
if (!next) {
|
||||
// Always reopen on the quick row.
|
||||
setExpanded(false)
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<PopoverAnchor asChild>{children}</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
// Opt this one surface out of the shared popover glass: emoji hover
|
||||
// tints at 15% alpha are unreadable over blurred transcript text.
|
||||
// Overriding the local surface var keeps the arrow matched for free.
|
||||
className={cn(
|
||||
'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
|
||||
!expanded && 'flex gap-0.5'
|
||||
)}
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
side="top"
|
||||
>
|
||||
{expanded ? (
|
||||
<FullEmojiPicker onSelect={onSelect} />
|
||||
) : (
|
||||
<>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<Button
|
||||
aria-label={emoji}
|
||||
aria-pressed={selected === emoji}
|
||||
className={cn('text-base', selected === emoji && 'bg-(--chrome-action-hover)')}
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(emoji)
|
||||
}}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{emoji}
|
||||
</Button>
|
||||
))}
|
||||
<Button aria-label="More emoji" onClick={() => setExpanded(true)} size="icon-sm" variant="ghost">
|
||||
<Plus />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reactions a message carries.
|
||||
*
|
||||
* Flat by design (DESIGN.md: "Flat, not boxed") — no pill, no border, no fill.
|
||||
* It reads as quiet metadata in the same register as the message age and the
|
||||
* checkpoint row it sits beside. Your own reaction is clickable to retract;
|
||||
* the agent's is display-only.
|
||||
*/
|
||||
export const ReactionBadge: FC<{
|
||||
className?: string
|
||||
onRetract?: () => void
|
||||
reactions: MessageReaction[]
|
||||
}> = ({ className, onRetract, reactions }) => {
|
||||
if (!reactions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-[0.8125rem] leading-none', className)}
|
||||
data-slot="aui_msg-reactions"
|
||||
>
|
||||
{reactions.map(reaction =>
|
||||
reaction.author === 'user' && onRetract ? (
|
||||
<button
|
||||
aria-label={`Remove ${reaction.emoji} reaction`}
|
||||
className="reaction-pop cursor-pointer leading-none transition-transform hover:scale-110 active:scale-95"
|
||||
key={`${reaction.author}-${reaction.emoji}`}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRetract()
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{reaction.emoji}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="reaction-pop leading-none"
|
||||
key={`${reaction.author}-${reaction.emoji}`}
|
||||
title="Reacted by Hermes"
|
||||
>
|
||||
{reaction.emoji}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from '@/app/chat/composer/focus'
|
||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||
import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo'
|
||||
import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions'
|
||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
|
|
@ -112,6 +113,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
const emoji = useEmojiCompletions()
|
||||
|
||||
// This is the one composer that routinely unmounts, so it is where the focus
|
||||
// bus leaks: confirming or cancelling an edit tears the composer down while
|
||||
|
|
@ -282,7 +284,13 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
}, [])
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
trigger?.kind === '@'
|
||||
? at.adapter
|
||||
: trigger?.kind === '/'
|
||||
? slash.adapter
|
||||
: trigger?.kind === ':'
|
||||
? emoji.adapter
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
|
|
@ -298,7 +306,14 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
const triggerLoading =
|
||||
trigger?.kind === '@'
|
||||
? at.loading
|
||||
: trigger?.kind === '/'
|
||||
? slash.loading
|
||||
: trigger?.kind === ':'
|
||||
? emoji.loading
|
||||
: false
|
||||
|
||||
const replaceTriggerWithChip = useCallback(
|
||||
(item: Unstable_TriggerItem) => {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
|
||||
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
|
||||
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { StopFilled } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toggleMessageReaction } from '@/store/reactions'
|
||||
import { $reactionsEnabled } from '@/store/reactions-enabled'
|
||||
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
|
||||
import { notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Stable empty identity — a fresh [] per render would re-run every consumer.
|
||||
const EMPTY_REACTIONS: MessageReaction[] = []
|
||||
|
||||
export function StickyHumanMessageContainer({
|
||||
attachments,
|
||||
|
|
@ -144,6 +154,40 @@ export const UserMessage: FC<{
|
|||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
const reactions = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
|
||||
|
||||
return custom.reactions ?? EMPTY_REACTIONS
|
||||
})
|
||||
|
||||
const rowId = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
|
||||
|
||||
return custom.rowId
|
||||
})
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const reactionsEnabled = useStore($reactionsEnabled)
|
||||
const localAll = useStore($localReactions)
|
||||
const agentLive = useStore($agentReactions)
|
||||
|
||||
const shownReactions = mergeReactions(
|
||||
reactions,
|
||||
localAll[messageId],
|
||||
rowId !== undefined ? agentLive[rowId] : undefined
|
||||
)
|
||||
|
||||
const react = useCallback(
|
||||
(emoji: null | string) => {
|
||||
setPickerOpen(false)
|
||||
// Flip the UI immediately — a tapback is direct manipulation and must
|
||||
// never wait on a round-trip. Persistence follows in the background.
|
||||
setLocalReaction(messageId, emoji)
|
||||
void toggleMessageReaction({ id: messageId, role: 'user', rowId, reactions } as ChatMessage, emoji)
|
||||
},
|
||||
[messageId, reactions, rowId]
|
||||
)
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
|
|
@ -257,84 +301,110 @@ export const UserMessage: FC<{
|
|||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (!bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
onContextMenu={
|
||||
// Right-click is the desktop stand-in for iOS touch-and-hold.
|
||||
readOnly || !reactionsEnabled
|
||||
? undefined
|
||||
: event => {
|
||||
event.preventDefault()
|
||||
setPickerOpen(true)
|
||||
}
|
||||
}
|
||||
>
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (!bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ReactionPicker>
|
||||
{/* Below the bubble, same register as the assistant action row:
|
||||
same emoji size, same vertical padding, right-aligned to the
|
||||
sent bubble. Overlaying the corner read badly in practice. */}
|
||||
<ReactionBadge
|
||||
className="justify-end gap-1.5 py-1.5 pr-1.5"
|
||||
onRetract={() => react(null)}
|
||||
reactions={shownReactions}
|
||||
/>
|
||||
<BranchPickerPrimitive.Root
|
||||
className={cn(
|
||||
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
|
||||
|
|
|
|||
|
|
@ -397,6 +397,8 @@ export const ar = defineLocale({
|
|||
translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل. متاح على macOS وWindows فقط.',
|
||||
backdropTitle: 'خلفية النافذة',
|
||||
backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.',
|
||||
reactionsTitle: 'تفاعلات الرسائل',
|
||||
reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.',
|
||||
embedsTitle: 'التضمينات المضمّنة',
|
||||
embedsDesc:
|
||||
'تُحمّل المعاينات الغنية من مواقع طرف ثالث (YouTube، X، …). "اسأل" يعرض عنصرا نائبا حتى تسمح لكل واحد؛ "دائما" يحمّلها تلقائيا؛ "إيقاف" يبقي الروابط عادية.',
|
||||
|
|
@ -2284,6 +2286,7 @@ export const ar = defineLocale({
|
|||
refresh: 'تحديث',
|
||||
moreActions: 'إجراءات إضافية',
|
||||
branchNewChat: 'تفريع إلى محادثة جديدة',
|
||||
react: 'تفاعل',
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
readAloudFailed: 'فشلت القراءة بصوت عال',
|
||||
preparingAudio: 'جار تجهيز الصوت',
|
||||
|
|
|
|||
|
|
@ -441,6 +441,8 @@ export const en: Translations = {
|
|||
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
|
||||
backdropTitle: 'Chat Backdrop',
|
||||
backdropDesc: 'The faint statue image behind the conversation.',
|
||||
reactionsTitle: 'Message Reactions',
|
||||
reactionsDesc: 'iMessage-style emoji tapbacks — react to messages, and Hermes can react to yours.',
|
||||
embedsTitle: 'Inline Embeds',
|
||||
embedsDesc:
|
||||
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
|
||||
|
|
@ -2703,6 +2705,7 @@ export const en: Translations = {
|
|||
refresh: 'Refresh',
|
||||
moreActions: 'More actions',
|
||||
branchNewChat: 'Branch in new chat',
|
||||
react: 'React',
|
||||
dismissError: 'Dismiss error',
|
||||
readAloudFailed: 'Read aloud failed',
|
||||
preparingAudio: 'Preparing audio...',
|
||||
|
|
|
|||
|
|
@ -318,6 +318,9 @@ export const ja = defineLocale({
|
|||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
backdropTitle: 'チャット背景',
|
||||
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
|
||||
reactionsTitle: 'メッセージリアクション',
|
||||
reactionsDesc:
|
||||
'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。',
|
||||
embedsTitle: 'インライン埋め込み',
|
||||
embedsDesc:
|
||||
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
|
||||
|
|
@ -2533,6 +2536,7 @@ export const ja = defineLocale({
|
|||
refresh: '更新',
|
||||
moreActions: 'その他のアクション',
|
||||
branchNewChat: '新しいチャットでブランチ',
|
||||
react: 'リアクション',
|
||||
dismissError: 'エラーを閉じる',
|
||||
readAloudFailed: '読み上げに失敗しました',
|
||||
preparingAudio: '音声を準備中...',
|
||||
|
|
|
|||
|
|
@ -351,6 +351,8 @@ export interface Translations {
|
|||
translucencyDesc: string
|
||||
backdropTitle: string
|
||||
backdropDesc: string
|
||||
reactionsTitle: string
|
||||
reactionsDesc: string
|
||||
embedsTitle: string
|
||||
embedsDesc: string
|
||||
embedsAsk: string
|
||||
|
|
@ -2301,6 +2303,7 @@ export interface Translations {
|
|||
refresh: string
|
||||
moreActions: string
|
||||
branchNewChat: string
|
||||
react: string
|
||||
dismissError: string
|
||||
readAloudFailed: string
|
||||
preparingAudio: string
|
||||
|
|
|
|||
|
|
@ -310,6 +310,8 @@ export const zhHant = defineLocale({
|
|||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '對話後方那張淡淡的雕像圖片。',
|
||||
reactionsTitle: '訊息回應',
|
||||
reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
embedsDesc:
|
||||
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
|
|
@ -2452,6 +2454,7 @@ export const zhHant = defineLocale({
|
|||
refresh: '重新整理',
|
||||
moreActions: '更多動作',
|
||||
branchNewChat: '在新聊天中分支',
|
||||
react: '回應',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗讀失敗',
|
||||
preparingAudio: '正在準備音訊...',
|
||||
|
|
|
|||
|
|
@ -433,6 +433,8 @@ export const zh: Translations = {
|
|||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '对话后方那张淡淡的雕像图片。',
|
||||
reactionsTitle: '消息回应',
|
||||
reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。',
|
||||
embedsTitle: '内嵌预览',
|
||||
embedsDesc:
|
||||
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
|
|
@ -2879,6 +2881,7 @@ export const zh: Translations = {
|
|||
refresh: '刷新',
|
||||
moreActions: '更多操作',
|
||||
branchNewChat: '在新对话中分支',
|
||||
react: '回应',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗读失败',
|
||||
preparingAudio: '正在准备音频...',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
|
|||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
|
||||
|
|
@ -25,6 +25,10 @@ export type ChatMessage = {
|
|||
interim?: boolean
|
||||
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
|
||||
attachmentRefs?: string[]
|
||||
/** Durable backend `messages.id`. Absent until the row is persisted. */
|
||||
rowId?: number
|
||||
/** Emoji reactions on this message — one per author (see MessageReaction). */
|
||||
reactions?: MessageReaction[]
|
||||
}
|
||||
|
||||
export type GatewayEventPayload = {
|
||||
|
|
@ -84,6 +88,12 @@ export type GatewayEventPayload = {
|
|||
kind?: string
|
||||
// pane.reveal (agent focusing a desktop pane via the focus_pane tool)
|
||||
pane?: string
|
||||
// message.reaction (agent reacting via the react_to_message tool) — the
|
||||
// durable messages.id, that row's full reaction list after the write, and
|
||||
// the row's role so a live (not-yet-round-tripped) message can be matched.
|
||||
row_id?: number
|
||||
reactions?: MessageReaction[]
|
||||
role?: string
|
||||
// session.title (live auto-title push) — stored session id + generated title
|
||||
session_id?: string
|
||||
title?: string
|
||||
|
|
@ -334,26 +344,38 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
|
|||
|
||||
// A remote backend older than this app serves display_metadata as raw JSON text,
|
||||
// and `in` throws on a primitive — which used to fail the whole session resume.
|
||||
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
|
||||
function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record<string, unknown> {
|
||||
let parsed: unknown = metadata
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed)
|
||||
} catch {
|
||||
return undefined
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
const count = (parsed as { task_count?: unknown }).task_count
|
||||
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
|
||||
const count = parseDisplayMetadata(metadata)?.task_count
|
||||
|
||||
return typeof count === 'number' ? count : undefined
|
||||
}
|
||||
|
||||
export function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] {
|
||||
const reactions = parseDisplayMetadata(metadata)?.reactions
|
||||
|
||||
if (!Array.isArray(reactions)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return reactions.filter(
|
||||
(r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function timelineDisplayContent(message: SessionMessage, content: string): string {
|
||||
if (message.display_kind === 'model_switch') {
|
||||
return 'model changed'
|
||||
|
|
@ -1044,11 +1066,19 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
flushPendingTools(index)
|
||||
}
|
||||
|
||||
const reactions = messageReactions(message.display_metadata)
|
||||
// Gateway resume names the durable row id `row_id`; the REST transcript
|
||||
// prefetch ships the same messages.id as a numeric `id`. Either one lets
|
||||
// reactions address this exact row later.
|
||||
const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined)
|
||||
|
||||
result.push({
|
||||
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
|
||||
role: displayRole,
|
||||
parts,
|
||||
timestamp: message.timestamp,
|
||||
...(rowId !== undefined ? { rowId } : {}),
|
||||
...(reactions.length ? { reactions } : {}),
|
||||
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -387,6 +387,13 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
|
||||
const createdAt = messageCreatedAt(message)
|
||||
|
||||
// Reactions and the durable row id ride metadata.custom for every role — the
|
||||
// established channel for per-message extras (attachmentRefs below).
|
||||
const reactionMeta = {
|
||||
...(message.rowId !== undefined ? { rowId: message.rowId } : {}),
|
||||
...(message.reactions?.length ? { reactions: message.reactions } : {})
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
return {
|
||||
id: message.id,
|
||||
|
|
@ -394,7 +401,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta } }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
|
|
@ -426,7 +433,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
unstable_data: [],
|
||||
steps: [],
|
||||
// Carries ChatMessage.interim to AssistantMessage's footer gate.
|
||||
custom: message.interim ? { interim: true } : {}
|
||||
custom: { ...(message.interim ? { interim: true } : {}), ...reactionMeta }
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
40
apps/desktop/src/store/reactions-enabled.ts
Normal file
40
apps/desktop/src/store/reactions-enabled.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Message reactions (iMessage-style tapbacks) — opt-in.
|
||||
*
|
||||
* Off by default: reactions add affordances to every message row (the ☺ slot,
|
||||
* right-click pickers, :shortcode: completions), and the agent gains a tool
|
||||
* that reacts to your messages. Presentation-scoped, so the renderer owns it
|
||||
* (desktop AGENTS.md: state lives with its authority).
|
||||
*
|
||||
* Gates the UI only — persisted reactions still render if the data exists
|
||||
* (a reaction you set before turning it off shouldn't vanish from history).
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
|
||||
const KEY = 'hermes.desktop.reactions.v1'
|
||||
|
||||
export const $reactionsEnabled = atom<boolean>(typeof window === 'undefined' ? false : storedString(KEY) === 'on')
|
||||
|
||||
export function setReactionsEnabled(enabled: boolean): void {
|
||||
$reactionsEnabled.set(enabled)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// listen, not subscribe: fire on CHANGE only, so app startup doesn't write
|
||||
// config.set (or clobber a profile's setting with another window's default).
|
||||
$reactionsEnabled.listen(enabled => {
|
||||
persistString(KEY, enabled ? 'on' : 'off')
|
||||
// Mirror into gateway config: the backend gates the agent's
|
||||
// react_to_message tool and the model-context annotation on
|
||||
// display.message_reactions, so the renderer toggle is the one lever.
|
||||
void activeGateway()
|
||||
?.request('config.set', { key: 'display.message_reactions', value: enabled ? 'true' : 'false' })
|
||||
.catch(() => {
|
||||
// Not connected yet — the next toggle (or default-off) still holds.
|
||||
})
|
||||
})
|
||||
}
|
||||
67
apps/desktop/src/store/reactions-local.ts
Normal file
67
apps/desktop/src/store/reactions-local.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { applyReaction } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
/**
|
||||
* Reactions the user has set in THIS window, keyed by renderer message id.
|
||||
*
|
||||
* The UI owns this outright. A tapback is a direct manipulation — it flips the
|
||||
* instant you click it, with no round-trip, no gateway, and no dependency on a
|
||||
* message having been persisted yet. Durable state (and the agent's own
|
||||
* reactions) still arrive through `metadata.custom.reactions`; this layer sits
|
||||
* on top of it so the interaction never waits on the backend to feel alive.
|
||||
*/
|
||||
export const $localReactions = atom<Record<string, MessageReaction[]>>({})
|
||||
|
||||
/**
|
||||
* Agent reactions announced live (`message.reaction` events), keyed by the
|
||||
* DURABLE row id — never the renderer message id, which the end-of-turn
|
||||
* resume regenerates (an overlay keyed on the old id would orphan the instant
|
||||
* the transcript rebuilds; "identity is not incidental", AGENTS.md). The
|
||||
* resume also rebuilds from the gateway's in-memory history, which doesn't
|
||||
* carry a reaction written to the DB mid-turn — this overlay outlives that
|
||||
* clobber and a real reload hydrates the same reaction from disk.
|
||||
*/
|
||||
export const $agentReactions = atom<Record<number, MessageReaction[]>>({})
|
||||
|
||||
/** Record an agent reaction painted from a live gateway event. */
|
||||
export function recordAgentReaction(rowId: number, reactions: MessageReaction[]): void {
|
||||
$agentReactions.set({
|
||||
...$agentReactions.get(),
|
||||
[rowId]: reactions.filter(reaction => reaction.author === 'agent')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the durable reaction list with anything this window knows live.
|
||||
*
|
||||
* The user's slot: local wins (they just clicked it — newer by definition).
|
||||
* The agent's slot: the live-event overlay wins over persisted (a mid-turn
|
||||
* reaction reaches the DB before the in-memory history the next resume
|
||||
* projects from), falling back to what the transcript carried.
|
||||
*/
|
||||
export function mergeReactions(
|
||||
persisted: MessageReaction[] | undefined,
|
||||
local: MessageReaction[] | undefined,
|
||||
agentLive?: MessageReaction[]
|
||||
): MessageReaction[] {
|
||||
const persistedList = persisted ?? []
|
||||
|
||||
const userSide = local
|
||||
? local.filter(reaction => reaction.author === 'user')
|
||||
: persistedList.filter(reaction => reaction.author === 'user')
|
||||
|
||||
const agentSide = agentLive ?? persistedList.filter(reaction => reaction.author === 'agent')
|
||||
|
||||
return [...userSide, ...agentSide]
|
||||
}
|
||||
|
||||
/** Toggle the user's reaction on a message — instant, local, no round-trip. */
|
||||
export function setLocalReaction(messageId: string, emoji: null | string): MessageReaction[] {
|
||||
const next = applyReaction($localReactions.get()[messageId], emoji, 'user')
|
||||
|
||||
$localReactions.set({ ...$localReactions.get(), [messageId]: next })
|
||||
|
||||
return next
|
||||
}
|
||||
59
apps/desktop/src/store/reactions.test.ts
Normal file
59
apps/desktop/src/store/reactions.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyReaction, QUICK_REACTIONS } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
const at = 1_700_000_000
|
||||
|
||||
function reaction(emoji: string, author: MessageReaction['author']): MessageReaction {
|
||||
return { emoji, author, at }
|
||||
}
|
||||
|
||||
describe('applyReaction', () => {
|
||||
it('adds a reaction to an empty message', () => {
|
||||
expect(applyReaction(undefined, '❤️', 'user')).toMatchObject([{ emoji: '❤️', author: 'user' }])
|
||||
})
|
||||
|
||||
it('replaces the same 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)
|
||||
})
|
||||
})
|
||||
93
apps/desktop/src/store/reactions.ts
Normal file
93
apps/desktop/src/store/reactions.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, $messages, setMessages } from '@/store/session'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
/** The six iOS Tapback defaults, in Apple's order. */
|
||||
export const QUICK_REACTIONS = ['❤️', '👍', '👎', '😂', '‼️', '❓'] as const
|
||||
|
||||
interface MessageReactResponse {
|
||||
row_id: number
|
||||
reactions: MessageReaction[]
|
||||
}
|
||||
|
||||
/** Apply the local half of a tapback: one reaction per author, re-tap retracts. */
|
||||
export function applyReaction(
|
||||
reactions: MessageReaction[] | undefined,
|
||||
emoji: null | string,
|
||||
author: MessageReaction['author']
|
||||
): MessageReaction[] {
|
||||
const current = reactions ?? []
|
||||
const previous = current.find(reaction => reaction.author === author)
|
||||
const without = current.filter(reaction => reaction.author !== author)
|
||||
|
||||
if (!emoji || previous?.emoji === emoji) {
|
||||
return without
|
||||
}
|
||||
|
||||
return [...without, { emoji, author, at: Date.now() / 1000 }]
|
||||
}
|
||||
|
||||
function writeReactions(messageId: string, reactions: MessageReaction[], rowId?: number) {
|
||||
// A NEW ChatMessage object per change is load-bearing: the runtime
|
||||
// repository caches normalized ThreadMessages in a WeakMap keyed by
|
||||
// ChatMessage identity, so a mutation in place renders stale.
|
||||
// Keyed by the renderer id, not rowId: a live message has no rowId yet.
|
||||
setMessages(messages =>
|
||||
messages.map(message =>
|
||||
message.id === messageId ? { ...message, reactions, ...(rowId === undefined ? {} : { rowId }) } : message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle *author*'s reaction on a persisted message.
|
||||
*
|
||||
* Optimistic: paints immediately, then lets the backend's returned list win.
|
||||
* A failed write rolls back to the snapshot (desktop AGENTS.md — "be optimistic,
|
||||
* then honest").
|
||||
*/
|
||||
export async function toggleMessageReaction(
|
||||
message: ChatMessage,
|
||||
emoji: null | string,
|
||||
author: MessageReaction['author'] = 'user'
|
||||
): Promise<void> {
|
||||
// A live message hasn't round-tripped through a resume yet, so it carries no
|
||||
// rowId. Rather than disable the affordance (which made reactions invisible
|
||||
// in any active conversation), let the backend resolve the newest row of
|
||||
// this role — which is exactly the message being reacted to.
|
||||
const rowId = message.rowId
|
||||
const sessionId = $activeSessionId.get()
|
||||
const gateway = activeGateway()
|
||||
|
||||
if (!sessionId || !gateway) {
|
||||
notifyError(
|
||||
new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
|
||||
'Could not react'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = $messages.get().find(m => m.id === message.id)?.reactions
|
||||
|
||||
writeReactions(message.id, applyReaction(snapshot, emoji, author))
|
||||
|
||||
try {
|
||||
const result = await gateway.request<MessageReactResponse>('message.react', {
|
||||
session_id: sessionId,
|
||||
...(rowId === undefined ? { newest_role: message.role } : { row_id: rowId }),
|
||||
emoji,
|
||||
author
|
||||
})
|
||||
|
||||
// Learn the row id from the response so later toggles address it directly.
|
||||
writeReactions(message.id, result?.reactions ?? [], result?.row_id)
|
||||
} catch (err) {
|
||||
// Be optimistic, THEN honest: a rejected write rolls back visibly and says
|
||||
// why, instead of the reaction quietly vanishing (desktop AGENTS.md).
|
||||
writeReactions(message.id, snapshot ?? [])
|
||||
notifyError(err, 'Could not react')
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,9 +36,47 @@ const debugEntry = (command: string, env: Record<string, string>) =>
|
|||
? path.resolve(__dirname, './src/debug/dev-only.ts')
|
||||
: path.resolve(__dirname, './src/debug/dev-only.noop.ts')
|
||||
|
||||
// The emoji picker (frimousse) fetches `<emojibaseUrl>/<locale>/data.json` at
|
||||
// runtime. Its default is a CDN; Electron must work offline, so serve the
|
||||
// bundled emojibase-data package at a stable local path instead — middleware
|
||||
// in dev, emitted assets in the build. Only the files a locale actually needs.
|
||||
const emojibaseDir =
|
||||
real(path.resolve(__dirname, 'node_modules/emojibase-data')) ??
|
||||
real(path.resolve(__dirname, '../../node_modules/emojibase-data'))
|
||||
|
||||
const EMOJIBASE_PATH = /^[a-z-]+\/(data|messages|shortcodes\/emojibase)\.json$/
|
||||
|
||||
const emojibaseAssets = () => ({
|
||||
name: 'hermes:emojibase-assets',
|
||||
configureServer(server: {
|
||||
middlewares: { use: (route: string, handler: (req: any, res: any, next: () => void) => void) => void }
|
||||
}) {
|
||||
server.middlewares.use('/emojibase', (req, res, next) => {
|
||||
const rel = (req.url ?? '').split('?')[0].replace(/^\/+/, '')
|
||||
if (!emojibaseDir || !EMOJIBASE_PATH.test(rel)) return next()
|
||||
fs.readFile(path.join(emojibaseDir, rel), (err: unknown, buf: Buffer) => {
|
||||
if (err) return next()
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.end(buf)
|
||||
})
|
||||
})
|
||||
},
|
||||
generateBundle(this: { emitFile: (asset: { type: 'asset'; fileName: string; source: Uint8Array }) => void }) {
|
||||
if (!emojibaseDir) return
|
||||
for (const rel of ['en/data.json', 'en/messages.json', 'en/shortcodes/emojibase.json']) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `emojibase/${rel}`,
|
||||
source: fs.readFileSync(path.join(emojibaseDir, rel))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), tailwindcss(), emojibaseAssets()],
|
||||
css: {
|
||||
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
|
||||
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and
|
||||
|
|
|
|||
228
hermes_state.py
228
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
|
||||
|
||||
|
|
|
|||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
tests/test_message_reactions.py
Normal file
175
tests/test_message_reactions.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Message reactions: persistence, tapback semantics, and cache safety.
|
||||
|
||||
Behavior contracts, not snapshots — these assert how reactions must RELATE to
|
||||
the transcript (one per author, announced once, never mutating history), so
|
||||
they survive refactors of where reactions are stored.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(db):
|
||||
key = db.create_session("react-test", "test")
|
||||
db.append_message(key, "user", "how do i center a div")
|
||||
db.append_message(key, "assistant", "use flexbox")
|
||||
rows = [m["_row_id"] for m in db.get_messages_as_conversation(key, include_row_ids=True)]
|
||||
|
||||
return key, rows
|
||||
|
||||
|
||||
def test_conversation_rows_carry_durable_row_id(session, db):
|
||||
"""Every projected message exposes its messages.id — reactions key off it."""
|
||||
key, rows = session
|
||||
|
||||
assert all(isinstance(r, int) for r in rows)
|
||||
assert rows == sorted(rows), "row ids must follow insertion order"
|
||||
assert len(set(rows)) == len(rows), "row ids must be unique"
|
||||
|
||||
|
||||
def test_one_reaction_per_author(session, db):
|
||||
"""A second emoji from the same author REPLACES the first (iOS Tapback)."""
|
||||
key, rows = session
|
||||
|
||||
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
|
||||
reactions = db.set_message_reaction(key, rows[0], "\U0001f602", author="user")
|
||||
|
||||
assert [r["emoji"] for r in reactions] == ["\U0001f602"]
|
||||
|
||||
|
||||
def test_repeating_an_emoji_retracts_it(session, db):
|
||||
"""Tapping the live reaction again clears it."""
|
||||
key, rows = session
|
||||
|
||||
db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
|
||||
reactions = db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
|
||||
|
||||
assert reactions == []
|
||||
assert db.get_message_reactions(key, rows[0]) == []
|
||||
|
||||
|
||||
def test_authors_are_independent(session, db):
|
||||
"""User and agent each hold their own slot on the same message."""
|
||||
key, rows = session
|
||||
|
||||
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
|
||||
reactions = db.set_message_reaction(key, rows[0], "\U0001f525", author="agent")
|
||||
|
||||
assert {r["author"] for r in reactions} == {"user", "agent"}
|
||||
|
||||
remaining = db.set_message_reaction(key, rows[0], None, author="user")
|
||||
assert [r["author"] for r in remaining] == ["agent"]
|
||||
|
||||
|
||||
def test_rejects_rows_outside_the_session(session, db):
|
||||
"""A row id from another conversation is never writable."""
|
||||
key, rows = session
|
||||
other = db.create_session("other", "test")
|
||||
db.append_message(other, "user", "elsewhere")
|
||||
|
||||
assert db.set_message_reaction(key, 9999, "\u2764\ufe0f") is None
|
||||
assert db.set_message_reaction("no-such-session", rows[0], "\u2764\ufe0f") is None
|
||||
|
||||
|
||||
def test_clearing_every_reaction_leaves_no_metadata(session, db):
|
||||
"""An empty reaction set removes the key instead of persisting `[]`."""
|
||||
key, rows = session
|
||||
|
||||
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
|
||||
db.set_message_reaction(key, rows[0], None, author="user")
|
||||
|
||||
message = db.get_messages_as_conversation(key)[0]
|
||||
assert "display_metadata" not in message
|
||||
|
||||
|
||||
def test_reactions_survive_reload(session, db):
|
||||
"""Reactions are durable, not in-memory display state."""
|
||||
key, rows = session
|
||||
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
|
||||
|
||||
reopened = SessionDB(db_path=db.db_path)
|
||||
assert [r["emoji"] for r in reopened.get_message_reactions(key, rows[1])] == ["\U0001f525"]
|
||||
|
||||
|
||||
def test_unseen_reactions_are_taken_exactly_once(session, db):
|
||||
"""The model is told about a reaction on ONE turn, never twice."""
|
||||
key, rows = session
|
||||
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
|
||||
|
||||
first = db.take_unseen_reactions(key, author="user")
|
||||
assert [e["emoji"] for e in first] == ["\u2764\ufe0f"]
|
||||
assert first[0]["row_id"] == rows[1]
|
||||
assert first[0]["text"] == "use flexbox"
|
||||
|
||||
assert db.take_unseen_reactions(key, author="user") == []
|
||||
|
||||
|
||||
def test_a_new_reaction_becomes_unseen_again(session, db):
|
||||
"""Replacing a seen reaction re-arms the announcement."""
|
||||
key, rows = session
|
||||
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
|
||||
db.take_unseen_reactions(key, author="user")
|
||||
|
||||
db.set_message_reaction(key, rows[1], "\U0001f525", author="user")
|
||||
|
||||
assert [e["emoji"] for e in db.take_unseen_reactions(key, author="user")] == ["\U0001f525"]
|
||||
|
||||
|
||||
def test_take_unseen_filters_by_author(session, db):
|
||||
"""The agent's own reactions are never fed back to it as user input."""
|
||||
key, rows = session
|
||||
db.set_message_reaction(key, rows[0], "\U0001f60a", author="agent")
|
||||
|
||||
assert db.take_unseen_reactions(key, author="user") == []
|
||||
|
||||
|
||||
def test_reacting_never_mutates_message_content(session, db):
|
||||
"""CACHE SAFETY: reacting must not rewrite any already-sent message.
|
||||
|
||||
Rewriting a past message would invalidate the provider's cached prefix for
|
||||
the whole conversation — the reason reactions ride display_metadata and are
|
||||
announced on the NEXT turn instead.
|
||||
"""
|
||||
key, rows = session
|
||||
before = [m["content"] for m in db.get_messages_as_conversation(key)]
|
||||
|
||||
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
|
||||
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
|
||||
db.take_unseen_reactions(key, author="user")
|
||||
|
||||
after = [m["content"] for m in db.get_messages_as_conversation(key)]
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_latest_user_message_is_the_agents_default_target(session, db):
|
||||
"""The agent reacts to "the message that triggered me" without an id."""
|
||||
key, rows = session
|
||||
assert db.latest_user_message_row_id(key) == rows[0]
|
||||
|
||||
db.append_message(key, "user", "thanks!")
|
||||
newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"]
|
||||
|
||||
assert db.latest_user_message_row_id(key) == newest
|
||||
|
||||
|
||||
def test_row_id_is_opt_in_and_never_reaches_the_provider(session, db):
|
||||
"""Only include_row_ids=True consumers see _row_id — and it's underscore-
|
||||
prefixed so transports strip it before the wire even for them. Default
|
||||
consumers (ACP restore, export) get the transcript in its historical shape.
|
||||
"""
|
||||
key, _rows = session
|
||||
|
||||
for message in db.get_messages_as_conversation(key):
|
||||
assert "_row_id" not in message
|
||||
|
||||
for message in db.get_messages_as_conversation(key, include_row_ids=True):
|
||||
assert "_row_id" in message
|
||||
assert all(not k.startswith("_") or k == "_row_id" for k in message)
|
||||
|
|
@ -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"}]
|
||||
|
||||
|
|
|
|||
167
tools/react_to_message_tool.py
Normal file
167
tools/react_to_message_tool.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Let the agent react to a message with an emoji in the Hermes desktop app.
|
||||
|
||||
The conversational counterpart to the user's tapback: the same reaction store,
|
||||
the same one-per-author semantics, just written with ``author="agent"``.
|
||||
|
||||
Gated on ``HERMES_DESKTOP`` (like the other GUI affordances) so it costs nothing
|
||||
on every other surface — the platform adapters already expose reactions through
|
||||
``send_message(action="react")``, and this is the desktop's equivalent.
|
||||
|
||||
Defaults to the message that triggered this turn (the photon precedent: the
|
||||
model shouldn't have to thread row ids through tool calls), and emits
|
||||
``message.reaction`` so the renderer paints it without waiting for a resume.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from gateway.session_context import get_session_env
|
||||
from tools import desktop_ui
|
||||
from tools.registry import registry, tool_error
|
||||
from utils import env_var_enabled
|
||||
|
||||
|
||||
def _open_session_db():
|
||||
"""Open the SessionDB for the profile owning this turn, or ``None``."""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
return SessionDB()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -> str:
|
||||
"""Attach (or with an empty ``emoji`` retract) the agent's reaction."""
|
||||
emoji = (emoji or "").strip()
|
||||
session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env(
|
||||
"HERMES_SESSION_ID", ""
|
||||
)
|
||||
|
||||
if not session_key:
|
||||
return tool_error("No active session — reactions need a persisted conversation.")
|
||||
|
||||
db = _open_session_db()
|
||||
if db is None:
|
||||
return tool_error("Session storage is unavailable.")
|
||||
|
||||
row_id = message_row_id
|
||||
target_role = "user"
|
||||
if row_id is None:
|
||||
# Default target: the latest user message. `messages_back` steps to
|
||||
# earlier user turns (1 = the one before, etc.) for retroactive
|
||||
# reactions — quoting text would be ambiguous, ids aren't visible to
|
||||
# the model, but "two messages ago" is how a person thinks about it.
|
||||
back = max(0, int(messages_back or 0))
|
||||
row_id = db.latest_message_row_id(session_key, role="user", offset=back)
|
||||
if row_id is None:
|
||||
return tool_error(
|
||||
f"No user message found {back} back." if back else "No user message to react to yet."
|
||||
)
|
||||
else:
|
||||
row = db.get_message_role(session_key, int(row_id))
|
||||
target_role = row or "user"
|
||||
|
||||
try:
|
||||
reactions = db.set_message_reaction(
|
||||
session_key, int(row_id), emoji or None, author="agent"
|
||||
)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to set the reaction: {exc}")
|
||||
|
||||
if reactions is None:
|
||||
return tool_error(f"Message {row_id} is not part of this conversation.")
|
||||
|
||||
# Paint it live. A missing bridge (non-desktop surface) is not an error —
|
||||
# the reaction is persisted either way and shows on the next load.
|
||||
# `role` lets the renderer match a live message that doesn't know its
|
||||
# durable row id yet (it only learns rowId on resume).
|
||||
try:
|
||||
desktop_ui.emit(
|
||||
"message.reaction",
|
||||
{"row_id": int(row_id), "reactions": reactions, "role": target_role},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps(
|
||||
{"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def check_react_requirements() -> bool:
|
||||
"""Desktop GUI only, and opt-in.
|
||||
|
||||
HERMES_DESKTOP is set on the gateway the app spawns; the feature itself is
|
||||
off by default and enabled from Settings → Appearance (the desktop mirrors
|
||||
the toggle into ``display.message_reactions``).
|
||||
"""
|
||||
if not env_var_enabled("HERMES_DESKTOP"):
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
|
||||
display = load_config_readonly().get("display")
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(display, dict) and bool(display.get("message_reactions", False))
|
||||
|
||||
|
||||
REACT_TO_MESSAGE_SCHEMA = {
|
||||
"name": "react_to_message",
|
||||
"description": (
|
||||
"React to a message with a single emoji, the way you'd tapback in iMessage. "
|
||||
"Reach for it when a reaction is what a person would do: something funny gets "
|
||||
"a 😂, warmth gets a ❤️, a plan you're on board with gets a 👍 — then just "
|
||||
"carry on with whatever the message actually needs. If a reaction says it "
|
||||
"all, it can BE the reply (skip the redundant 'sounds good!' turn). Use it "
|
||||
"like a person would: occasionally, when felt — not on every message, and "
|
||||
"never as a status signal. NEVER narrate or explain a reaction ('I reacted "
|
||||
"with...', 'Reacting now') — the emoji appearing on the bubble is the whole "
|
||||
"point, and commentary kills it. Defaults to the user's most recent message. "
|
||||
"One reaction per message: a different emoji replaces yours, an empty string "
|
||||
"retracts it."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The emoji to react with (e.g. '❤️', '😂', '👍'). Pass an empty "
|
||||
"string to remove your reaction."
|
||||
),
|
||||
},
|
||||
"message_row_id": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Optional. The specific message to react to. Omit to react to the "
|
||||
"user's latest message, which is almost always what you want."
|
||||
),
|
||||
},
|
||||
"messages_back": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Optional. React to an EARLIER user message: 1 = the one before "
|
||||
"the latest, 2 = two before, and so on. For when something lands "
|
||||
"late — the joke you only got after answering."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["emoji"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
registry.register(
|
||||
name="react_to_message",
|
||||
toolset="terminal",
|
||||
schema=REACT_TO_MESSAGE_SCHEMA,
|
||||
handler=lambda args, **kw: react_to_message_tool(
|
||||
emoji=args.get("emoji", ""),
|
||||
message_row_id=args.get("message_row_id"),
|
||||
messages_back=args.get("messages_back"),
|
||||
),
|
||||
check_fn=check_react_requirements,
|
||||
emoji="💛",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue