From fec1ac0a7acfda0b926616e031bef59c30a762c6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 21:37:12 -0500 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20reactions=20are=20opt-in=20und?= =?UTF-8?q?er=20Settings=20=E2=86=92=20Appearance,=20off=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/composer/index.tsx | 2 +- .../src/app/chat/composer/text-utils.ts | 5 +- .../src/app/settings/appearance-settings.tsx | 20 +++++++ .../assistant-ui/thread/assistant-message.tsx | 54 ++++++++++--------- .../assistant-ui/thread/user-message.tsx | 5 +- apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 3 ++ apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/store/reactions-enabled.ts | 40 ++++++++++++++ tools/react_to_message_tool.py | 17 +++++- tui_gateway/methods_prompt.py | 9 ++++ 14 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/store/reactions-enabled.ts diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5062e1c293973..c249a7a25a7d6 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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' diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index bd029de28f305..19dfa8ce0d956 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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 } diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 530870d13b255..8149c13c90d0b 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -15,6 +15,7 @@ import { cn } from '@/lib/utils' import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' +import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' import { $zoomPercent, setZoomPercent } from '@/store/zoom' @@ -250,6 +251,7 @@ export function AppearanceSettings() { const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) const translucency = useStore($translucency) + const reactionsEnabled = useStore($reactionsEnabled) const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) @@ -472,6 +474,24 @@ export function AppearanceSettings() { title={a.backdropTitle} /> + { + triggerHaptic('selection') + setReactionsEnabled(id === 'on') + }} + options={[ + { id: 'off', label: t.common.off }, + { id: 'on', label: t.common.on } + ]} + value={reactionsEnabled ? 'on' : 'off'} + /> + } + description={a.reactionsDesc} + title={a.reactionsTitle} + /> + = ({ messageId, getMessageText, }) const [pickerOpen, setPickerOpen] = useState(false) + const reactionsEnabled = useStore($reactionsEnabled) const localAll = useStore($localReactions) const agentLive = useStore($agentReactions) @@ -219,32 +221,34 @@ const AssistantActionBar: FC = ({ 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. */} - reaction.author === 'user')?.emoji} - > - 0 || undefined} - data-slot="aui_msg-reactions" - data-state={pickerOpen ? 'open' : undefined} - onClick={() => setPickerOpen(open => !open)} - tooltip={copy.react} + {(reactionsEnabled || shownReactions.length > 0) && ( + reaction.author === 'user')?.emoji} > - {shownReactions.length > 0 ? ( - - {shownReactions.map(reaction => ( - - {reaction.emoji} - - ))} - - ) : ( - - )} - - + 0 || undefined} + data-slot="aui_msg-reactions" + data-state={pickerOpen ? 'open' : undefined} + onClick={reactionsEnabled ? () => setPickerOpen(open => !open) : undefined} + tooltip={copy.react} + > + {shownReactions.length > 0 ? ( + + {shownReactions.map(reaction => ( + + {reaction.emoji} + + ))} + + ) : ( + + )} + + + )} ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 56d1034903852..00a024aa97494 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -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() diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 9f58344cb8e65..f4727efef1d7d 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -397,6 +397,8 @@ export const ar = defineLocale({ translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل. متاح على macOS وWindows فقط.', backdropTitle: 'خلفية النافذة', backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.', + reactionsTitle: 'تفاعلات الرسائل', + reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.', embedsTitle: 'التضمينات المضمّنة', embedsDesc: 'تُحمّل المعاينات الغنية من مواقع طرف ثالث (YouTube، X، …). "اسأل" يعرض عنصرا نائبا حتى تسمح لكل واحد؛ "دائما" يحمّلها تلقائيا؛ "إيقاف" يبقي الروابط عادية.', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 97578dda8748d..2abf3a82c8800 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 2d067d4496a1a..111113bfcbadb 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -318,6 +318,9 @@ export const ja = defineLocale({ translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', backdropDesc: '会話の背後に表示される淡い彫像の画像。', + reactionsTitle: 'メッセージリアクション', + reactionsDesc: + 'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。', embedsTitle: 'インライン埋め込み', embedsDesc: 'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b43de2e18e04a..d3d993d0d4aa7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -351,6 +351,8 @@ export interface Translations { translucencyDesc: string backdropTitle: string backdropDesc: string + reactionsTitle: string + reactionsDesc: string embedsTitle: string embedsDesc: string embedsAsk: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 6c1cad2d1184b..045f2ac260fad 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -310,6 +310,8 @@ export const zhHant = defineLocale({ translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', backdropDesc: '對話後方那張淡淡的雕像圖片。', + reactionsTitle: '訊息回應', + reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。', embedsTitle: '內嵌預覽', embedsDesc: '豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index af671007cf5c3..51b9f0710cf6c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -433,6 +433,8 @@ export const zh: Translations = { translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', backdropDesc: '对话后方那张淡淡的雕像图片。', + reactionsTitle: '消息回应', + reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。', embedsTitle: '内嵌预览', embedsDesc: '富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。', diff --git a/apps/desktop/src/store/reactions-enabled.ts b/apps/desktop/src/store/reactions-enabled.ts new file mode 100644 index 0000000000000..66870760651eb --- /dev/null +++ b/apps/desktop/src/store/reactions-enabled.ts @@ -0,0 +1,40 @@ +/** + * Message reactions (iMessage-style tapbacks) — opt-in. + * + * Off by default: reactions add affordances to every message row (the ☺ slot, + * right-click pickers, :shortcode: completions), and the agent gains a tool + * that reacts to your messages. Presentation-scoped, so the renderer owns it + * (desktop AGENTS.md: state lives with its authority). + * + * Gates the UI only — persisted reactions still render if the data exists + * (a reaction you set before turning it off shouldn't vanish from history). + */ + +import { atom } from 'nanostores' + +import { persistString, storedString } from '@/lib/storage' +import { activeGateway } from '@/store/gateway' + +const KEY = 'hermes.desktop.reactions.v1' + +export const $reactionsEnabled = atom(typeof window === 'undefined' ? false : storedString(KEY) === 'on') + +export function setReactionsEnabled(enabled: boolean): void { + $reactionsEnabled.set(enabled) +} + +if (typeof window !== 'undefined') { + // listen, not subscribe: fire on CHANGE only, so app startup doesn't write + // config.set (or clobber a profile's setting with another window's default). + $reactionsEnabled.listen(enabled => { + persistString(KEY, enabled ? 'on' : 'off') + // Mirror into gateway config: the backend gates the agent's + // react_to_message tool and the model-context annotation on + // display.message_reactions, so the renderer toggle is the one lever. + void activeGateway() + ?.request('config.set', { key: 'display.message_reactions', value: enabled ? 'true' : 'false' }) + .catch(() => { + // Not connected yet — the next toggle (or default-off) still holds. + }) + }) +} diff --git a/tools/react_to_message_tool.py b/tools/react_to_message_tool.py index b2b37178cce2d..5a6b52e5ef282 100644 --- a/tools/react_to_message_tool.py +++ b/tools/react_to_message_tool.py @@ -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 = { diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 804c7f44da082..763de70ac8e0a 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -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: