mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user