feat(desktop): two-way reaction UI — tapback pill, frimousse picker, live paint

One slot, Slack-style: on assistant rows the picker trigger and the landed
reaction are the same far-right element, so reacting never shifts layout
(empty → ☺ following the action bar's hover fade; reacted → the emoji, always
visible, always full-strength). User bubbles react via right-click and show
the badge beneath, in the checkpoint row's register.

- Clicks paint instantly from a local nanostores overlay — no round-trip in
  the loop; the RPC persists behind it and rolls back visibly on rejection
- Agent reactions land via the message.reaction event into an overlay keyed
  by DURABLE row id, so the end-of-turn resume (which regenerates renderer
  ids and rebuilds from in-memory history) can't clobber the paint
- Full picker is frimousse behind the six-emoji quick row, fed from bundled
  emojibase-data served at ./emojibase by a small vite plugin (offline, no
  CDN), with Slack-style alternating cell tints keyed off the codepoint
- Reaction picker opts out of the shared popover glass: solid surface so
  15%-alpha hover tints stay readable
- react_to_message tool blocks are suppressed in the transcript (like todo):
  the reaction appearing IS the UI; failures still render
- rowId reaches rehydrated messages from both transcript shapes (gateway
  row_id, REST numeric id)
This commit is contained in:
Brooklyn Nicholson 2026-07-29 21:04:22 -05:00
parent 7d92056c49
commit 6ec319f530
23 changed files with 991 additions and 543 deletions

View file

@ -12,9 +12,9 @@
},
"scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
"clean:renderer":"tsc --build tsconfig.json --clean ",
"clean:electron":"tsc --build tsconfig.electron.json --clean",
"clean:e2e": "tsc --build tsconfig.e2e.json --clean",
"clean:renderer": "tsc --build tsconfig.json --clean ",
"clean:electron": "tsc --build tsconfig.electron.json --clean",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
@ -101,7 +101,9 @@
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"emojibase-data": "^16.0.3",
"fflate": "^0.8.3",
"frimousse": "^0.3.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",

View file

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

View file

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

View file

@ -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,22 @@ 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 { $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 +143,41 @@ 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 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 +211,40 @@ 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. */}
<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={() => setPickerOpen(open => !open)}
tooltip={copy.react}
>
{shownReactions.length > 0 ? (
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
{shownReactions.map(reaction => (
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
{reaction.emoji}
</span>
))}
</span>
) : (
<SmilePlusIcon className="size-3.5" />
)}
</TooltipIconButton>
</ReactionPicker>
</div>
)
}

View file

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

View file

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

View file

@ -1,18 +1,27 @@
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 { $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 +153,39 @@ 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 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 +299,111 @@ 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.
// Only offered once the row is persisted (rowId present).
readOnly
? undefined
: event => {
event.preventDefault()
setPickerOpen(true)
}
}
>
{readOnly ? (
// Spectator transcript: clicking only toggles the clamp so the
// full prompt is readable — never opens an edit composer.
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
aria-expanded={bodyClamped ? expanded : undefined}
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
onClick={() => {
if (!bodyClamped) {
return
}
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
) : (
// Always editable — clicking opens the edit composer even while a
// turn streams; sending the edit reverts (interrupt + rewind).
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
{StopGlyph}
{bubbleContent}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
type="button"
>
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
)}
</div>
)}
</div>
</ReactionPicker>
{/* Below the bubble, same register as the assistant action row:
same emoji size, same vertical padding, right-aligned to the
sent bubble. Overlaying the corner read badly in practice. */}
<ReactionBadge
className="justify-end gap-1.5 py-1.5 pr-1.5"
onRetract={() => react(null)}
reactions={shownReactions}
/>
<BranchPickerPrimitive.Root
className={cn(
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',

View file

@ -2284,6 +2284,7 @@ export const ar = defineLocale({
refresh: 'تحديث',
moreActions: 'إجراءات إضافية',
branchNewChat: 'تفريع إلى محادثة جديدة',
react: 'تفاعل',
dismissError: 'تجاهل الخطأ',
readAloudFailed: 'فشلت القراءة بصوت عال',
preparingAudio: 'جار تجهيز الصوت',

View file

@ -2703,6 +2703,7 @@ export const en: Translations = {
refresh: 'Refresh',
moreActions: 'More actions',
branchNewChat: 'Branch in new chat',
react: 'React',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',

View file

@ -2533,6 +2533,7 @@ export const ja = defineLocale({
refresh: '更新',
moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ',
react: 'リアクション',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',

View file

@ -2301,6 +2301,7 @@ export interface Translations {
refresh: string
moreActions: string
branchNewChat: string
react: string
dismissError: string
readAloudFailed: string
preparingAudio: string

View file

@ -2452,6 +2452,7 @@ export const zhHant = defineLocale({
refresh: '重新整理',
moreActions: '更多動作',
branchNewChat: '在新聊天中分支',
react: '回應',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',

View file

@ -2879,6 +2879,7 @@ export const zh: Translations = {
refresh: '刷新',
moreActions: '更多操作',
branchNewChat: '在新对话中分支',
react: '回应',
dismissError: '关闭错误',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

492
package-lock.json generated
View file

@ -120,7 +120,9 @@
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"emojibase-data": "^16.0.3",
"fflate": "^0.8.3",
"frimousse": "^0.3.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@ -1927,448 +1929,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -10079,6 +9639,33 @@
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"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/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -11197,6 +10784,25 @@
}
}
},
"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
}
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@ -18369,7 +17975,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",