feat(web): redesign responsive settings navigation (#1027)

This commit is contained in:
SSU-WEI HUANG
2026-07-13 08:42:25 +08:00
committed by GitHub
parent b9eed7c071
commit f6ad345339
24 changed files with 1218 additions and 1730 deletions
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
const fixture = '/e2e-fixtures/settings-fixture.html'
test.describe('settings responsive layout', () => {
test('mobile drills from the category hub into a full-width detail page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(fixture)
await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible()
await expect(page.getByRole('button', { name: /Display/ })).toBeVisible()
await expect(page.locator('aside')).toBeHidden()
await page.getByRole('button', { name: /Display/ }).click()
await expect(page.locator('header').getByRole('heading', { name: 'Display' })).toBeVisible()
await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible()
await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden()
await expect(page.getByRole('listbox')).toHaveCount(0)
})
test('desktop keeps navigation visible and renders Display as the default detail', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto(fixture)
const desktopNav = page.locator('aside nav')
await expect(desktopNav).toBeVisible()
await expect(desktopNav.getByRole('button', { name: 'Display' })).toHaveAttribute('aria-current', 'page')
await expect(page.getByRole('heading', { name: 'Display' })).toBeVisible()
await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible()
await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden()
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible()
await expect(page.locator('aside')).toBeHidden()
})
})
+8 -1
View File
@@ -30,7 +30,14 @@ See `src/router.tsx` for route definitions.
- `/sessions/$sessionId/files` - File browser with git status. - `/sessions/$sessionId/files` - File browser with git status.
- `/sessions/$sessionId/file` - File viewer with diff support. - `/sessions/$sessionId/file` - File viewer with diff support.
- `/sessions/$sessionId/terminal` - Terminal interface. - `/sessions/$sessionId/terminal` - Terminal interface.
- `/settings` - Application settings. - `/settings` - Settings category hub (mobile) and responsive master-detail shell.
- `/settings/general` - Language preferences.
- `/settings/display` - Appearance, typography, colors, and session list preferences.
- `/settings/chat` - Message input, tool card, and conversation color preferences.
- `/settings/voice` - Everyday voice assistant preferences.
- `/settings/voice/voices` - Full-page voice picker.
- `/settings/voice/advanced` - Voice persona, tuning, and diagnostics.
- `/settings/about` - Application links and version information.
## Features ## Features
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Settings responsive fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e-fixtures/settings-fixture.tsx"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'
import '../src/index.css'
import { I18nProvider } from '../src/lib/i18n-context'
import SettingsLayout from '../src/routes/settings/layout'
import SettingsHubPage from '../src/routes/settings'
import SettingsDisplayPage from '../src/routes/settings/display'
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: SettingsLayout,
})
const settingsIndexRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/',
component: SettingsHubPage,
})
const settingsDisplayRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'display',
component: SettingsDisplayPage,
})
const routeTree = rootRoute.addChildren([
settingsRoute.addChildren([settingsIndexRoute, settingsDisplayRoute]),
])
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/settings'] }),
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<I18nProvider>
<RouterProvider router={router} />
</I18nProvider>
</React.StrictMode>
)
}
@@ -0,0 +1,48 @@
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/lib/use-translation'
import { useAppearance } from '@/hooks/useTheme'
import { useFontScale } from '@/hooks/useFontScale'
import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { settingsCategories } from '@/routes/settings/categories'
import { ChevronRightIcon } from './SettingsPrimitives'
export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
const navigate = useNavigate()
const { t, locale } = useTranslation()
const { appearance } = useAppearance()
const { fontScale } = useFontScale()
const { composerEnterBehavior } = useComposerEnterBehavior()
const summaries: Record<string, string> = {
general: locale === 'zh-CN' ? '简体中文' : 'English',
display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`,
chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`),
voice: t('settings.hub.voice.summary'),
about: `v${__APP_VERSION__}`,
}
return (
<nav aria-label={t('settings.title')} className={props.mobile ? 'divide-y divide-[var(--app-divider)]' : 'space-y-1 p-3'}>
{settingsCategories.map((category) => {
const active = props.activeId === category.id
return (
<button
key={category.id}
type="button"
onClick={() => navigate({ to: category.path })}
aria-current={active ? 'page' : undefined}
className={props.mobile
? 'flex min-h-16 w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]'
: `flex w-full items-center rounded-lg px-3 py-2.5 text-left transition-colors ${active ? 'bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
>
<span className="min-w-0 flex-1">
<span className="block font-medium">{t(category.titleKey)}</span>
{props.mobile ? <span className="mt-0.5 block truncate text-sm text-[var(--app-hint)]">{summaries[category.id]}</span> : null}
</span>
{props.mobile ? <ChevronRightIcon className="h-4 w-4 text-[var(--app-hint)]" /> : null}
</button>
)
})}
</nav>
)
}
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react'
export function ChevronRightIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={props.className} aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
)
}
export function CheckIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={props.className} aria-hidden="true">
<path d="m20 6-11 11-5-5" />
</svg>
)
}
export function SettingsPageContent(props: { title: string; description?: string; children: ReactNode }) {
return (
<div className="mx-auto w-full max-w-[720px] space-y-5 px-3 py-4 lg:px-6 lg:py-6">
<div>
<h1 tabIndex={-1} className="hidden text-xl font-semibold text-[var(--app-fg)] outline-none lg:block">{props.title}</h1>
{props.description ? <p className="text-sm text-[var(--app-hint)] lg:mt-1">{props.description}</p> : null}
</div>
{props.children}
</div>
)
}
export function SettingsSection(props: { title?: string; description?: string; children: ReactNode }) {
return (
<section>
{props.title ? <h2 className="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--app-hint)]">{props.title}</h2> : null}
{props.description ? <p className="mb-2 px-1 text-sm text-[var(--app-hint)]">{props.description}</p> : null}
<div className="overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-sm divide-y divide-[var(--app-divider)]">
{props.children}
</div>
</section>
)
}
export function SettingsRow(props: { label: string; description?: string; trailing?: ReactNode; children?: ReactNode }) {
return (
<div className="flex min-h-12 items-center justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="text-[var(--app-fg)]">{props.label}</div>
{props.description ? <div className="mt-0.5 text-xs leading-snug text-[var(--app-hint)]">{props.description}</div> : null}
{props.children}
</div>
{props.trailing ? <div className="shrink-0">{props.trailing}</div> : null}
</div>
)
}
export function SettingsSwitch(props: { label: string; description?: string; checked: boolean; onChange: (checked: boolean) => void }) {
return (
<SettingsRow label={props.label} description={props.description} trailing={
<label className="relative inline-flex h-6 w-11 items-center">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} className="peer sr-only" aria-label={props.label} />
<span className="absolute inset-0 rounded-full bg-[var(--app-border)] transition-colors peer-checked:bg-[var(--app-link)]" />
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-[var(--app-bg)] shadow-sm transition-transform peer-checked:translate-x-5" />
</label>
} />
)
}
export function SettingsChoiceGroup<T extends string | number>(props: {
label: string
value: T
options: ReadonlyArray<{ value: T; label: string; description?: string }>
onChange: (value: T) => void
columns?: 2 | 4 | 5
}) {
const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2'
return (
<fieldset className="px-3 py-3">
<legend className="mb-2 text-[var(--app-fg)]">{props.label}</legend>
<div role="radiogroup" aria-label={props.label} className={`grid ${columns} gap-2`}>
{props.options.map((option) => {
const selected = props.value === option.value
return (
<button
key={String(option.value)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => props.onChange(option.value)}
className={`min-w-0 rounded-lg border px-2 py-2 text-center text-sm transition-colors ${selected
? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]'
: 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
>
<span className="block truncate font-medium">{option.label}</span>
{option.description ? <span className="mt-0.5 block text-xs text-[var(--app-hint)]">{option.description}</span> : null}
</button>
)
})}
</div>
</fieldset>
)
}
export function SettingsLinkRow(props: { label: string; value?: string; description?: string; onClick: () => void }) {
return (
<button type="button" onClick={props.onClick} className="flex min-h-12 w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]">
<span className="min-w-0 flex-1">
<span className="block text-[var(--app-fg)]">{props.label}</span>
{props.description ? <span className="mt-0.5 block text-xs text-[var(--app-hint)]">{props.description}</span> : null}
</span>
{props.value ? <span className="max-w-[45%] truncate text-sm text-[var(--app-hint)]">{props.value}</span> : null}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-[var(--app-hint)]" />
</button>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getSettingsBackTarget } from './useAppGoBack'
describe('getSettingsBackTarget', () => {
it.each([
['/settings', '/sessions'],
['/settings/general', '/settings'],
['/settings/display', '/settings'],
['/settings/voice', '/settings'],
['/settings/voice/voices', '/settings/voice'],
['/settings/voice/advanced', '/settings/voice'],
['/sessions', null],
])('maps %s to %s', (pathname, target) => {
expect(getSettingsBackTarget(pathname)).toBe(target)
})
})
+11 -3
View File
@@ -1,6 +1,13 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router' import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
export function getSettingsBackTarget(pathname: string): string | null {
if (pathname === '/settings') return '/sessions'
if (pathname === '/settings/voice/advanced' || pathname === '/settings/voice/voices') return '/settings/voice'
if (pathname.startsWith('/settings/')) return '/settings'
return null
}
export function useAppGoBack(): () => void { export function useAppGoBack(): () => void {
const navigate = useNavigate() const navigate = useNavigate()
const router = useRouter() const router = useRouter()
@@ -14,9 +21,10 @@ export function useAppGoBack(): () => void {
return return
} }
// Settings page always goes back to sessions // Settings uses explicit parent routes so mobile drill-down remains predictable.
if (pathname === '/settings') { const settingsBackTarget = getSettingsBackTarget(pathname)
navigate({ to: '/sessions' }) if (settingsBackTarget) {
navigate({ to: settingsBackTarget })
return return
} }
+18
View File
@@ -545,10 +545,18 @@ export default {
'language.chinese': '简体中文', 'language.chinese': '简体中文',
// Settings page // Settings page
'common.back': 'Back',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.hub.description': 'Choose a category to adjust HAPI to your workflow.',
'settings.hub.voice.summary': 'Voice, language, and behavior',
'settings.general.title': 'General',
'settings.general.description': 'Language and general application preferences.',
'settings.language.title': 'Language', 'settings.language.title': 'Language',
'settings.language.label': 'Language', 'settings.language.label': 'Language',
'settings.display.title': 'Display', 'settings.display.title': 'Display',
'settings.display.description': 'Appearance, typography, and session list preferences.',
'settings.display.typography': 'Typography',
'settings.display.sessions': 'Session list',
'settings.display.appearance': 'Appearance', 'settings.display.appearance': 'Appearance',
'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.system': 'Follow System',
'settings.display.appearance.dark': 'Dark', 'settings.display.appearance.dark': 'Dark',
@@ -558,6 +566,7 @@ export default {
'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.', 'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.',
'settings.display.themeColors.reset': 'Reset', 'settings.display.themeColors.reset': 'Reset',
'settings.display.themeColors.resetAll': 'Reset all', 'settings.display.themeColors.resetAll': 'Reset all',
'settings.display.themeColors.expand': 'Customize',
'settings.display.themeColors.key.background': 'Background', 'settings.display.themeColors.key.background': 'Background',
'settings.display.themeColors.key.surface': 'Cards & surfaces', 'settings.display.themeColors.key.surface': 'Cards & surfaces',
'settings.display.themeColors.key.text': 'Text', 'settings.display.themeColors.key.text': 'Text',
@@ -577,6 +586,10 @@ export default {
'settings.display.sessionListStatus.detailed': 'Detailed', 'settings.display.sessionListStatus.detailed': 'Detailed',
'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).', 'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).',
'settings.chat.title': 'Chat', 'settings.chat.title': 'Chat',
'settings.chat.description': 'Message input, tool cards, and conversation colors.',
'settings.chat.input': 'Input',
'settings.chat.tools': 'Tool cards',
'settings.chat.colors': 'Conversation colors',
'settings.chat.enterBehavior': 'Enter Key', 'settings.chat.enterBehavior': 'Enter Key',
'settings.chat.enterBehavior.send': 'Send message', 'settings.chat.enterBehavior.send': 'Send message',
'settings.chat.enterBehavior.newline': 'Insert newline', 'settings.chat.enterBehavior.newline': 'Insert newline',
@@ -591,6 +604,8 @@ export default {
'settings.chat.surfaceColor.softYellow': 'Soft yellow', 'settings.chat.surfaceColor.softYellow': 'Soft yellow',
'settings.chat.surfaceColor.custom': 'Custom color', 'settings.chat.surfaceColor.custom': 'Custom color',
'settings.voice.title': 'Voice Assistant', 'settings.voice.title': 'Voice Assistant',
'settings.voice.description': 'Voice connection, language, and everyday behavior.',
'settings.voice.voices.description': 'Choose the voice used by the selected backend.',
'settings.voice.sounds.title': 'How It Sounds', 'settings.voice.sounds.title': 'How It Sounds',
'settings.voice.responds.title': 'How It Responds', 'settings.voice.responds.title': 'How It Responds',
'settings.voice.connection.title': 'Connection & provider', 'settings.voice.connection.title': 'Connection & provider',
@@ -671,11 +686,14 @@ export default {
'settings.voice.tuning.selectBackend': 'Per-backend sliders appear when a voice backend is selected.', 'settings.voice.tuning.selectBackend': 'Per-backend sliders appear when a voice backend is selected.',
'settings.voice.preview.elevenlabsOnly': 'Voice preview is only available for ElevenLabs', 'settings.voice.preview.elevenlabsOnly': 'Voice preview is only available for ElevenLabs',
'settings.voice.preview.unavailable': 'Preview unavailable without an ElevenLabs API key', 'settings.voice.preview.unavailable': 'Preview unavailable without an ElevenLabs API key',
'settings.voice.preview.play': 'Preview voice',
'settings.voice.preview.stop': 'Stop preview',
'settings.voice.staticCatalogHint': 'Open the list to see voice character notes. Audio preview is ElevenLabs only.', 'settings.voice.staticCatalogHint': 'Open the list to see voice character notes. Audio preview is ElevenLabs only.',
'settings.voice.session.label': 'Session behavior', 'settings.voice.session.label': 'Session behavior',
'settings.voice.proactive': 'Start voice session with summary', 'settings.voice.proactive': 'Start voice session with summary',
'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.', 'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.',
'settings.about.title': 'About', 'settings.about.title': 'About',
'settings.about.description': 'HAPI links and version information.',
'settings.about.website': 'Website', 'settings.about.website': 'Website',
'settings.about.appVersion': 'App Version', 'settings.about.appVersion': 'App Version',
'settings.about.protocolVersion': 'Protocol Version', 'settings.about.protocolVersion': 'Protocol Version',
+18
View File
@@ -549,10 +549,18 @@ export default {
'language.chinese': '简体中文', 'language.chinese': '简体中文',
// Settings page // Settings page
'common.back': '返回',
'settings.title': '设置', 'settings.title': '设置',
'settings.hub.description': '选择一个分类,按你的工作方式调整 HAPI。',
'settings.hub.voice.summary': '声音、语言和行为',
'settings.general.title': '通用',
'settings.general.description': '语言和通用应用偏好。',
'settings.language.title': '语言', 'settings.language.title': '语言',
'settings.language.label': '语言', 'settings.language.label': '语言',
'settings.display.title': '显示', 'settings.display.title': '显示',
'settings.display.description': '外观、字体和会话列表偏好。',
'settings.display.typography': '字体',
'settings.display.sessions': '会话列表',
'settings.display.appearance': '外观', 'settings.display.appearance': '外观',
'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.system': '跟随系统',
'settings.display.appearance.dark': '深色', 'settings.display.appearance.dark': '深色',
@@ -562,6 +570,7 @@ export default {
'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。', 'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
'settings.display.themeColors.reset': '重置', 'settings.display.themeColors.reset': '重置',
'settings.display.themeColors.resetAll': '全部重置', 'settings.display.themeColors.resetAll': '全部重置',
'settings.display.themeColors.expand': '自定义',
'settings.display.themeColors.key.background': '背景', 'settings.display.themeColors.key.background': '背景',
'settings.display.themeColors.key.surface': '卡片与表面', 'settings.display.themeColors.key.surface': '卡片与表面',
'settings.display.themeColors.key.text': '文字', 'settings.display.themeColors.key.text': '文字',
@@ -581,6 +590,10 @@ export default {
'settings.display.sessionListStatus.detailed': '详细', 'settings.display.sessionListStatus.detailed': '详细',
'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。', 'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。',
'settings.chat.title': '聊天', 'settings.chat.title': '聊天',
'settings.chat.description': '消息输入、工具卡片和对话颜色。',
'settings.chat.input': '输入',
'settings.chat.tools': '工具卡片',
'settings.chat.colors': '对话颜色',
'settings.chat.enterBehavior': '回车键行为', 'settings.chat.enterBehavior': '回车键行为',
'settings.chat.enterBehavior.send': '发送消息', 'settings.chat.enterBehavior.send': '发送消息',
'settings.chat.enterBehavior.newline': '插入换行', 'settings.chat.enterBehavior.newline': '插入换行',
@@ -595,6 +608,8 @@ export default {
'settings.chat.surfaceColor.softYellow': '柔和黄', 'settings.chat.surfaceColor.softYellow': '柔和黄',
'settings.chat.surfaceColor.custom': '自定义颜色', 'settings.chat.surfaceColor.custom': '自定义颜色',
'settings.voice.title': '语音助手', 'settings.voice.title': '语音助手',
'settings.voice.description': '语音连接、语言和常用行为。',
'settings.voice.voices.description': '选择当前后端使用的声音。',
'settings.voice.sounds.title': '声音效果', 'settings.voice.sounds.title': '声音效果',
'settings.voice.responds.title': '对话风格', 'settings.voice.responds.title': '对话风格',
'settings.voice.connection.title': '连接与提供商', 'settings.voice.connection.title': '连接与提供商',
@@ -675,11 +690,14 @@ export default {
'settings.voice.tuning.selectBackend': '选择语音后端后显示对应滑块。', 'settings.voice.tuning.selectBackend': '选择语音后端后显示对应滑块。',
'settings.voice.preview.elevenlabsOnly': '仅 ElevenLabs 支持试听', 'settings.voice.preview.elevenlabsOnly': '仅 ElevenLabs 支持试听',
'settings.voice.preview.unavailable': '未配置 ElevenLabs API 密钥时无法试听', 'settings.voice.preview.unavailable': '未配置 ElevenLabs API 密钥时无法试听',
'settings.voice.preview.play': '预览声音',
'settings.voice.preview.stop': '停止预览',
'settings.voice.staticCatalogHint': '展开列表可查看声音特点说明;仅 ElevenLabs 支持试听。', 'settings.voice.staticCatalogHint': '展开列表可查看声音特点说明;仅 ElevenLabs 支持试听。',
'settings.voice.session.label': '会话行为', 'settings.voice.session.label': '会话行为',
'settings.voice.proactive': '以摘要开始语音会话', 'settings.voice.proactive': '以摘要开始语音会话',
'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。', 'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。',
'settings.about.title': '关于', 'settings.about.title': '关于',
'settings.about.description': 'HAPI 链接和版本信息。',
'settings.about.website': '官方网站', 'settings.about.website': '官方网站',
'settings.about.appVersion': '应用版本', 'settings.about.appVersion': '应用版本',
'settings.about.protocolVersion': '协议版本', 'settings.about.protocolVersion': '协议版本',
+68 -3
View File
@@ -46,7 +46,15 @@ import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } fr
import FilesPage from '@/routes/sessions/files' import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file' import FilePage from '@/routes/sessions/file'
import TerminalPage from '@/routes/sessions/terminal' import TerminalPage from '@/routes/sessions/terminal'
import SettingsPage from '@/routes/settings' import SettingsLayout from '@/routes/settings/layout'
import SettingsHubPage from '@/routes/settings'
import SettingsGeneralPage from '@/routes/settings/general'
import SettingsDisplayPage from '@/routes/settings/display'
import SettingsChatPage from '@/routes/settings/chat'
import SettingsVoicePage from '@/routes/settings/voice'
import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices'
import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced'
import SettingsAboutPage from '@/routes/settings/about'
import SharePage from '@/routes/share' import SharePage from '@/routes/share'
import { setSharePendingTransfer } from '@/lib/sharePendingState' import { setSharePendingTransfer } from '@/lib/sharePendingState'
import { deleteShareTransfer } from '@/lib/shareTransfer' import { deleteShareTransfer } from '@/lib/shareTransfer'
@@ -1226,7 +1234,55 @@ const browseRoute = createRoute({
const settingsRoute = createRoute({ const settingsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/settings', path: '/settings',
component: SettingsPage, component: SettingsLayout,
})
const settingsIndexRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/',
component: SettingsHubPage,
})
const settingsGeneralRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'general',
component: SettingsGeneralPage,
})
const settingsDisplayRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'display',
component: SettingsDisplayPage,
})
const settingsChatRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'chat',
component: SettingsChatPage,
})
const settingsVoiceRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'voice',
component: SettingsVoicePage,
})
const settingsVoiceVoicesRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'voice/voices',
component: SettingsVoiceVoicesPage,
})
const settingsVoiceAdvancedRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'voice/advanced',
component: SettingsVoiceAdvancedPage,
})
const settingsAboutRoute = createRoute({
getParentRoute: () => settingsRoute,
path: 'about',
component: SettingsAboutPage,
}) })
// Web Share Target landing route. Service worker (`web/src/sw.ts`) // Web Share Target landing route. Service worker (`web/src/sw.ts`)
@@ -1260,7 +1316,16 @@ export const routeTree = rootRoute.addChildren([
]), ]),
]), ]),
browseRoute, browseRoute,
settingsRoute, settingsRoute.addChildren([
settingsIndexRoute,
settingsGeneralRoute,
settingsDisplayRoute,
settingsChatRoute,
settingsVoiceRoute,
settingsVoiceVoicesRoute,
settingsVoiceAdvancedRoute,
settingsAboutRoute,
]),
shareRoute, shareRoute,
]) ])
+18
View File
@@ -0,0 +1,18 @@
import { PROTOCOL_VERSION } from '@hapi/protocol'
import { useTranslation } from '@/lib/use-translation'
import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives'
export default function SettingsAboutPage() {
const { t } = useTranslation()
return (
<SettingsPageContent title={t('settings.about.title')} description={t('settings.about.description')}>
<SettingsSection>
<SettingsRow label={t('settings.about.website')} trailing={
<a href="https://hapi.run" target="_blank" rel="noopener noreferrer" className="text-[var(--app-link)] hover:underline">hapi.run</a>
} />
<SettingsRow label={t('settings.about.appVersion')} trailing={<span className="text-[var(--app-hint)]">{__APP_VERSION__}</span>} />
<SettingsRow label={t('settings.about.protocolVersion')} trailing={<span className="text-[var(--app-hint)]">{PROTOCOL_VERSION}</span>} />
</SettingsSection>
</SettingsPageContent>
)
}
+13
View File
@@ -0,0 +1,13 @@
export const settingsCategories = [
{ id: 'general', path: '/settings/general', titleKey: 'settings.general.title' },
{ id: 'display', path: '/settings/display', titleKey: 'settings.display.title' },
{ id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' },
{ id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' },
{ id: 'about', path: '/settings/about', titleKey: 'settings.about.title' },
] as const
export type SettingsCategory = typeof settingsCategories[number]
export function getSettingsCategory(pathname: string): SettingsCategory | undefined {
return settingsCategories.find((category) => pathname === category.path || pathname.startsWith(`${category.path}/`))
}
+75
View File
@@ -0,0 +1,75 @@
import { useTranslation } from '@/lib/use-translation'
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
import {
getChatSurfaceColorPickerValue,
getChatSurfaceColorPresetOptions,
toCustomChatSurfaceColorPreference,
toPresetChatSurfaceColorPreference,
useChatSurfaceColors,
type ChatSurfaceColorPreference,
type ChatSurfaceColorPreset,
} from '@/hooks/useChatSurfaceColors'
import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
function ChatSurfaceColorControl(props: {
label: string
preference: ChatSurfaceColorPreference
onPresetChange: (preset: ChatSurfaceColorPreset) => void
onCustomChange: (value: string) => void
}) {
const { t } = useTranslation()
const pickerValue = getChatSurfaceColorPickerValue(props.preference)
return (
<fieldset className="px-3 py-3">
<legend className="mb-2 text-[var(--app-fg)]">{props.label}</legend>
<div role="radiogroup" aria-label={props.label} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{getChatSurfaceColorPresetOptions().map((option) => {
const preference = toPresetChatSurfaceColorPreference(option.value)
const selected = props.preference === preference
return (
<button key={option.value} type="button" role="radio" aria-checked={selected} onClick={() => props.onPresetChange(option.value)} className={`flex min-w-0 items-center gap-2 rounded-lg border px-2.5 py-2 text-sm ${selected ? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'border-[var(--app-border)] text-[var(--app-fg)]'}`}>
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: getChatSurfaceColorPickerValue(preference) }} />
<span className="truncate">{t(option.labelKey)}</span>
</button>
)
})}
</div>
<label className="mt-3 flex items-center justify-between gap-3 text-sm text-[var(--app-hint)]">
{t('settings.chat.surfaceColor.custom')}
<input type="color" value={pickerValue} onChange={(event) => props.onCustomChange(event.target.value)} className="h-9 w-12 cursor-pointer border-0 bg-transparent p-0" />
</label>
</fieldset>
)
}
export default function SettingsChatPage() {
const { t } = useTranslation()
const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior()
const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode()
const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors()
return (
<SettingsPageContent title={t('settings.chat.title')} description={t('settings.chat.description')}>
<SettingsSection title={t('settings.chat.input')}>
<SettingsChoiceGroup
label={t('settings.chat.enterBehavior')}
value={composerEnterBehavior}
options={getComposerEnterBehaviorOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setComposerEnterBehavior}
/>
</SettingsSection>
<SettingsSection title={t('settings.chat.tools')}>
<SettingsChoiceGroup
label={t('settings.chat.terminalToolDisplay')}
value={terminalToolDisplayMode}
options={getTerminalToolDisplayModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setTerminalToolDisplayMode}
/>
</SettingsSection>
<SettingsSection title={t('settings.chat.colors')}>
<ChatSurfaceColorControl label={t('settings.chat.groupedToolBackground')} preference={toolGroupBackground} onPresetChange={(preset) => setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} />
<ChatSurfaceColorControl label={t('settings.chat.userMessageBackground')} preference={userMessageBackground} onPresetChange={(preset) => setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} />
</SettingsSection>
</SettingsPageContent>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '@/lib/use-translation'
import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme'
import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { SettingsChoiceGroup, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives'
function MinusIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M5 12h14" /></svg>
}
function PlusIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
}
function SessionPreviewLimitControl() {
const { t } = useTranslation()
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
const [draft, setDraft] = useState(String(sessionPreviewLimit))
useEffect(() => setDraft(String(sessionPreviewLimit)), [sessionPreviewLimit])
const commit = () => {
const parsed = draft.trim() === '' ? sessionPreviewLimit : Number(draft)
const next = normalizeSessionPreviewLimit(parsed)
setSessionPreviewLimit(next)
setDraft(String(next))
}
const step = (delta: number) => setSessionPreviewLimit(normalizeSessionPreviewLimit(sessionPreviewLimit + delta))
return (
<SettingsRow label={t('settings.display.sessionPreviewLimit')} trailing={
<div className="flex h-9 items-center rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)]">
<button type="button" onClick={() => step(-1)} disabled={sessionPreviewLimit <= MIN_SESSION_PREVIEW_LIMIT} aria-label={t('settings.display.sessionPreviewLimit.decrease')} className="flex h-8 w-8 items-center justify-center disabled:opacity-40"><MinusIcon /></button>
<input
aria-label={t('settings.display.sessionPreviewLimit')}
type="number"
inputMode="numeric"
min={MIN_SESSION_PREVIEW_LIMIT}
max={MAX_SESSION_PREVIEW_LIMIT}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') { commit(); event.currentTarget.blur() }
if (event.key === 'Escape') { setDraft(String(sessionPreviewLimit)); event.currentTarget.blur() }
}}
className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm text-[var(--app-fg)] outline-none"
/>
<button type="button" onClick={() => step(1)} disabled={sessionPreviewLimit >= MAX_SESSION_PREVIEW_LIMIT} aria-label={t('settings.display.sessionPreviewLimit.increase')} className="flex h-8 w-8 items-center justify-center disabled:opacity-40"><PlusIcon /></button>
</div>
} />
)
}
function ThemeColorControls() {
const { t } = useTranslation()
const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors()
return (
<details open={hasAnyCustom} className="group">
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-3 text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]">
<span>
<span className="block">{t('settings.display.themeColors.title')}</span>
<span className="mt-0.5 block text-xs text-[var(--app-hint)]">{t('settings.display.themeColors.description')}</span>
</span>
<span className="ml-3 text-sm text-[var(--app-hint)]">{hasAnyCustom ? t('settings.voice.advanced.customizedBadge') : t('settings.display.themeColors.expand')}</span>
</summary>
<div className="border-t border-[var(--app-divider)] px-3 py-3">
{hasAnyCustom ? <div className="mb-3 text-right"><button type="button" onClick={resetAll} className="text-sm text-[var(--app-link)] hover:underline">{t('settings.display.themeColors.resetAll')}</button></div> : null}
<div className="grid gap-3 sm:grid-cols-2">
{keys.map((key) => (
<div key={key.id} className="flex items-center justify-between gap-2 rounded-lg border border-[var(--app-border)] px-2.5 py-2">
<span className="min-w-0 truncate text-sm text-[var(--app-fg)]">{t(key.labelKey)}</span>
<span className="flex shrink-0 items-center gap-2">
{isCustomized(key.id) ? <button type="button" onClick={(event) => { event.preventDefault(); resetColor(key.id as ThemeColorKeyId) }} className="text-xs text-[var(--app-hint)] hover:text-[var(--app-link)]">{t('settings.display.themeColors.reset')}</button> : null}
<input type="color" aria-label={t(key.labelKey)} value={getPickerValue(key.id)} onChange={(event) => setColor(key.id as ThemeColorKeyId, event.target.value)} className="h-8 w-10 cursor-pointer border-0 bg-transparent p-0" />
</span>
</div>
))}
</div>
</div>
</details>
)
}
export default function SettingsDisplayPage() {
const { t } = useTranslation()
const { appearance, setAppearance } = useAppearance()
const { fontScale, setFontScale } = useFontScale()
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly()
return (
<SettingsPageContent title={t('settings.display.title')} description={t('settings.display.description')}>
<SettingsSection title={t('settings.display.appearance')}>
<SettingsChoiceGroup
label={t('settings.display.appearance')}
value={appearance}
columns={4}
options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setAppearance}
/>
<ThemeColorControls />
</SettingsSection>
<SettingsSection title={t('settings.display.typography')}>
<SettingsChoiceGroup label={t('settings.display.fontSize')} value={fontScale} columns={5} options={getFontScaleOptions()} onChange={setFontScale} />
<SettingsChoiceGroup label={t('settings.display.terminalFontSize')} value={terminalFontSize} columns={5} options={getTerminalFontSizeOptions()} onChange={setTerminalFontSize} />
</SettingsSection>
<SettingsSection title={t('settings.display.sessions')}>
<SessionPreviewLimitControl />
<SettingsSwitch label={t('settings.display.activeSessionsOnly')} description={t('settings.display.activeSessionsOnly.desc')} checked={showActiveSessionsOnly} onChange={setShowActiveSessionsOnly} />
<SettingsChoiceGroup
label={t('settings.display.sessionListStatus')}
value={sessionListStatusMode}
options={getSessionListStatusModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setSessionListStatusMode}
/>
{sessionListStatusMode === 'detailed' ? <div className="px-3 pb-3 text-xs text-[var(--app-hint)]">{t('settings.display.sessionListStatus.detailedDescription')}</div> : null}
</SettingsSection>
</SettingsPageContent>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { useTranslation, type Locale } from '@/lib/use-translation'
import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
const locales: ReadonlyArray<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
]
export default function SettingsGeneralPage() {
const { t, locale, setLocale } = useTranslation()
return (
<SettingsPageContent title={t('settings.general.title')} description={t('settings.general.description')}>
<SettingsSection>
<SettingsChoiceGroup label={t('settings.language.label')} value={locale} options={locales} onChange={setLocale} />
</SettingsSection>
</SettingsPageContent>
)
}
+140 -440
View File
@@ -1,42 +1,89 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { I18nContext, I18nProvider } from '@/lib/i18n-context' import { I18nProvider } from '@/lib/i18n-context'
import { en } from '@/lib/locales' import SettingsHubPage from './index'
import { PROTOCOL_VERSION } from '@hapi/protocol' import SettingsGeneralPage from './general'
import SettingsPage from './index' import SettingsDisplayPage from './display'
import SettingsChatPage from './chat'
import SettingsAboutPage from './about'
import SettingsVoicePage from './voice'
import SettingsVoiceVoicesPage from './voice-voices'
import SettingsVoiceAdvancedPage from './voice-advanced'
vi.mock('@hapi/protocol', () => ({ const { navigate, setAppearance, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({
PROTOCOL_VERSION: 1, navigate: vi.fn(),
setAppearance: vi.fn(),
setFontScale: vi.fn(),
setTerminalFontSize: vi.fn(),
setComposerEnterBehavior: vi.fn(),
setVoice: vi.fn(),
})) }))
// Mock the router hooks
vi.mock('@tanstack/react-router', () => ({ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(), useNavigate: () => navigate,
useRouter: () => ({ history: { back: vi.fn() } }), }))
useLocation: () => '/settings',
vi.mock('@hapi/protocol', () => ({ PROTOCOL_VERSION: 1 }))
vi.mock('@/hooks/useTheme', () => ({
useAppearance: () => ({ appearance: 'system', setAppearance }),
getAppearanceOptions: () => [
{ value: 'system', labelKey: 'settings.display.appearance.system' },
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
{ value: 'oled', labelKey: 'settings.display.appearance.oled' },
{ value: 'light', labelKey: 'settings.display.appearance.light' },
],
})) }))
// Mock useFontScale hook
vi.mock('@/hooks/useFontScale', () => ({ vi.mock('@/hooks/useFontScale', () => ({
useFontScale: () => ({ fontScale: 1, setFontScale: vi.fn() }), useFontScale: () => ({ fontScale: 1, setFontScale }),
getFontScaleOptions: () => [ getFontScaleOptions: () => [
{ value: 0.875, label: '87.5%' }, { value: 0.8, label: '80%' }, { value: 0.9, label: '90%' }, { value: 1, label: '100%' },
{ value: 1, label: '100%' }, { value: 1.1, label: '110%' }, { value: 1.2, label: '120%' },
{ value: 1.125, label: '112.5%' },
], ],
})) }))
vi.mock('@/hooks/useTerminalFontSize', () => ({ vi.mock('@/hooks/useTerminalFontSize', () => ({
useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize: vi.fn() }), useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize }),
getTerminalFontSizeOptions: () => [ getTerminalFontSizeOptions: () => [
{ value: 9, label: '9px' }, { value: 9, label: '9px' }, { value: 11, label: '11px' }, { value: 13, label: '13px' },
{ value: 13, label: '13px' }, { value: 15, label: '15px' }, { value: 17, label: '17px' },
{ value: 17, label: '17px' },
], ],
})) }))
vi.mock('@/hooks/useSessionListStatusMode', () => ({
useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }),
getSessionListStatusModeOptions: () => [
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
],
}))
vi.mock('@/hooks/useShowActiveSessionsOnly', () => ({
useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }),
}))
vi.mock('@/hooks/useSessionPreviewLimit', () => ({
MIN_SESSION_PREVIEW_LIMIT: 1,
MAX_SESSION_PREVIEW_LIMIT: 99,
normalizeSessionPreviewLimit: (value: number) => Math.max(1, Math.min(99, Math.round(value))),
useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }),
}))
vi.mock('@/hooks/useThemeColors', () => ({
useThemeColors: () => ({
keys: [],
getPickerValue: vi.fn(),
isCustomized: vi.fn(() => false),
hasAnyCustom: false,
setColor: vi.fn(),
resetColor: vi.fn(),
resetAll: vi.fn(),
}),
}))
vi.mock('@/hooks/useComposerEnterBehavior', () => ({ vi.mock('@/hooks/useComposerEnterBehavior', () => ({
useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior: vi.fn() }), useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior }),
getComposerEnterBehaviorOptions: () => [ getComposerEnterBehaviorOptions: () => [
{ value: 'send', labelKey: 'settings.chat.enterBehavior.send' }, { value: 'send', labelKey: 'settings.chat.enterBehavior.send' },
{ value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' }, { value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' },
@@ -51,21 +98,6 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({
], ],
})) }))
vi.mock('@/hooks/useSessionListStatusMode', () => ({
useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }),
getSessionListStatusModeOptions: () => [
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
],
}))
vi.mock('@/hooks/useSessionPreviewLimit', () => ({
MIN_SESSION_PREVIEW_LIMIT: 1,
MAX_SESSION_PREVIEW_LIMIT: 99,
normalizeSessionPreviewLimit: (value: number) => Number.isInteger(value) ? Math.min(99, Math.max(1, value)) : 8,
useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }),
}))
vi.mock('@/hooks/useChatSurfaceColors', () => ({ vi.mock('@/hooks/useChatSurfaceColors', () => ({
useChatSurfaceColors: () => ({ useChatSurfaceColors: () => ({
toolGroupBackground: 'default', toolGroupBackground: 'default',
@@ -76,440 +108,108 @@ vi.mock('@/hooks/useChatSurfaceColors', () => ({
getChatSurfaceColorPresetOptions: () => [ getChatSurfaceColorPresetOptions: () => [
{ value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, { value: 'default', labelKey: 'settings.chat.surfaceColor.default' },
{ value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' },
{ value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' },
{ value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' },
], ],
getChatSurfaceColorPickerValue: () => '#7db7ff', getChatSurfaceColorPickerValue: () => '#7db7ff',
toPresetChatSurfaceColorPreference: (value: string) => value === 'default' ? 'default' : `preset:${value}`, toPresetChatSurfaceColorPreference: (value: string) => value === 'default' ? 'default' : `preset:${value}`,
toCustomChatSurfaceColorPreference: (value: string) => `custom:${value}`, toCustomChatSurfaceColorPreference: (value: string) => `custom:${value}`,
})) }))
// Mock useTheme hook vi.mock('@/components/settings/VoiceAdvancedControls', () => ({
vi.mock('@/hooks/useTheme', () => ({ VoiceRespondsControls: () => <div>Response length controls</div>,
useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), VoiceSoundsControls: () => <div>Sound controls</div>,
getAppearanceOptions: () => [ VoicePersonaControls: () => <div>Persona controls</div>,
{ value: 'system', labelKey: 'settings.display.appearance.system' }, VoiceDiagnosticsControls: () => <div>Diagnostics controls</div>,
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
{ value: 'light', labelKey: 'settings.display.appearance.light' },
],
})) }))
// Mock languages vi.mock('./useVoiceSettings', () => ({
vi.mock('@/lib/languages', () => ({ useVoiceSettings: () => ({
getElevenLabsSupportedLanguages: () => [ configuredBackends: ['elevenlabs'],
{ code: null, name: 'Auto-detect' }, backend: 'elevenlabs',
{ code: 'en', name: 'English' }, setBackend: vi.fn(),
], voiceId: null,
getLanguageDisplayName: (lang: { code: string | null; name: string }) => lang.name, setVoice,
voices: [
{ id: 'voice-1', name: 'Jessica', description: 'Warm', previewUrl: 'https://example.test/voice.mp3', category: 'premade' },
],
voiceLanguage: null,
setVoiceLanguage: vi.fn(),
voiceLanguages: [{ code: null, name: 'Auto-detect' }, { code: 'en', name: 'English' }],
playingVoiceId: null,
previewVoice: vi.fn(),
}),
})) }))
// Use vi.hoisted so these mocks are available when vi.mock factories run function renderPage(page: React.ReactElement) {
const { mockFetchVoices, mockFetchVoiceBackend, mockApi } = vi.hoisted(() => { return render(<I18nProvider>{page}</I18nProvider>)
const mockFetchVoices = vi.fn(() => Promise.resolve<unknown[]>([]))
const mockFetchVoiceBackend = vi.fn(() => Promise.resolve({
backend: 'elevenlabs' as 'elevenlabs' | 'gemini-live' | 'qwen-realtime',
backends: ['elevenlabs'] as Array<'elevenlabs' | 'gemini-live' | 'qwen-realtime'>
}))
const mockApi = {
fetchVoices: vi.fn(() => Promise.resolve({ voices: [] })),
}
return { mockFetchVoices, mockFetchVoiceBackend, mockApi }
})
// Mock static voices list
vi.mock('@/lib/voices', () => ({
VOICES: [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }],
DEFAULT_VOICE_ID: 'voice1',
getVoiceById: (id: string | null) =>
id === 'voice1' ? { id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' } : undefined,
getFallbackVoices: () => [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }],
}))
// Mock fetchVoices to return a resolved list by default
vi.mock('@/api/voice', () => ({
fetchVoices: mockFetchVoices,
fetchVoiceBackend: mockFetchVoiceBackend,
fetchVoiceToken: vi.fn(() => Promise.resolve({ allowed: true, token: 'tok' })),
}))
// Mock useAppContext so the page doesn't throw "AppContext is not available"
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({ api: mockApi, token: 'test', baseUrl: '' }),
AppContextProvider: ({ children }: { children: React.ReactNode }) => children,
}))
afterEach(() => {
cleanup()
})
function renderWithProviders(ui: React.ReactElement) {
return render(
<I18nProvider>
{ui}
</I18nProvider>
)
} }
function renderWithSpyT(ui: React.ReactElement) { describe('responsive settings pages', () => {
const translations = en as Record<string, string>
const spyT = vi.fn((key: string) => translations[key] ?? key)
render(
<I18nContext.Provider value={{ t: spyT, locale: 'en', setLocale: vi.fn() }}>
{ui}
</I18nContext.Provider>
)
return spyT
}
describe('SettingsPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockFetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) localStorage.clear()
// Reset fetchVoices mock to return empty list by default
mockFetchVoices.mockResolvedValue([])
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(() => null),
length: 0,
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
}) })
it('renders the About section', () => { it('renders the mobile hub categories with current summaries', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsHubPage />)
expect(screen.getByText('About')).toBeInTheDocument() expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getAllByText('Display').length).toBeGreaterThan(0)
expect(screen.getByText('Voice, language, and behavior')).toBeInTheDocument()
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
}) })
it('displays the App Version with correct value', () => { it('navigates from the hub to a category route', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsHubPage />)
expect(screen.getAllByText('App Version').length).toBeGreaterThanOrEqual(1) fireEvent.click(screen.getByRole('button', { name: /General/ }))
expect(screen.getAllByText(__APP_VERSION__).length).toBeGreaterThanOrEqual(1) expect(navigate).toHaveBeenCalledWith({ to: '/settings/general' })
}) })
it('displays the Protocol Version with correct value', () => { it('changes the application language inline', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsGeneralPage />)
expect(screen.getAllByText('Protocol Version').length).toBeGreaterThanOrEqual(1) fireEvent.click(screen.getByRole('radio', { name: '简体中文' }))
expect(screen.getAllByText(String(PROTOCOL_VERSION)).length).toBeGreaterThanOrEqual(1) expect(localStorage.getItem('hapi-lang')).toBe('zh-CN')
}) })
it('displays the website link with correct URL and security attributes', () => { it('renders compact display controls without dropdown popovers', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsDisplayPage />)
expect(screen.getAllByText('Website').length).toBeGreaterThanOrEqual(1) expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument()
const links = screen.getAllByRole('link', { name: 'hapi.run' }) expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument()
expect(links.length).toBeGreaterThanOrEqual(1) expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8)
const link = links[0] expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(link).toHaveAttribute('href', 'https://hapi.run')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
}) })
it('uses correct i18n keys for About section', () => { it('keeps chat enum choices inline', () => {
const spyT = renderWithSpyT(<SettingsPage />) renderPage(<SettingsChatPage />)
const calledKeys = spyT.mock.calls.map((call) => call[0]) fireEvent.click(screen.getByRole('radio', { name: 'Insert newline' }))
expect(calledKeys).toContain('settings.about.title') expect(setComposerEnterBehavior).toHaveBeenCalledWith('newline')
expect(calledKeys).toContain('settings.about.website') expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument()
expect(calledKeys).toContain('settings.about.appVersion')
expect(calledKeys).toContain('settings.about.protocolVersion')
}) })
it('renders the Appearance setting', () => { it('renders About metadata on its own route page', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsAboutPage />)
expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('App Version')).toBeInTheDocument()
expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1) expect(screen.getByText(String(__APP_VERSION__))).toBeInTheDocument()
expect(screen.getByText('Protocol Version')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'hapi.run' })).toHaveAttribute('rel', 'noopener noreferrer')
}) })
it('uses correct i18n keys for Appearance setting', () => { it('links common voice settings to full-page voices and advanced pages', () => {
const spyT = renderWithSpyT(<SettingsPage />) renderPage(<SettingsVoicePage />)
const calledKeys = spyT.mock.calls.map((call) => call[0]) fireEvent.click(screen.getByRole('button', { name: /Voice/ }))
expect(calledKeys).toContain('settings.display.appearance') expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/voices' })
expect(calledKeys).toContain('settings.display.appearance.system') fireEvent.click(screen.getByRole('button', { name: /Advanced voice settings/ }))
expect(calledKeys).toContain('settings.display.sessionPreviewLimit') expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/advanced' })
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase')
expect(calledKeys).toContain('settings.display.sessionListStatus')
expect(calledKeys).toContain('settings.display.sessionListStatus.standard')
}) })
it('renders the Terminal Font Size setting', () => { it('selects a voice from the full-page picker', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsVoiceVoicesPage />)
expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1) fireEvent.click(screen.getByRole('radio', { name: /Jessica/ }))
expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1) expect(setVoice).toHaveBeenCalledWith('voice-1')
}) })
it('renders the Session Preview Limit setting', () => { it('keeps persona, tuning, and diagnostics on the advanced route page', () => {
renderWithProviders(<SettingsPage />) renderPage(<SettingsVoiceAdvancedPage />)
expect(screen.getAllByText('Sessions Before Folding').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('Persona controls')).toBeInTheDocument()
expect(screen.getByLabelText('Sessions Before Folding')).toHaveValue(8) expect(screen.getByText('Sound controls')).toBeInTheDocument()
expect(screen.getAllByLabelText('Show fewer sessions before folding').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('Diagnostics controls')).toBeInTheDocument()
expect(screen.getAllByLabelText('Show more sessions before folding').length).toBeGreaterThanOrEqual(1)
})
it('renders the Session list status setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Session list status').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Standard').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('renders the Terminal Tool Display setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Terminal Tool Cards').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Compact (command only)').length).toBeGreaterThanOrEqual(1)
})
it('renders grouped tool and user message background settings', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Grouped Tool Use Background').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('User Message Background').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Default color').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Soft blue').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Soft green').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Soft yellow').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByLabelText('Custom color').length).toBeGreaterThanOrEqual(2)
})
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')
expect(calledKeys).toContain('settings.chat.terminalToolDisplay')
expect(calledKeys).toContain('settings.chat.terminalToolDisplay.compact')
expect(calledKeys).toContain('settings.chat.groupedToolBackground')
expect(calledKeys).toContain('settings.chat.userMessageBackground')
expect(calledKeys).toContain('settings.chat.surfaceColor.default')
})
// Voice picker tests
it('renders the Voice section with "Voice" label', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Voice').length).toBeGreaterThanOrEqual(1)
})
it('uses correct i18n keys for the voice picker', () => {
const spyT = renderWithSpyT(<SettingsPage />)
const calledKeys = spyT.mock.calls.map((call) => call[0])
expect(calledKeys).toContain('settings.voice.voice')
expect(calledKeys).toContain('settings.voice.voiceDefault')
})
it('voice picker shows "Default" option when opened', () => {
renderWithProviders(<SettingsPage />)
// The current value "Default" is shown in the closed picker button
expect(screen.getAllByText('Default').length).toBeGreaterThanOrEqual(1)
})
it('opens voice picker and shows "Default" option in the list', () => {
renderWithProviders(<SettingsPage />)
// Click the voice picker button (aria-label target via the label text)
const voiceButtons = screen.getAllByRole('button', { name: /Default/i })
// Find the button that has aria-haspopup — that's the voice picker trigger
const pickerButton = voiceButtons.find(btn => btn.getAttribute('aria-haspopup') === 'listbox')
expect(pickerButton).toBeTruthy()
fireEvent.click(pickerButton!)
// The listbox should appear with a "Default" option inside
const listbox = screen.getByRole('listbox', { name: 'Voice' })
expect(listbox).toBeInTheDocument()
expect(listbox.textContent).toContain('Default')
})
it('shows dynamic voices in picker when fetchVoices returns a list', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
{ id: 'dyn2', name: 'Bob', previewUrl: 'https://example.com/bob.mp3', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
})
it('shows a disabled preview button with tooltip when previewUrl is missing', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const previewButton = await screen.findByLabelText('Preview voice')
expect(previewButton).toBeDisabled()
expect(previewButton).toHaveAttribute('title', 'Preview unavailable without an ElevenLabs API key')
})
it('shows a play button for voices with a previewUrl', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
await screen.findByText('Alice')
expect(screen.getByLabelText('Preview voice')).toBeInTheDocument()
expect(screen.getByLabelText('Preview voice')).not.toBeDisabled()
})
it('stops preview audio on unmount', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' },
])
const pause = vi.fn()
const play = vi.fn(() => Promise.resolve())
const addEventListener = vi.fn()
class MockAudio {
pause = pause
play = play
addEventListener = addEventListener
constructor(_url: string) {}
}
const OriginalAudio = globalThis.Audio
const OriginalWindowAudio = window.Audio
// @ts-expect-error test override
globalThis.Audio = MockAudio
// @ts-expect-error test override
window.Audio = MockAudio
const view = renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const aliceLabel = await screen.findByText('Alice')
const optionRow = aliceLabel.closest('[role="option"]')
expect(optionRow).toBeTruthy()
const enabledPreview = optionRow?.querySelector('button[aria-label="Preview voice"]') as HTMLButtonElement | null
expect(enabledPreview).toBeTruthy()
expect(enabledPreview?.disabled).toBe(false)
fireEvent.click(enabledPreview as HTMLElement)
view.unmount()
expect(pause).toHaveBeenCalled()
globalThis.Audio = OriginalAudio
window.Audio = OriginalWindowAudio
})
it('selecting a voice calls localStorage.setItem with the voice id', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const alice = await screen.findByText('Alice')
fireEvent.click(alice)
expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-elevenlabs', 'dyn1')
})
it('shows Gemini static voices when hub backend is gemini-live', async () => {
mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] })
renderWithProviders(<SettingsPage />)
const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
await waitFor(() => {
expect(screen.getByText('Puck')).toBeInTheDocument()
expect(screen.getByText('Aoede')).toBeInTheDocument()
expect(screen.getByText('Conversational, friendly')).toBeInTheDocument()
})
expect(mockFetchVoices).not.toHaveBeenCalled()
})
it('shows static catalog hint when Gemini backend is selected', async () => {
mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] })
renderWithProviders(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('Open the list to see voice character notes. Audio preview is ElevenLabs only.')).toBeInTheDocument()
})
})
it('persists Gemini voice selection under gemini storage key', async () => {
mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] })
renderWithProviders(<SettingsPage />)
const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const puck = await screen.findByText('Puck')
fireEvent.click(puck)
expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-gemini', 'Puck')
})
it('shows backend chooser when hub has multiple configured backends', async () => {
mockFetchVoiceBackend.mockResolvedValue({
backend: 'gemini-live',
backends: ['elevenlabs', 'gemini-live']
})
;(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
if (key === 'hapi-voice-backend') return 'elevenlabs'
return null
})
renderWithProviders(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('Voice backend')).toBeInTheDocument()
expect(screen.getByText('Voice Assistant')).toBeInTheDocument()
expect(screen.getByText('Connection & provider')).toBeInTheDocument()
})
const backendButton = screen.getByRole('button', { name: /Voice backend\s*ElevenLabs/i })
fireEvent.click(backendButton)
const listbox = screen.getByRole('listbox', { name: 'Voice backend' })
expect(listbox.textContent).toContain('Gemini Live')
})
it('switches to ElevenLabs voices when backend chooser selects elevenlabs', async () => {
mockFetchVoiceBackend.mockResolvedValue({
backend: 'gemini-live',
backends: ['elevenlabs', 'gemini-live']
})
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const backendButton = await screen.findByRole('button', { name: /Voice backend/i })
fireEvent.click(backendButton)
fireEvent.click(screen.getByRole('option', { name: /ElevenLabs/i }))
const voicePickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(voicePickerButton)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-backend', 'elevenlabs')
}) })
}) })
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/lib/use-translation'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { SettingsNav } from '@/components/settings/SettingsNav'
import { getSettingsCategory } from './categories'
function BackIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-5 w-5" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
)
}
export default function SettingsLayout() {
const { t } = useTranslation()
const goBack = useAppGoBack()
const navigate = useNavigate()
const pathname = useLocation({ select: (location) => location.pathname })
const category = getSettingsCategory(pathname)
const mobileTitleKey = pathname === '/settings/voice/voices'
? 'settings.voice.voice'
: pathname === '/settings/voice/advanced'
? 'settings.voice.advanced.title'
: category?.titleKey ?? 'settings.title'
const mobileTitle = t(mobileTitleKey)
return (
<div className="flex h-full min-h-0 flex-col bg-[var(--app-bg)]">
<header className="shrink-0 border-b border-[var(--app-border)] bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto flex w-full max-w-content items-center gap-2 p-3">
<button type="button" onClick={goBack} aria-label={t('common.back')} className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] lg:hidden">
<BackIcon />
</button>
<button type="button" onClick={() => navigate({ to: '/sessions' })} aria-label={t('common.back')} className="hidden h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] lg:flex">
<BackIcon />
</button>
<div className="min-w-0 flex-1 font-semibold">
<h1 className="truncate lg:hidden">{mobileTitle}</h1>
<span className="hidden lg:inline">{t('settings.title')}</span>
</div>
</div>
</header>
<div className="min-h-0 flex-1">
<div className="mx-auto flex h-full w-full max-w-content min-h-0">
<aside className="hidden w-56 shrink-0 border-r border-[var(--app-border)] lg:block">
<SettingsNav activeId={category?.id ?? 'display'} />
</aside>
<main className="app-scroll-y min-w-0 flex-1">
<Outlet />
</main>
</div>
</div>
</div>
)
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import { useVoiceSettings } from './useVoiceSettings'
const { fetchVoiceBackend, fetchVoices, pause, play } = vi.hoisted(() => ({
fetchVoiceBackend: vi.fn(),
fetchVoices: vi.fn(),
pause: vi.fn(),
play: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({ api: {} }),
}))
vi.mock('@/api/voice', () => ({
fetchVoiceBackend,
fetchVoices,
}))
function Wrapper(props: { children: React.ReactNode }) {
return <I18nProvider>{props.children}</I18nProvider>
}
describe('useVoiceSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
fetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] })
fetchVoices.mockResolvedValue([])
class MockAudio {
constructor(_source: string) {}
play = play
pause = pause
addEventListener = vi.fn()
}
vi.stubGlobal('Audio', MockAudio)
})
it('loads dynamic voices and keeps the existing per-backend storage key', async () => {
fetchVoices.mockResolvedValue([
{ id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' },
])
const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1'))
act(() => result.current.setVoice('dynamic-1'))
expect(localStorage.getItem('hapi-voice-elevenlabs')).toBe('dynamic-1')
})
it('switches configured backends and restores the backend-specific selection', async () => {
localStorage.setItem('hapi-voice-elevenlabs', 'eleven-1')
fetchVoiceBackend.mockResolvedValue({
backend: 'gemini-live',
backends: ['gemini-live', 'elevenlabs'],
})
const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.backend).toBe('gemini-live'))
act(() => result.current.setBackend('elevenlabs'))
expect(result.current.backend).toBe('elevenlabs')
expect(result.current.voiceId).toBe('eleven-1')
expect(localStorage.getItem('hapi-voice-backend')).toBe('elevenlabs')
})
it('stops a playing preview when the picker unmounts', async () => {
fetchVoices.mockResolvedValue([
{ id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' },
])
const { result, unmount } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1'))
act(() => result.current.previewVoice(result.current.voices[0]))
expect(play).toHaveBeenCalledOnce()
unmount()
expect(pause).toHaveBeenCalled()
})
})
+121
View File
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppContext } from '@/lib/app-context'
import { useTranslation } from '@/lib/use-translation'
import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice'
import { getFallbackVoices } from '@/lib/voices'
import { getElevenLabsSupportedLanguages, type Language } from '@/lib/languages'
import {
getStaticVoiceOptions,
readStoredVoiceSelection,
resolveSelectedVoiceBackend,
writeStoredVoiceBackendPreference,
writeStoredVoiceSelection,
} from '@/lib/voicePickerPreferences'
import type { VoiceBackendType } from '@hapi/protocol/voice'
export function useVoiceSettings() {
const { api } = useAppContext()
const { locale } = useTranslation()
const [configuredBackends, setConfiguredBackends] = useState<VoiceBackendType[]>([])
const [backend, setBackendState] = useState<VoiceBackendType | null>(null)
const [voiceId, setVoiceIdState] = useState<string | null>(null)
const [dynamicVoices, setDynamicVoices] = useState<VoiceInfo[] | null>(null)
const [voiceLanguage, setVoiceLanguageState] = useState<string | null>(() => localStorage.getItem('hapi-voice-lang'))
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
let cancelled = false
fetchVoiceBackend(api).then((response) => {
if (cancelled) return
setConfiguredBackends(response.backends)
const selected = resolveSelectedVoiceBackend(response.backends, response.backend)
setBackendState(selected)
setVoiceIdState(readStoredVoiceSelection(selected))
}).catch(() => {
if (cancelled) return
setConfiguredBackends(['elevenlabs'])
setBackendState('elevenlabs')
setVoiceIdState(readStoredVoiceSelection('elevenlabs'))
})
return () => { cancelled = true }
}, [api])
useEffect(() => {
if (backend !== 'elevenlabs') {
setDynamicVoices(null)
return
}
let cancelled = false
fetchVoices(api).then((voices) => {
if (!cancelled) setDynamicVoices(voices.length > 0 ? voices : null)
}).catch(() => {
if (!cancelled) setDynamicVoices(null)
})
return () => { cancelled = true }
}, [api, backend])
const voices = useMemo<VoiceInfo[]>(() => {
const fallback = getFallbackVoices(locale)
if (backend === 'elevenlabs') {
return dynamicVoices ?? fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' }))
}
if (backend === 'gemini-live' || backend === 'qwen-realtime') {
return getStaticVoiceOptions(backend).map((voice) => ({ id: voice.id, name: voice.label, description: voice.description, previewUrl: '', category: 'premade' }))
}
return fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' }))
}, [backend, dynamicVoices, locale])
const setBackend = useCallback((value: VoiceBackendType) => {
writeStoredVoiceBackendPreference(value)
setBackendState(value)
setVoiceIdState(readStoredVoiceSelection(value))
}, [])
const setVoice = useCallback((value: string | null) => {
setVoiceIdState(value)
writeStoredVoiceSelection(backend ?? 'elevenlabs', value)
}, [backend])
const setVoiceLanguage = useCallback((language: Language) => {
setVoiceLanguageState(language.code)
if (language.code === null) localStorage.removeItem('hapi-voice-lang')
else localStorage.setItem('hapi-voice-lang', language.code)
}, [])
const stopPreview = useCallback(() => {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoiceId(null)
}, [])
const previewVoice = useCallback((voice: VoiceInfo) => {
if (!voice.previewUrl || backend !== 'elevenlabs') return
if (playingVoiceId === voice.id) {
stopPreview()
return
}
stopPreview()
const audio = new Audio(voice.previewUrl)
audioRef.current = audio
setPlayingVoiceId(voice.id)
audio.play().catch(stopPreview)
audio.addEventListener('ended', stopPreview, { once: true })
}, [backend, playingVoiceId, stopPreview])
useEffect(() => stopPreview, [stopPreview])
return {
configuredBackends,
backend,
setBackend,
voiceId,
setVoice,
voices,
voiceLanguage,
setVoiceLanguage,
voiceLanguages: getElevenLabsSupportedLanguages(),
playingVoiceId,
previewVoice,
}
}
@@ -0,0 +1,22 @@
import { useTranslation } from '@/lib/use-translation'
import { VoiceDiagnosticsControls, VoicePersonaControls, VoiceSoundsControls } from '@/components/settings/VoiceAdvancedControls'
import { SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { useVoiceSettings } from './useVoiceSettings'
export default function SettingsVoiceAdvancedPage() {
const { t } = useTranslation()
const { backend } = useVoiceSettings()
return (
<SettingsPageContent title={t('settings.voice.advanced.title')} description={t('settings.voice.advanced.hint')}>
<SettingsSection title={t('settings.voice.persona.title')}>
<VoicePersonaControls t={t} voiceBackend={backend} />
</SettingsSection>
<SettingsSection title={t('settings.voice.sounds.title')}>
<VoiceSoundsControls t={t} voiceBackend={backend} />
</SettingsSection>
<SettingsSection title={t('settings.voice.advanced.section.title')}>
<VoiceDiagnosticsControls t={t} voiceBackend={backend} />
</SettingsSection>
</SettingsPageContent>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useTranslation } from '@/lib/use-translation'
import { CheckIcon, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { useVoiceSettings } from './useVoiceSettings'
function PlayIcon() {
return <svg viewBox="0 0 24 24" fill="currentColor" className="h-3.5 w-3.5" aria-hidden="true"><path d="m6 4 14 8-14 8V4Z" /></svg>
}
function StopIcon() {
return <svg viewBox="0 0 24 24" fill="currentColor" className="h-3.5 w-3.5" aria-hidden="true"><path d="M5 5h14v14H5z" /></svg>
}
export default function SettingsVoiceVoicesPage() {
const { t } = useTranslation()
const voice = useVoiceSettings()
return (
<SettingsPageContent title={t('settings.voice.voice')} description={t('settings.voice.voices.description')}>
<SettingsSection>
<div role="radiogroup" aria-label={t('settings.voice.voice')} className="divide-y divide-[var(--app-divider)]">
<button type="button" role="radio" aria-checked={voice.voiceId === null} onClick={() => voice.setVoice(null)} className={`flex min-h-12 w-full items-center justify-between px-3 py-3 text-left ${voice.voiceId === null ? 'bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}>
{t('settings.voice.voiceDefault')}
{voice.voiceId === null ? <CheckIcon className="h-4 w-4" /> : null}
</button>
{voice.voices.map((option) => {
const selected = voice.voiceId === option.id
const playing = voice.playingVoiceId === option.id
const canPreview = voice.backend === 'elevenlabs' && Boolean(option.previewUrl)
return (
<div key={option.id} className={`flex min-h-14 items-center ${selected ? 'bg-[var(--app-subtle-bg)]' : ''}`}>
<button type="button" role="radio" aria-checked={selected} onClick={() => voice.setVoice(option.id)} className={`min-w-0 flex-1 px-3 py-2.5 text-left ${selected ? 'text-[var(--app-link)]' : 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}>
<span className="flex items-center justify-between gap-2">
<span className="min-w-0">
<span className="block truncate font-medium">{option.name}</span>
{option.description ? <span className="mt-0.5 block text-xs leading-snug text-[var(--app-hint)]">{option.description}</span> : null}
</span>
{selected ? <CheckIcon className="h-4 w-4 shrink-0" /> : null}
</span>
</button>
<button type="button" disabled={!canPreview} onClick={() => voice.previewVoice(option)} aria-label={playing ? t('settings.voice.preview.stop') : t('settings.voice.preview.play')} title={canPreview ? (playing ? t('settings.voice.preview.stop') : t('settings.voice.preview.play')) : t(voice.backend === 'elevenlabs' ? 'settings.voice.preview.unavailable' : 'settings.voice.preview.elevenlabsOnly')} className="flex h-11 w-11 shrink-0 items-center justify-center text-[var(--app-hint)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-30">
{playing ? <StopIcon /> : <PlayIcon />}
</button>
</div>
)
})}
</div>
</SettingsSection>
</SettingsPageContent>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { VOICE_BACKEND_LABELS } from '@hapi/protocol/voicePickerCatalog'
import { getLanguageDisplayName } from '@/lib/languages'
import { useTranslation } from '@/lib/use-translation'
import { VoiceRespondsControls } from '@/components/settings/VoiceAdvancedControls'
import { SettingsChoiceGroup, SettingsLinkRow, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { useVoiceSettings } from './useVoiceSettings'
export default function SettingsVoicePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const voice = useVoiceSettings()
const [opening, setOpening] = useState<'greet' | 'brief'>(() => localStorage.getItem('hapi-voice-proactive') === 'true' ? 'brief' : 'greet')
const selectedLanguage = voice.voiceLanguages.find((language) => language.code === voice.voiceLanguage)
const selectedVoice = voice.voices.find((option) => option.id === voice.voiceId)
const setVoiceOpening = (value: 'greet' | 'brief') => {
setOpening(value)
if (value === 'brief') localStorage.setItem('hapi-voice-proactive', 'true')
else localStorage.removeItem('hapi-voice-proactive')
}
return (
<SettingsPageContent title={t('settings.voice.title')} description={t('settings.voice.description')}>
<SettingsSection title={t('settings.voice.connection.title')} description={t('settings.voice.group.hint')}>
{voice.configuredBackends.length > 1 && voice.backend ? (
<SettingsChoiceGroup
label={t('settings.voice.backend')}
value={voice.backend}
options={voice.configuredBackends.map((backend) => ({ value: backend, label: VOICE_BACKEND_LABELS[backend] }))}
onChange={voice.setBackend}
/>
) : null}
<label className="flex min-h-12 items-center justify-between gap-3 px-3 py-3">
<span className="text-[var(--app-fg)]">{t('settings.voice.language')}</span>
<select
value={voice.voiceLanguage ?? ''}
onChange={(event) => {
const language = voice.voiceLanguages.find((option) => (option.code ?? '') === event.target.value)
if (language) voice.setVoiceLanguage(language)
}}
className="max-w-[55%] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)]"
>
{voice.voiceLanguages.map((language) => <option key={language.code ?? 'auto'} value={language.code ?? ''}>{language.code === null ? t('settings.voice.autoDetect') : getLanguageDisplayName(language)}</option>)}
</select>
</label>
<SettingsLinkRow
label={t('settings.voice.voice')}
value={selectedVoice?.name ?? t('settings.voice.voiceDefault')}
description={selectedLanguage?.code ? getLanguageDisplayName(selectedLanguage) : t('settings.voice.autoDetect')}
onClick={() => navigate({ to: '/settings/voice/voices' })}
/>
</SettingsSection>
<SettingsSection title={t('settings.voice.behaves.title')}>
<SettingsChoiceGroup
label={t('settings.voice.opening.label')}
value={opening}
options={(['greet', 'brief'] as const).map((value) => ({ value, label: t(`settings.voice.opening.${value}`), description: t(`settings.voice.opening.${value}.hint`) }))}
onChange={setVoiceOpening}
/>
<VoiceRespondsControls t={t} voiceBackend={voice.backend} />
</SettingsSection>
<SettingsSection>
<SettingsLinkRow label={t('settings.voice.advanced.title')} description={t('settings.voice.advanced.hint')} onClick={() => navigate({ to: '/settings/voice/advanced' })} />
</SettingsSection>
</SettingsPageContent>
)
}