fix(desktop): prevent accidental composer popout

This commit is contained in:
Lester Liang
2026-07-24 13:21:25 +10:00
committed by Teknium
parent 354abd7c57
commit 49fbca1880
13 changed files with 250 additions and 6 deletions
@@ -0,0 +1,45 @@
import { cleanup, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerPopoutGesturesEnabled, $composerPopoutZones } from '@/store/composer-popout'
import { useComposerPopout } from './use-composer-popout'
vi.mock('@/components/pane-shell/pane-visibility', () => ({
usePaneGroup: () => 'test-zone',
usePaneVisible: () => true
}))
vi.mock('@/hooks/use-resize-observer', () => ({ useResizeObserver: () => undefined }))
vi.mock('@/store/windows', () => ({ isSecondaryWindow: () => false }))
function PopoutAffordanceHarness() {
const composerRef = useRef<HTMLFormElement>(null)
const { popoutAllowed } = useComposerPopout({ composerRef })
return (
<form ref={composerRef}>{popoutAllowed && <div data-slot="composer-drag-region" data-testid="drag-region" />}</form>
)
}
describe('useComposerPopout', () => {
beforeEach(() => {
$composerPopoutZones.set({})
$composerPopoutGesturesEnabled.set(true)
})
afterEach(() => {
cleanup()
$composerPopoutZones.set({})
$composerPopoutGesturesEnabled.set(true)
})
it('removes the pop-out affordance when gestures are disabled', () => {
$composerPopoutGesturesEnabled.set(false)
render(<PopoutAffordanceHarness />)
expect(screen.queryByTestId('drag-region')).toBeNull()
})
})
@@ -5,6 +5,7 @@ import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visib
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { triggerHaptic } from '@/lib/haptics'
import {
$composerPopoutGesturesEnabled,
$composerPopoutZone,
clampPopoutPosition,
getComposerPopoutZone,
@@ -121,8 +122,9 @@ function usePopoutPlacement(
* docked: a floating composer makes no sense in a scratch window.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
const popoutAllowed = !isSecondaryWindow()
const groupId = usePaneGroup()
const gesturesEnabled = useStore($composerPopoutGesturesEnabled)
const popoutAllowed = gesturesEnabled && !isSecondaryWindow()
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
const poppedOut = zone.poppedOut && popoutAllowed
@@ -0,0 +1,66 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useComposerPopoutGestures } from './use-popout-drag'
function GestureHarness({ onPopOut }: { onPopOut: () => void }) {
const composerRef = useRef<HTMLFormElement>(null)
const { onPointerDown } = useComposerPopoutGestures({
composerRef,
groupId: 'test-zone',
onDock: vi.fn(),
onPopOut,
poppedOut: false,
position: { bottom: 24, right: 24 }
})
return (
<form data-testid="composer" onPointerDown={onPointerDown} ref={composerRef}>
<div data-slot="composer-drag-region" data-testid="drag-region" />
<div data-slot="composer-surface" data-testid="surface">
<div contentEditable data-slot="composer-rich-input">
<span data-testid="editable-text">select me</span>
</div>
</div>
</form>
)
}
function dragUp(target: Element) {
fireEvent.pointerDown(target, { button: 0, clientX: 100, clientY: 100, pointerId: 7 })
fireEvent.pointerMove(window, { clientX: 100, clientY: 60, pointerId: 7 })
fireEvent.pointerUp(window, { clientX: 100, clientY: 60, pointerId: 7 })
}
afterEach(cleanup)
describe('useComposerPopoutGestures', () => {
it('never peels the composer out when a text-selection drag starts in the rich editor', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('editable-text'))
expect(onPopOut).not.toHaveBeenCalled()
})
it('does not arm a dock peel from the composer surface', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('surface'))
expect(onPopOut).not.toHaveBeenCalled()
})
it('still peels out from the dedicated drag region', () => {
const onPopOut = vi.fn()
render(<GestureHarness onPopOut={onPopOut} />)
dragUp(screen.getByTestId('drag-region'))
expect(onPopOut).toHaveBeenCalledOnce()
})
})
@@ -49,7 +49,16 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
return !target.closest(
'button, a, input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-slot="composer-rich-input"], [role="menuitem"], [data-radix-popper-content-wrapper]'
)
}
/** Docked composer only peels from its exposed grab ring. Pointer events from
* the editor and composer surface bubble through the root too, but they belong
* to text selection and controls, never to the pop-out gesture. */
function isDockDragPlatform(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('[data-slot="composer-drag-region"]'))
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */
@@ -217,6 +226,10 @@ export function useComposerPopoutGestures({
return
}
if (!poppedOut && !isDockDragPlatform(event.target)) {
return
}
stateRef.current = {
armed: false,
mode: poppedOut ? 'float' : 'dock',
@@ -13,6 +13,7 @@ import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from '@/store/composer-popout'
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'
@@ -28,7 +29,7 @@ import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/use
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives'
import { TerminalFontSetting } from './terminal-font-setting'
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
@@ -255,6 +256,7 @@ export function AppearanceSettings() {
const zoomPercent = useStore($zoomPercent)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const composerPopoutGesturesEnabled = useStore($composerPopoutGesturesEnabled)
const translucency = useStore($translucency)
const reactionsEnabled = useStore($reactionsEnabled)
const backdrop = useStore($backdrop)
@@ -502,6 +504,13 @@ export function AppearanceSettings() {
title={a.backdropTitle}
/>
<ToggleRow
checked={composerPopoutGesturesEnabled}
description={a.composerPopoutDesc}
label={a.composerPopoutTitle}
onChange={setComposerPopoutGesturesEnabled}
/>
<ListRow
action={
<SegmentedControl
+2
View File
@@ -405,6 +405,8 @@ export const ar = defineLocale({
backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.',
reactionsTitle: 'تفاعلات الرسائل',
reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.',
composerPopoutTitle: 'محرر عائم',
composerPopoutDesc: 'السماح بسحب محرر الرسائل خارج موضعه. عطّل هذا الخيار لإبقائه مثبتًا في الأسفل.',
embedsTitle: 'التضمينات المضمّنة',
embedsDesc:
'تُحمّل المعاينات الغنية من مواقع طرف ثالث (YouTube، X، …). "اسأل" يعرض عنصرا نائبا حتى تسمح لكل واحد؛ "دائما" يحمّلها تلقائيا؛ "إيقاف" يبقي الروابط عادية.',
+2
View File
@@ -480,6 +480,8 @@ export const en: Translations = {
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.',
composerPopoutTitle: 'Floating Composer',
composerPopoutDesc: 'Allow dragging the composer out of its dock. Turn this off to keep it locked at the bottom.',
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.',
+2
View File
@@ -340,6 +340,8 @@ export const ja = defineLocale({
reactionsTitle: 'メッセージリアクション',
reactionsDesc:
'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。',
composerPopoutTitle: 'フローティング入力欄',
composerPopoutDesc: '入力欄をドックからドラッグして外せるようにします。オフにすると画面下部に固定されます。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
+2
View File
@@ -381,6 +381,8 @@ export interface Translations {
backdropDesc: string
reactionsTitle: string
reactionsDesc: string
composerPopoutTitle: string
composerPopoutDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string
+2
View File
@@ -331,6 +331,8 @@ export const zhHant = defineLocale({
backdropDesc: '對話後方那張淡淡的雕像圖片。',
reactionsTitle: '訊息回應',
reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。',
composerPopoutTitle: '懸浮輸入框',
composerPopoutDesc: '允許將輸入框拖出底部停靠區。關閉後,輸入框會鎖定在底部。',
embedsTitle: '內嵌預覽',
embedsDesc:
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
+2
View File
@@ -470,6 +470,8 @@ export const zh: Translations = {
backdropDesc: '对话后方那张淡淡的雕像图片。',
reactionsTitle: '消息回应',
reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。',
composerPopoutTitle: '悬浮输入框',
composerPopoutDesc: '允许将输入框拖出底部停靠区。关闭后,输入框会锁定在底部。',
embedsTitle: '内嵌预览',
embedsDesc:
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const GESTURES_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const ZONES_KEY = 'hermes.desktop.composerPopout.zones.v1'
const loadStore = () => import('./composer-popout')
describe('composer pop-out preference', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('docks every floating zone, preserves positions, and persists the lock', async () => {
const first = await loadStore()
first.setComposerPopoutPosition('left', { bottom: 100, right: 100 })
first.setComposerPoppedOut('left', true)
first.setComposerPopoutPosition('right', { bottom: 200, right: 200 })
first.setComposerPoppedOut('right', true)
first.setComposerPopoutGesturesEnabled(false)
expect(first.$composerPopoutGesturesEnabled.get()).toBe(false)
expect(first.getComposerPopoutZone('left')).toEqual({
poppedOut: false,
position: { bottom: 100, right: 100 }
})
expect(first.getComposerPopoutZone('right')).toEqual({
poppedOut: false,
position: { bottom: 200, right: 200 }
})
expect(window.localStorage.getItem(GESTURES_KEY)).toBe('false')
expect(window.localStorage.getItem(LEGACY_ENABLED_KEY)).toBe('false')
vi.resetModules()
const reloaded = await loadStore()
expect(reloaded.$composerPopoutGesturesEnabled.get()).toBe(false)
expect(reloaded.getComposerPopoutZone('left').poppedOut).toBe(false)
expect(reloaded.getComposerPopoutZone('right').poppedOut).toBe(false)
})
it('normalizes stale floating zones when the persisted preference is disabled', async () => {
window.localStorage.setItem(GESTURES_KEY, 'false')
window.localStorage.setItem(
ZONES_KEY,
JSON.stringify({ stale: { poppedOut: true, position: { bottom: 48, right: 64 } } })
)
const store = await loadStore()
expect(store.getComposerPopoutZone('stale')).toEqual({
poppedOut: false,
position: { bottom: 48, right: 64 }
})
})
it('keeps pop-out gestures enabled by default', async () => {
const store = await loadStore()
expect(store.$composerPopoutGesturesEnabled.get()).toBe(true)
})
})
+35 -3
View File
@@ -1,14 +1,17 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
const POPOUT_STORAGE_KEY = 'hermes.desktop.composerPopout.zones.v1'
const POPOUT_GESTURES_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
// Pre-zone keys: one flag + one position for the whole window. Read at load to
// seed the first zone the user touches (see `legacySeed`), never written again.
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const LEGACY_POSITION_KEY = 'hermes.desktop.composerPopout.position'
const gesturesEnabledAtLoad = storedBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, true)
/** Where the floating composer's bottom-right corner sits, measured as an inset
* from the viewport's bottom/right edges. Anchoring to the bottom-right keeps
* the box visually pinned to its default corner as the window resizes and as
@@ -63,7 +66,7 @@ function load(): Record<string, PopoutZoneState> {
const zone = value as null | Partial<PopoutZoneState>
if (typeof zone?.poppedOut === 'boolean' && isPosition(zone.position)) {
out[id] = { poppedOut: zone.poppedOut, position: { ...zone.position } }
out[id] = { poppedOut: gesturesEnabledAtLoad && zone.poppedOut, position: { ...zone.position } }
}
}
} catch {
@@ -80,6 +83,7 @@ function load(): Record<string, PopoutZoneState> {
* put it), while a split zone beside them keeps its own — popping out on the
* left doesn't fling a composer out of the right. */
export const $composerPopoutZones = atom<Record<string, PopoutZoneState>>(load())
export const $composerPopoutGesturesEnabled = atom(gesturesEnabledAtLoad)
/** Write-through to storage. Called explicitly — NOT on every store change: a
* drag updates the position once per frame, and serializing every zone to
@@ -91,7 +95,9 @@ const persistZones = () => persistString(POPOUT_STORAGE_KEY, JSON.stringify($com
* — but ONLY until they touch any zone. Once real per-zone state exists, that
* is the truth, and a zone split later starts docked like any other. */
let legacySeed: PopoutZoneState | null =
storedString(LEGACY_ENABLED_KEY) === 'true' ? { poppedOut: true, position: legacyPosition() } : null
gesturesEnabledAtLoad && storedString(LEGACY_ENABLED_KEY) === 'true'
? { poppedOut: true, position: legacyPosition() }
: null
const zoneState = (zones: Record<string, PopoutZoneState>, groupId: string): PopoutZoneState =>
zones[groupId] ?? legacySeed ?? DEFAULT_ZONE
@@ -223,6 +229,32 @@ export function setComposerPoppedOut(groupId: string, value: boolean) {
persistZones()
}
export function setComposerPopoutGesturesEnabled(value: boolean) {
$composerPopoutGesturesEnabled.set(value)
persistBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, value)
if (value) {
return
}
// Turning the feature off is an immediate lock-to-dock action for every
// layout zone, not merely a promise to ignore the next gesture. Keep each
// zone's resting position so opting back in restores where the user put it.
const zones = $composerPopoutZones.get()
const docked = Object.fromEntries(
Object.entries(zones).map(([groupId, zone]) => [groupId, { ...zone, poppedOut: false }])
)
legacySeed = null
$composerPopoutZones.set(docked)
persistZones()
// Neutralize the pre-zone singleton migration seed too, otherwise a reload
// with no materialized zones could revive an old floating composer.
persistBoolean(LEGACY_ENABLED_KEY, false)
}
/** Move this zone's box. Used per-frame during a drag, so it only writes the
* in-memory store by default; pass `persist` for the resting position on
* release. Returns the clamped position so callers can sync their live ref. */