feat(web): add composer enter behavior setting (#586)

This commit is contained in:
junes
2026-05-07 08:27:13 +08:00
committed by GitHub
parent 6df84df756
commit 08d3d9e111
9 changed files with 398 additions and 5 deletions
@@ -23,6 +23,7 @@ import { usePWAInstall } from '@/hooks/usePWAInstall'
import { supportsEffort, supportsModelChange } from '@hapi/protocol'
import { markSkillUsed } from '@/lib/recent-skills'
import { useComposerDraft } from '@/hooks/useComposerDraft'
import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
@@ -119,6 +120,7 @@ export function HappyComposer(props: {
const effort = rawEffort ?? null
const api = useAssistantApi()
const { composerEnterBehavior } = useComposerEnterBehavior()
const composerText = useAssistantState(({ composer }) => composer.text)
const attachments = useAssistantState(({ composer }) => composer.attachments)
const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning)
@@ -316,6 +318,14 @@ export function HappyComposer(props: {
// Only plain Enter (no modifiers) sends; other modifier combos are ignored
if (key === 'Enter') {
if (composerEnterBehavior === 'newline') {
if ((e.ctrlKey || e.metaKey) && !e.altKey && canSend) {
e.preventDefault()
api.composer().send()
setShowContinueHint(false)
}
return
}
e.preventDefault()
if (!e.ctrlKey && !e.altKey && !e.metaKey && canSend) {
api.composer().send()
@@ -376,7 +386,8 @@ export function HappyComposer(props: {
permissionModes,
canSend,
api,
haptic
haptic,
composerEnterBehavior
])
useEffect(() => {
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_COMPOSER_ENTER_BEHAVIOR,
getComposerEnterBehaviorOptions,
getInitialComposerEnterBehavior,
} from './useComposerEnterBehavior'
describe('useComposerEnterBehavior helpers', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('returns the allowed enter behavior options', () => {
expect(getComposerEnterBehaviorOptions()).toEqual([
{ value: 'send', labelKey: 'settings.chat.enterBehavior.send' },
{ value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' },
])
})
it('falls back to the default behavior for missing or invalid storage values', () => {
expect(getInitialComposerEnterBehavior()).toBe(DEFAULT_COMPOSER_ENTER_BEHAVIOR)
window.localStorage.setItem('hapi-composer-enter-behavior', 'invalid')
expect(getInitialComposerEnterBehavior()).toBe(DEFAULT_COMPOSER_ENTER_BEHAVIOR)
})
it('reads a valid stored enter behavior', () => {
window.localStorage.setItem('hapi-composer-enter-behavior', 'newline')
expect(getInitialComposerEnterBehavior()).toBe('newline')
})
})
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
export type ComposerEnterBehavior = 'send' | 'newline'
export const DEFAULT_COMPOSER_ENTER_BEHAVIOR: ComposerEnterBehavior = 'send'
export function getComposerEnterBehaviorOptions(): ReadonlyArray<{ value: ComposerEnterBehavior; labelKey: string }> {
return [
{ value: 'send', labelKey: 'settings.chat.enterBehavior.send' },
{ value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' },
]
}
function getComposerEnterBehaviorStorageKey(): string {
return 'hapi-composer-enter-behavior'
}
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
function parseComposerEnterBehavior(raw: string | null): ComposerEnterBehavior {
if (raw === 'send' || raw === 'newline') {
return raw
}
return DEFAULT_COMPOSER_ENTER_BEHAVIOR
}
export function getInitialComposerEnterBehavior(): ComposerEnterBehavior {
return parseComposerEnterBehavior(safeGetItem(getComposerEnterBehaviorStorageKey()))
}
export function useComposerEnterBehavior(): {
composerEnterBehavior: ComposerEnterBehavior
setComposerEnterBehavior: (behavior: ComposerEnterBehavior) => void
} {
const [composerEnterBehavior, setComposerEnterBehaviorState] = useState<ComposerEnterBehavior>(getInitialComposerEnterBehavior)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getComposerEnterBehaviorStorageKey()) {
return
}
setComposerEnterBehaviorState(parseComposerEnterBehavior(event.newValue))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setComposerEnterBehavior = useCallback((behavior: ComposerEnterBehavior) => {
setComposerEnterBehaviorState(behavior)
if (behavior === DEFAULT_COMPOSER_ENTER_BEHAVIOR) {
safeRemoveItem(getComposerEnterBehaviorStorageKey())
} else {
safeSetItem(getComposerEnterBehaviorStorageKey(), behavior)
}
}, [])
return { composerEnterBehavior, setComposerEnterBehavior }
}
+4
View File
@@ -302,6 +302,10 @@ export default {
'settings.display.appearance.light': 'Light',
'settings.display.fontSize': 'Font Size',
'settings.display.terminalFontSize': 'Terminal Font Size',
'settings.chat.title': 'Chat',
'settings.chat.enterBehavior': 'Enter Key',
'settings.chat.enterBehavior.send': 'Send message',
'settings.chat.enterBehavior.newline': 'Insert newline',
'settings.voice.title': 'Voice Assistant',
'settings.voice.language': 'Voice Language',
'settings.voice.autoDetect': 'Auto-detect',
+4
View File
@@ -304,6 +304,10 @@ export default {
'settings.display.appearance.light': '浅色',
'settings.display.fontSize': '字体大小',
'settings.display.terminalFontSize': '终端字体大小',
'settings.chat.title': '聊天',
'settings.chat.enterBehavior': '回车键行为',
'settings.chat.enterBehavior.send': '发送消息',
'settings.chat.enterBehavior.newline': '插入换行',
'settings.voice.title': '语音助手',
'settings.voice.language': '语音语言',
'settings.voice.autoDetect': '自动检测',
+22
View File
@@ -35,6 +35,14 @@ vi.mock('@/hooks/useTerminalFontSize', () => ({
],
}))
vi.mock('@/hooks/useComposerEnterBehavior', () => ({
useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior: vi.fn() }),
getComposerEnterBehaviorOptions: () => [
{ value: 'send', labelKey: 'settings.chat.enterBehavior.send' },
{ value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' },
],
}))
// Mock useTheme hook
vi.mock('@/hooks/useTheme', () => ({
useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }),
@@ -143,4 +151,18 @@ describe('SettingsPage', () => {
expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1)
})
it('renders the Enter Key setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Enter Key').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Send message').length).toBeGreaterThanOrEqual(1)
})
it('uses correct i18n keys for the Enter Key setting', () => {
const spyT = renderWithSpyT(<SettingsPage />)
const calledKeys = spyT.mock.calls.map((call) => call[0])
expect(calledKeys).toContain('settings.chat.title')
expect(calledKeys).toContain('settings.chat.enterBehavior')
expect(calledKeys).toContain('settings.chat.enterBehavior.send')
})
})
+74 -4
View File
@@ -4,6 +4,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack'
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
import { PROTOCOL_VERSION } from '@hapi/protocol'
@@ -78,14 +79,17 @@ export default function SettingsPage() {
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
const [isFontOpen, setIsFontOpen] = useState(false)
const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false)
const [isChatOpen, setIsChatOpen] = useState(false)
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const appearanceContainerRef = useRef<HTMLDivElement>(null)
const fontContainerRef = useRef<HTMLDivElement>(null)
const terminalFontContainerRef = useRef<HTMLDivElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior()
const { appearance, setAppearance } = useAppearance()
// Voice language state - read from localStorage
@@ -95,11 +99,13 @@ export default function SettingsPage() {
const fontScaleOptions = getFontScaleOptions()
const terminalFontSizeOptions = getTerminalFontSizeOptions()
const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions()
const appearanceOptions = getAppearanceOptions()
const currentLocale = locales.find((loc) => loc.value === locale)
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%'
const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px'
const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send'
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
const handleLocaleChange = (newLocale: Locale) => {
@@ -122,6 +128,11 @@ export default function SettingsPage() {
setIsTerminalFontOpen(false)
}
const handleComposerEnterBehaviorChange = (newBehavior: ComposerEnterBehavior) => {
setComposerEnterBehavior(newBehavior)
setIsChatOpen(false)
}
const handleVoiceLanguageChange = (language: Language) => {
setVoiceLanguage(language.code)
if (language.code === null) {
@@ -134,7 +145,7 @@ export default function SettingsPage() {
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isVoiceOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -149,6 +160,9 @@ export default function SettingsPage() {
if (isTerminalFontOpen && terminalFontContainerRef.current && !terminalFontContainerRef.current.contains(event.target as Node)) {
setIsTerminalFontOpen(false)
}
if (isChatOpen && chatContainerRef.current && !chatContainerRef.current.contains(event.target as Node)) {
setIsChatOpen(false)
}
if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) {
setIsVoiceOpen(false)
}
@@ -156,11 +170,11 @@ export default function SettingsPage() {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isVoiceOpen])
// Close on escape key
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isVoiceOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -168,13 +182,14 @@ export default function SettingsPage() {
setIsAppearanceOpen(false)
setIsFontOpen(false)
setIsTerminalFontOpen(false)
setIsChatOpen(false)
setIsVoiceOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isVoiceOpen])
return (
<div className="flex h-full min-h-0 flex-col">
@@ -399,6 +414,61 @@ export default function SettingsPage() {
</div>
</div>
{/* Chat section */}
<div className="border-b border-[var(--app-divider)]">
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
{t('settings.chat.title')}
</div>
<div ref={chatContainerRef} className="relative">
<button
type="button"
onClick={() => setIsChatOpen(!isChatOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isChatOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.chat.enterBehavior')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{t(currentComposerEnterBehaviorLabel)}</span>
<ChevronDownIcon className={`transition-transform ${isChatOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isChatOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[170px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
role="listbox"
aria-label={t('settings.chat.enterBehavior')}
>
{composerEnterBehaviorOptions.map((opt) => {
const isSelected = composerEnterBehavior === opt.value
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleComposerEnterBehaviorChange(opt.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{t(opt.labelKey)}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
{/* Voice Assistant section */}
<div className="border-b border-[var(--app-divider)]">
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">