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:
parent
1af8839139
commit
fec1ac0a7a
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -397,6 +397,8 @@ export const ar = defineLocale({
|
|||
translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل. متاح على macOS وWindows فقط.',
|
||||
backdropTitle: 'خلفية النافذة',
|
||||
backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.',
|
||||
reactionsTitle: 'تفاعلات الرسائل',
|
||||
reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.',
|
||||
embedsTitle: 'التضمينات المضمّنة',
|
||||
embedsDesc:
|
||||
'تُحمّل المعاينات الغنية من مواقع طرف ثالث (YouTube، X، …). "اسأل" يعرض عنصرا نائبا حتى تسمح لكل واحد؛ "دائما" يحمّلها تلقائيا؛ "إيقاف" يبقي الروابط عادية.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -318,6 +318,9 @@ export const ja = defineLocale({
|
|||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
backdropTitle: 'チャット背景',
|
||||
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
|
||||
reactionsTitle: 'メッセージリアクション',
|
||||
reactionsDesc:
|
||||
'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。',
|
||||
embedsTitle: 'インライン埋め込み',
|
||||
embedsDesc:
|
||||
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
|
||||
|
|
|
|||
|
|
@ -351,6 +351,8 @@ export interface Translations {
|
|||
translucencyDesc: string
|
||||
backdropTitle: string
|
||||
backdropDesc: string
|
||||
reactionsTitle: string
|
||||
reactionsDesc: string
|
||||
embedsTitle: string
|
||||
embedsDesc: string
|
||||
embedsAsk: string
|
||||
|
|
|
|||
|
|
@ -310,6 +310,8 @@ export const zhHant = defineLocale({
|
|||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '對話後方那張淡淡的雕像圖片。',
|
||||
reactionsTitle: '訊息回應',
|
||||
reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
embedsDesc:
|
||||
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
|
|
|
|||
|
|
@ -433,6 +433,8 @@ export const zh: Translations = {
|
|||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '对话后方那张淡淡的雕像图片。',
|
||||
reactionsTitle: '消息回应',
|
||||
reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。',
|
||||
embedsTitle: '内嵌预览',
|
||||
embedsDesc:
|
||||
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue