feat(desktop): reactions are opt-in under Settings → Appearance, off by default

One lever, every surface. The renderer toggle persists locally and mirrors
into display.message_reactions; the backend gates the agent's
react_to_message tool (check_fn) and the model-context annotation on the same
key, and the ':' composer trigger reads the store at detection time. Off
means off everywhere: no ☺ slot, no right-click picker, no :shortcode:
popover, no agent reactions, and the model hears nothing — while reactions
already persisted keep rendering so history doesn't lose data. Also fixes the
import-order lint error CI flagged in composer/index.tsx.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 21:37:12 -05:00
parent 1af8839139
commit fec1ac0a7a
14 changed files with 134 additions and 31 deletions

View File

@ -36,7 +36,6 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff
import { markActiveComposer } from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useEmojiCompletions } from './hooks/use-emoji-completions'
import { useComposerBranch } from './hooks/use-composer-branch'
import { useComposerDraft } from './hooks/use-composer-draft'
import { useComposerDrop } from './hooks/use-composer-drop'
@ -50,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'

View File

@ -1,4 +1,5 @@
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
@ -185,7 +186,9 @@ export function detectTrigger(textBefore: string): TriggerState | null {
}
// After `@` so a directive starter's colon (`@file:`) stays an `@` query.
const emoji = EMOJI_TRIGGER_RE.exec(textBefore)
// 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 }

View File

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

View File

@ -33,6 +33,7 @@ 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'
@ -156,6 +157,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
})
const [pickerOpen, setPickerOpen] = useState(false)
const reactionsEnabled = useStore($reactionsEnabled)
const localAll = useStore($localReactions)
const agentLive = useStore($agentReactions)
@ -219,32 +221,34 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
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}
{(reactionsEnabled || shownReactions.length > 0) && (
<ReactionPicker
onOpenChange={setPickerOpen}
onSelect={react}
open={pickerOpen}
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
>
{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>
<TooltipIconButton
data-reacted={shownReactions.length > 0 || undefined}
data-slot="aui_msg-reactions"
data-state={pickerOpen ? 'open' : undefined}
onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined}
tooltip={copy.react}
>
{shownReactions.length > 0 ? (
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
{shownReactions.map(reaction => (
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
{reaction.emoji}
</span>
))}
</span>
) : (
<SmilePlusIcon className="size-3.5" />
)}
</TooltipIconButton>
</ReactionPicker>
)}
</div>
)
}

View File

@ -15,6 +15,7 @@ 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'
@ -166,6 +167,7 @@ export const UserMessage: FC<{
})
const [pickerOpen, setPickerOpen] = useState(false)
const reactionsEnabled = useStore($reactionsEnabled)
const localAll = useStore($localReactions)
const agentLive = useStore($agentReactions)
@ -309,8 +311,7 @@ export const UserMessage: FC<{
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
readOnly || !reactionsEnabled
? undefined
: event => {
event.preventDefault()

View File

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

View File

@ -441,6 +441,8 @@ export const en: Translations = {
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
backdropTitle: 'Chat Backdrop',
backdropDesc: 'The faint statue image behind the conversation.',
reactionsTitle: 'Message Reactions',
reactionsDesc: 'iMessage-style emoji tapbacks — react to messages, and Hermes can react to yours.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',

View File

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

View File

@ -351,6 +351,8 @@ export interface Translations {
translucencyDesc: string
backdropTitle: string
backdropDesc: string
reactionsTitle: string
reactionsDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string

View File

@ -310,6 +310,8 @@ export const zhHant = defineLocale({
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '對話後方那張淡淡的雕像圖片。',
reactionsTitle: '訊息回應',
reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應Hermes 也能回應你的訊息。',
embedsTitle: '內嵌預覽',
embedsDesc:
'豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',

View File

@ -433,6 +433,8 @@ export const zh: Translations = {
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '对话后方那张淡淡的雕像图片。',
reactionsTitle: '消息回应',
reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应Hermes 也能回应你的消息。',
embedsTitle: '内嵌预览',
embedsDesc:
'富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',

View File

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

View File

@ -90,8 +90,21 @@ def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -
def check_react_requirements() -> bool:
"""Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns."""
return env_var_enabled("HERMES_DESKTOP")
"""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 = {

View File

@ -26,6 +26,15 @@ def _pending_reaction_notes(session: dict) -> str:
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: