mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): add composer enter behavior setting (#586)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Web Enter Behavior Setting Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a persisted Web setting that switches composer Enter behavior between send and newline modes.
|
||||
|
||||
**Architecture:** Store the preference in a dedicated Web hook backed by localStorage, surface it through the existing settings dropdown UI, and branch the composer Enter handling based on the selected mode while preserving IME and autocomplete behavior.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, localStorage-backed hooks, project i18n dictionaries.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- New: `web/src/hooks/useComposerEnterBehavior.ts` — composer enter behavior type, options, storage helpers, React hook
|
||||
- New: `web/src/hooks/useComposerEnterBehavior.test.ts` — helper-level regression tests
|
||||
- Modify: `web/src/routes/settings/index.tsx` — add settings dropdown and wire hook
|
||||
- Modify: `web/src/routes/settings/index.test.tsx` — assert new setting renders and uses i18n key
|
||||
- Modify: `web/src/components/AssistantChat/HappyComposer.tsx` — apply behavior to Enter handling
|
||||
- Modify: `web/src/lib/locales/en.ts` — English labels
|
||||
- Modify: `web/src/lib/locales/zh-CN.ts` — Chinese labels
|
||||
|
||||
### Task 1: Preference hook
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useComposerEnterBehavior.ts`
|
||||
- Test: `web/src/hooks/useComposerEnterBehavior.test.ts`
|
||||
|
||||
- [ ] Define `ComposerEnterBehavior = 'send' | 'newline'`
|
||||
- [ ] Add helper `getComposerEnterBehaviorOptions()` returning the two values with translation keys
|
||||
- [ ] Add localStorage-backed parser with default fallback to `send`
|
||||
- [ ] Add `useComposerEnterBehavior()` hook consistent with existing hook patterns
|
||||
- [ ] Add helper tests for options, default fallback, invalid fallback, valid stored value
|
||||
|
||||
### Task 2: Settings integration + i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/routes/settings/index.tsx`
|
||||
- Modify: `web/src/routes/settings/index.test.tsx`
|
||||
- Modify: `web/src/lib/locales/en.ts`
|
||||
- Modify: `web/src/lib/locales/zh-CN.ts`
|
||||
|
||||
- [ ] Add i18n keys for chat section, enter key label, send option, newline option
|
||||
- [ ] Add dropdown open/close state and refs in settings page
|
||||
- [ ] Render the new Chat section using the existing settings dropdown UI pattern
|
||||
- [ ] Show translated current label from selected behavior option
|
||||
- [ ] Extend settings page tests to assert the new setting renders and translation key is used
|
||||
|
||||
### Task 3: Composer behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/AssistantChat/HappyComposer.tsx`
|
||||
|
||||
- [ ] Read composer enter behavior from the new hook
|
||||
- [ ] Keep IME guard unchanged
|
||||
- [ ] Keep suggestion selection precedence on Enter unchanged
|
||||
- [ ] In `send` mode keep plain Enter send behavior
|
||||
- [ ] In `newline` mode allow plain Enter default newline behavior, but send on Ctrl/Cmd+Enter
|
||||
- [ ] Keep Shift+Enter newline behavior in both modes
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
**Files:**
|
||||
- No source change required unless verification exposes defects
|
||||
|
||||
- [ ] Run: `cd web && bun run test -- src/hooks/useComposerEnterBehavior.test.ts src/routes/settings/index.test.tsx`
|
||||
- [ ] Run: `cd web && bun run typecheck`
|
||||
- [ ] Review diff to confirm scope is limited to Web setting + i18n + composer logic
|
||||
@@ -0,0 +1,83 @@
|
||||
# Web Enter Behavior Setting Design
|
||||
|
||||
**Date:** 2026-05-06
|
||||
**Issue:** [#570](https://github.com/tiann/hapi/issues/570)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a Web setting that controls what Enter does in the chat composer. Default stays **send**. Users can switch to **newline** mode. In newline mode, plain Enter inserts a newline and Ctrl/Cmd+Enter sends.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a persisted Web preference for composer enter behavior
|
||||
- Expose the preference in the Web settings page
|
||||
- Apply the preference in `HappyComposer`
|
||||
- Add i18n strings in English and Simplified Chinese
|
||||
- Add focused regression tests for preference helpers and settings rendering
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No per-session override
|
||||
- No mobile-only special case
|
||||
- No backend / shared protocol change
|
||||
|
||||
## Behavior
|
||||
|
||||
### Mode: `send` (default)
|
||||
- Enter: send
|
||||
- Shift+Enter: newline
|
||||
- Ctrl/Cmd+Enter: keep current non-send behavior; plain Enter remains the only send shortcut
|
||||
|
||||
### Mode: `newline`
|
||||
- Enter: newline
|
||||
- Ctrl/Cmd+Enter: send
|
||||
- Shift+Enter: newline
|
||||
|
||||
### Shared rules
|
||||
- IME composition must remain untouched
|
||||
- When autocomplete suggestions are open, Enter should still select the highlighted suggestion first
|
||||
- Existing send button remains available in all modes
|
||||
|
||||
## Storage
|
||||
|
||||
Use localStorage key:
|
||||
|
||||
- `hapi-composer-enter-behavior`
|
||||
|
||||
Allowed values:
|
||||
|
||||
- `send`
|
||||
- `newline`
|
||||
|
||||
Fallback:
|
||||
|
||||
- invalid / missing value => `send`
|
||||
|
||||
## UI
|
||||
|
||||
Add a new settings section item in Web settings:
|
||||
|
||||
- Section: Chat
|
||||
- Label: Enter Key
|
||||
- Options:
|
||||
- Send message
|
||||
- Insert newline
|
||||
|
||||
This should follow the existing dropdown interaction pattern already used in settings.
|
||||
|
||||
## Files
|
||||
|
||||
- New: `web/src/hooks/useComposerEnterBehavior.ts`
|
||||
- New: `web/src/hooks/useComposerEnterBehavior.test.ts`
|
||||
- Modify: `web/src/routes/settings/index.tsx`
|
||||
- Modify: `web/src/routes/settings/index.test.tsx`
|
||||
- Modify: `web/src/components/AssistantChat/HappyComposer.tsx`
|
||||
- Modify: `web/src/lib/locales/en.ts`
|
||||
- Modify: `web/src/lib/locales/zh-CN.ts`
|
||||
|
||||
## Testing
|
||||
|
||||
- Verify helper options + storage fallback logic
|
||||
- Verify settings page shows the new translated setting and selected label
|
||||
- Run targeted web tests
|
||||
- Run `web` typecheck
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '自动检测',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user