fix(web): close composer settings on outside click (#1354)

* fix(web): close composer settings on outside click

Co-Authored-By: Codex <noreply@anthropic.com>

* fix(web): capture composer outside clicks

Co-Authored-By: Codex <noreply@anthropic.com>

---------

Co-authored-by: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-08 13:53:48 +08:00
committed by GitHub
co-authored by Codex
parent bdf8da1d5a
commit 17d1ea0c75
3 changed files with 86 additions and 10 deletions
@@ -6,7 +6,7 @@ import { ScheduleTimePicker } from './ScheduleTimePicker'
import type { PendingSchedule } from './ScheduleTimePicker'
import { useFue } from '@/lib/use-fue'
import { FueCallout, FueDot } from '@/components/Fue'
import { Children, isValidElement, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { Children, isValidElement, useRef, useState, type ReactElement, type ReactNode, type Ref } from 'react'
import { useComposerToolbarLayout, type ComposerToolbarItemId, type ComposerToolbarLayout } from '@/hooks/useComposerToolbarLayout'
import { useLongPress } from '@/hooks/useLongPress'
import type { ComposerSendIntent } from '@/lib/messageDelivery'
@@ -623,6 +623,7 @@ export function ComposerButtons(props: {
canSend: boolean
controlsDisabled: boolean
showSettingsButton: boolean
settingsButtonRef?: Ref<HTMLButtonElement>
onSettingsToggle: () => void
expanded: boolean
onExpandedToggle: () => void
@@ -704,6 +705,7 @@ export function ComposerButtons(props: {
<ToolbarItemSlot item="settings">
{props.showSettingsButton ? (
<button
ref={props.settingsButtonRef}
type="button"
aria-label={t('composer.settings')}
title={t('composer.settings')}
@@ -4,16 +4,33 @@ import {
useLocalRuntime,
} from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { Ref } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HappyComposer } from './HappyComposer'
vi.mock('@/components/AssistantChat/ComposerButtons', () => ({
ComposerButtons: (props: { expanded: boolean; onExpandedToggle: () => void }) => (
<button
type="button"
aria-label={props.expanded ? 'Collapse message editor' : 'Expand message editor'}
onClick={props.onExpandedToggle}
/>
ComposerButtons: (props: {
expanded: boolean
onExpandedToggle: () => void
showSettingsButton: boolean
settingsButtonRef?: Ref<HTMLButtonElement>
onSettingsToggle: () => void
}) => (
<div>
<button
type="button"
aria-label={props.expanded ? 'Collapse message editor' : 'Expand message editor'}
onClick={props.onExpandedToggle}
/>
{props.showSettingsButton ? (
<button
ref={props.settingsButtonRef}
type="button"
aria-label="Composer settings"
onClick={props.onSettingsToggle}
/>
) : null}
</div>
),
}))
@@ -53,11 +70,21 @@ const adapter: ChatModelAdapter = {
async *run() {},
}
function TestRuntime() {
function TestRuntime(props: { withSettings?: boolean }) {
const runtime = useLocalRuntime(adapter)
return (
<AssistantRuntimeProvider runtime={runtime}>
<HappyComposer />
<HappyComposer
agentFlavor={props.withSettings ? 'claude' : undefined}
onPermissionModeChange={props.withSettings ? () => {} : undefined}
/>
{props.withSettings ? (
<button
type="button"
aria-label="Outside action"
onPointerDown={(event) => event.stopPropagation()}
/>
) : null}
</AssistantRuntimeProvider>
)
}
@@ -104,4 +131,33 @@ describe('HappyComposer plain-text expansion', () => {
expect(document.activeElement).toBe(nextCollapsedInput)
})
})
it('closes settings when clicking outside while preserving inside and trigger interactions', () => {
render(<TestRuntime withSettings />)
const settingsButton = screen.getByRole('button', { name: 'Composer settings' })
fireEvent.click(settingsButton)
const panelLabel = screen.getByText('misc.permissionMode')
fireEvent.pointerDown(panelLabel)
expect(screen.getByText('misc.permissionMode')).toBeInTheDocument()
fireEvent.pointerDown(settingsButton)
expect(screen.getByText('misc.permissionMode')).toBeInTheDocument()
fireEvent.click(settingsButton)
expect(screen.queryByText('misc.permissionMode')).not.toBeInTheDocument()
fireEvent.click(settingsButton)
expect(screen.getByText('misc.permissionMode')).toBeInTheDocument()
fireEvent.pointerDown(screen.getByRole('textbox'))
expect(screen.queryByText('misc.permissionMode')).not.toBeInTheDocument()
fireEvent.click(settingsButton)
expect(screen.getByText('misc.permissionMode')).toBeInTheDocument()
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside action' }))
expect(screen.queryByText('misc.permissionMode')).not.toBeInTheDocument()
})
})
@@ -565,6 +565,8 @@ export function HappyComposer(props: {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richInputRef = useRef<RichComposerInputHandle>(null)
const richComposerFueAnchorRef = useRef<HTMLDivElement>(null)
const settingsButtonRef = useRef<HTMLButtonElement>(null)
const settingsOverlayRef = useRef<HTMLDivElement>(null)
// `composer.text === ''` alone is not enough to identify the empty state
// created by a send. A user can type and delete a fresh draft before the
// failed mutation reports back. Keep monotonic interaction generations so
@@ -1483,6 +1485,21 @@ export function HappyComposer(props: {
haptic('light')
}, [onModelEffortChange, onModelChange, controlsDisabled, haptic, dismissSettings])
useEffect(() => {
if (!showSettings) return
const handlePointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (settingsOverlayRef.current?.contains(target)) return
if (settingsButtonRef.current?.contains(target)) return
dismissSettings()
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [dismissSettings, showSettings])
const handleSubmit = useCallback((event?: ReactFormEvent<HTMLFormElement>) => {
event?.preventDefault()
if (!attachmentsReady) {
@@ -1660,7 +1677,7 @@ export function HappyComposer(props: {
// Non-Pi flavors: original unified gear menu
if (showSettings && (showCollaborationSettings || showCopilotAgentModeSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) {
return (
<div className={`${overlayPositionClass} w-full`}>
<div ref={settingsOverlayRef} className={`${overlayPositionClass} w-full`}>
<FloatingOverlay maxHeight={320}>
{showCollaborationSettings ? (
<div className="py-2">
@@ -2274,6 +2291,7 @@ export function HappyComposer(props: {
canSend={canSend}
controlsDisabled={controlsDisabled}
showSettingsButton={showSettingsButton}
settingsButtonRef={settingsButtonRef}
onSettingsToggle={handleSettingsToggle}
expanded={isExpanded}
onExpandedToggle={handleExpandedToggle}