Add a Chat settings page for structured native chat (#25685)

* Add a Chat settings page for structured native chat

* Remove unused chat appearance summary translations

* Fix chat settings search entries and preview anchor

* Keep shortcut formatting available in settings sidebar tests

* Clarify the Chat settings page description

* Update Chat settings test and remove unused summary alias
This commit is contained in:
Brennan Benson
2026-10-06 03:43:07 -07:00
committed by GitHub
parent 3ec38b8c6d
commit b75213100b
23 changed files with 519 additions and 76 deletions
@@ -5,8 +5,10 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { AppearanceChatSection } from './AppearanceChatSection'
import { getChatAppearanceEntriesByKey } from './chat-appearance-search'
import { getAppearancePaneSearchEntries } from './appearance-search'
import {
getChatAppearanceEntriesByKey,
getChatAppearanceSearchEntries
} from './chat-appearance-search'
import { matchesSettingsSearch } from './settings-search'
const mocks = vi.hoisted(
@@ -46,7 +48,7 @@ function persistInMock(settings: GlobalSettings) {
})
}
describe('chat appearance settings card', () => {
describe('chat appearance settings controls', () => {
it.each([
{ platform: 'darwin', increase: '⌘=', decrease: '⌘-' },
{ platform: 'win32', increase: 'Ctrl+=', decrease: 'Ctrl+-' },
@@ -142,8 +144,8 @@ describe('chat appearance settings card', () => {
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(2))
expect(mocks.state.settings?.nativeChatAppearance).toEqual({ contrast: 151 })
})
it('indexes each row and width choice in Appearance settings search', () => {
const entries = getAppearancePaneSearchEntries()
it('indexes each row and width choice in Chat settings search', () => {
const entries = getChatAppearanceSearchEntries()
for (const query of [
'Chat',
'Code text size',
@@ -38,7 +38,11 @@ export function AppearanceChatSection({
}
return (
<div className="divide-y divide-border/40">
<SearchableSetting {...entries.textSize} forceVisible={forceVisiblePrimary}>
<SearchableSetting
id={entries.textSize.targetSectionId}
{...entries.textSize}
forceVisible={forceVisiblePrimary}
>
<NumberField
label={entries.textSize.title}
description={entries.textSize.description}
@@ -51,7 +55,11 @@ export function AppearanceChatSection({
onChange={(fontSize) => update({ fontSize })}
/>
</SearchableSetting>
<SearchableSetting {...entries.codeTextSize} forceVisible={forceVisiblePrimary}>
<SearchableSetting
id={entries.codeTextSize.targetSectionId}
{...entries.codeTextSize}
forceVisible={forceVisiblePrimary}
>
<NumberField
label={entries.codeTextSize.title}
description={entries.codeTextSize.description}
@@ -64,7 +72,11 @@ export function AppearanceChatSection({
onChange={(codeFontSize) => update({ codeFontSize })}
/>
</SearchableSetting>
<SearchableSetting {...entries.width} forceVisible={forceVisiblePrimary}>
<SearchableSetting
id={entries.width.targetSectionId}
{...entries.width}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={entries.width.title}
description={entries.width.description}
@@ -78,7 +90,11 @@ export function AppearanceChatSection({
}
/>
</SearchableSetting>
<SearchableSetting {...entries.reset} forceVisible={forceVisiblePrimary}>
<SearchableSetting
id={entries.reset.targetSectionId}
{...entries.reset}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={entries.reset.title}
description={entries.reset.description}
@@ -244,6 +244,16 @@ describe('AppearancePane', () => {
delete (window as unknown as { api?: unknown }).api
})
it('keeps chat appearance controls out of the Appearance pane', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane({
...getDefaultSettings('/tmp'),
experimentalStructuredNativeChat: true
})
expect(appearanceSectionToggle(container, 'chat')).toBeUndefined()
expect(container.textContent).not.toContain('Reset chat appearance')
})
it('shows language as a primary interface control without opening Advanced', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -397,7 +407,7 @@ describe('AppearancePane', () => {
expect(mocks.state.setWorktreeCardMode).toHaveBeenCalledWith('Compact')
})
it('renders the four top-level section rows and no Code & Markdown row when not searching', async () => {
it('renders the three top-level section rows and no Code & Markdown row when not searching', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -580,7 +590,7 @@ describe('AppearancePane', () => {
expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity')
})
it('expands Interface, Terminal, Chat, and Window & Sidebar by default', async () => {
it('expands Interface, Terminal, and Window & Sidebar by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -588,7 +598,7 @@ describe('AppearancePane', () => {
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(expanded).toHaveLength(4)
expect(expanded).toHaveLength(3)
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Interface')
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Terminal')
expect(expanded.map((button) => button.textContent).join(' ')).toContain('Window & Sidebar')
@@ -612,7 +622,7 @@ describe('AppearancePane', () => {
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(stillExpanded).toHaveLength(3)
expect(stillExpanded).toHaveLength(2)
expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain('Interface')
expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain(
'Window & Sidebar'
@@ -1,12 +1,9 @@
import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings'
import { getChatAppearanceSearchEntries, getChatWidthOptions } from './chat-appearance-search'
import type React from 'react'
import { useLayoutEffect, useState } from 'react'
import { AppWindow, MessageSquare, PanelLeft, TerminalSquare } from 'lucide-react'
import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { AppearanceChatSection } from './AppearanceChatSection'
import { AppearanceSection } from './AppearanceSection'
import { AppearanceInterfaceSection } from './AppearanceInterfaceSection'
import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection'
@@ -57,12 +54,11 @@ type AppearancePaneProps = {
warpThemes: UseWarpThemeImportReturn
}
type AppearanceSectionKey = 'interface' | 'terminal' | 'chat' | 'window'
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
const ALL_APPEARANCE_SECTIONS = [
'interface',
'terminal',
'chat',
'window'
] as const satisfies readonly AppearanceSectionKey[]
@@ -129,21 +125,6 @@ export function AppearancePane({
'auto.components.settings.AppearancePane.terminalTitle',
'Terminal'
)
const chatTitle = translate('settings.appearance.chat.title', 'Chat')
const chatMatches = matchesSettingsSearch(searchQuery, [
{ title: chatTitle },
...getChatAppearanceSearchEntries()
])
const chatLabelMatches = matchesSettingsSearch(searchQuery, { title: chatTitle })
const chatAppearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance)
const chatSummary = translate(
'settings.appearance.chat.summary',
'{{fontSize}}px · {{width}} width',
{
fontSize: chatAppearance.fontSize,
width: getChatWidthOptions().find((option) => option.value === chatAppearance.width)?.label
}
)
const windowSidebarTitle = translate(
'auto.components.settings.AppearancePane.windowSidebarTitle',
'Window & Sidebar'
@@ -202,9 +183,7 @@ export function AppearancePane({
? interfaceMatches
: key === 'terminal'
? terminalMatches
: key === 'chat'
? chatMatches
: windowMatches
: windowMatches
}
return openSections.has(key)
}
@@ -275,24 +254,6 @@ export function AppearancePane({
</AppearanceSection>
) : null}
{chatMatches ? (
<AppearanceSection
id="chat"
icon={<MessageSquare aria-hidden="true" />}
title={chatTitle}
summary={chatSummary}
open={isSectionOpen('chat')}
onToggle={() => toggleSection('chat')}
toggleDisabled={isSearching}
>
<AppearanceChatSection
settings={settings}
updateSettings={updateSettings}
forceVisiblePrimary={chatLabelMatches}
/>
</AppearanceSection>
) : null}
{windowMatches ? (
<AppearanceSection
id="window"
@@ -0,0 +1,127 @@
// @vitest-environment happy-dom
import { useRef, useState } from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import { ChatSettingsSection } from './ChatSettingsSection'
import { ActiveSettingsSectionProvider } from './SettingsSection'
import { getChatAppearanceSearchEntries } from './chat-appearance-search'
import { useSettingsRepoScrollEffects } from './use-settings-repo-scroll-effects'
import type { SettingsStoreModel } from './use-settings-store-model'
import type { SettingsInteractionController } from './use-settings-interaction-controller'
import type { SettingsNavigationModel } from './use-settings-navigation-model'
import type { SettingsTerminalModel } from './use-settings-terminal-model'
vi.mock('../../store', () => ({
useAppStore: (selector: (value: { settingsSearchQuery: string }) => unknown) =>
selector({ settingsSearchQuery: '' })
}))
afterEach(cleanup)
function NavigationHarness({
enabled,
initialSection
}: {
enabled: boolean
initialSection: string
}) {
const [activeSectionId, setActiveSectionId] = useState(initialSection)
const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled }
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: enabled,
repos: []
})
const pendingNavSectionRef = useRef<string | null>('chat')
const pendingScrollTargetRef = useRef<string | null>('chat-code-text-size')
const contentScrollRef = useRef<HTMLDivElement>(null)
const pendingScrollTargetWatchRef = useRef(null)
const pendingSubsectionScrollFrameRef = useRef<number | null>(null)
const repoHooksRequestSeqRef = useRef(0)
const model: Pick<
SettingsStoreModel,
| 'activeSectionId'
| 'setActiveSectionId'
| 'pendingNavRequestTick'
| 'setPendingNavRequestTick'
| 'repos'
| 'setRepoHooksMap'
| 'settingsSearchQuery'
| 'setSettingsSearchQuery'
> = {
activeSectionId,
setActiveSectionId,
pendingNavRequestTick: 0,
setPendingNavRequestTick: vi.fn(),
repos: [],
setRepoHooksMap: vi.fn(),
settingsSearchQuery: '',
setSettingsSearchQuery: vi.fn()
}
const interactions = {
contentScrollRef,
pendingNavSectionRef,
pendingScrollTargetRef,
pendingScrollTargetWatchRef,
pendingSubsectionScrollFrameRef,
repoHooksRequestSeqRef
}
const terminal: Pick<SettingsTerminalModel, 'neededRepos'> = { neededRepos: [] }
useSettingsRepoScrollEffects(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Supplies every model field read by this hook; no repos need loading.
model as SettingsStoreModel,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Supplies every interaction ref read by this hook.
interactions as SettingsInteractionController,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook only reads the visible navigation sections and IDs.
{
visibleNavSections: sections,
visibleSectionIds: new Set(sections.map((s) => s.id))
} as SettingsNavigationModel,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook only reads neededRepos; an empty list skips remote loading.
terminal as SettingsTerminalModel
)
return (
<ActiveSettingsSectionProvider value={activeSectionId}>
<output aria-label="Selected settings page">{activeSectionId}</output>
<ChatSettingsSection
settings={settings}
updateSettings={vi.fn()}
searchEntries={getChatAppearanceSearchEntries()}
isMounted
/>
</ActiveSettingsSectionProvider>
)
}
describe('Chat settings deep links', () => {
it('activates Chat and renders the moved row for a deep link', async () => {
render(<NavigationHarness enabled initialSection="appearance" />)
await waitFor(() => {
expect(screen.getByRole('status', { name: 'Selected settings page' }).textContent).toBe(
'chat'
)
})
expect(screen.getByRole('spinbutton', { name: 'Code text size' })).toBeTruthy()
})
it('falls back through the existing navigation rule when a hidden Chat page is selected', async () => {
const { container } = render(<NavigationHarness enabled={false} initialSection="chat" />)
await waitFor(() => {
expect(screen.getByRole('status', { name: 'Selected settings page' }).textContent).toBe(
'agents'
)
})
expect(container.querySelector('#chat')).toBeNull()
})
it('keeps the current visible page for a deep link to hidden Chat', () => {
const { container } = render(<NavigationHarness enabled={false} initialSection="appearance" />)
expect(screen.getByRole('status', { name: 'Selected settings page' }).textContent).toBe(
'appearance'
)
expect(container.querySelector('#chat')).toBeNull()
})
})
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { ChatSettingsSection } from './ChatSettingsSection'
import { ActiveSettingsSectionProvider } from './SettingsSection'
import { getChatAppearanceSearchEntries } from './chat-appearance-search'
import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import { buildCmdJSettingsResults } from '../cmd-j/palette-results'
import { isSettingsNavigationTarget } from '@/lib/settings-navigation-types'
import { getSettingsSectionId, getSettingsScrollTarget } from './settings-navigation-foundations'
const state = vi.hoisted((): { settingsSearchQuery: string; settings: GlobalSettings | null } => ({
settingsSearchQuery: '',
settings: null
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), {
getState: () => state
})
}))
afterEach(cleanup)
beforeEach(() => {
state.settingsSearchQuery = ''
state.settings = null
})
function renderChat(enabled: boolean | undefined) {
const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled }
state.settings = settings
const updateSettings = vi.fn(async (updates: Partial<GlobalSettings>) => {
if (state.settings) {
state.settings = { ...state.settings, ...updates }
}
})
const element = (active = 'chat') => (
<ActiveSettingsSectionProvider value={active}>
<ChatSettingsSection
settings={settings}
updateSettings={updateSettings}
searchEntries={getChatAppearanceSearchEntries()}
isMounted
/>
</ActiveSettingsSectionProvider>
)
return { ...render(element()), element, updateSettings }
}
describe('Chat settings page', () => {
it.each([false, undefined])('is absent with structured chat set to %s', (enabled) => {
const { container } = renderChat(enabled)
expect(container.querySelector('#chat')).toBeNull()
})
it('renders the existing controls under Appearance and writes the same settings', async () => {
const { container, updateSettings } = renderChat(true)
expect(screen.getByRole('heading', { name: 'Chat', level: 2 })).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Appearance', level: 3 })).toBeTruthy()
expect(container.querySelector('button[aria-controls="appearance-section-chat"]')).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Text size' }).getAttribute('value')).toBe('14')
expect(screen.getByRole('spinbutton', { name: 'Code text size' }).getAttribute('value')).toBe(
'12'
)
expect(screen.getByRole('radio', { name: 'Comfortable' })).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: 'Wide' }))
await waitFor(() =>
expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { width: 'wide' } })
)
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
await waitFor(() =>
expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: undefined })
)
})
it('unmounts the page when the opt-in is disabled while it is selected', () => {
const { container, rerender } = renderChat(true)
rerender(
<ActiveSettingsSectionProvider value="chat">
<ChatSettingsSection
settings={{ ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: false }}
updateSettings={vi.fn()}
searchEntries={[]}
isMounted
/>
</ActiveSettingsSectionProvider>
)
expect(container.querySelector('#chat')).toBeNull()
})
it.each(['Chat', 'Appearance'])('shows every row for the %s heading search', (query) => {
state.settingsSearchQuery = query
renderChat(true)
expect(screen.getByRole('spinbutton', { name: 'Text size' })).toBeTruthy()
expect(screen.getByRole('spinbutton', { name: 'Code text size' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reset' })).toBeTruthy()
})
it('indexes Appearance on Chat without adding ambiguous palette rows', () => {
const sections = buildSettingsNavigationMetadata({
isMac: true,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: true,
repos: []
})
const results = buildCmdJSettingsResults(sections)
expect(results.filter((entry) => entry.title === 'Appearance')).toHaveLength(1)
const chatResults = results.filter((entry) => entry.sectionId === 'chat')
expect(chatResults.map((entry) => entry.title)).not.toContain('Appearance')
expect(chatResults.find((entry) => !entry.targetSectionId)?.configKeywords).toEqual(
expect.arrayContaining(['appearance'])
)
})
it('searches a moved row and resolves its deep link within the Chat page', () => {
state.settingsSearchQuery = 'Code text size'
const { container, element, rerender } = renderChat(true)
expect(screen.queryByRole('spinbutton', { name: 'Text size' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Code text size' })).toBeTruthy()
const sections = buildSettingsNavigationMetadata({
isMac: true,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: true,
repos: []
})
const result = buildCmdJSettingsResults(sections).find(
(entry) => entry.sectionId === 'chat' && entry.title === 'Code text size'
)
expect(result?.targetSectionId).toBe('chat-code-text-size')
const target = { pane: 'chat', repoId: null, sectionId: result?.targetSectionId } as const
expect(isSettingsNavigationTarget(target)).toBe(true)
expect(getSettingsSectionId(target.pane, target.repoId, new Map())).toBe('chat')
expect(getSettingsScrollTarget(target.sectionId ?? '', container)?.querySelector('input')).toBe(
screen.getByRole('spinbutton', { name: 'Code text size' })
)
rerender(element('appearance'))
expect(container.querySelector('#chat')).toBeNull()
state.settingsSearchQuery = ''
rerender(element())
for (const entry of getChatAppearanceSearchEntries().filter((entry) => entry.targetSectionId)) {
expect(getSettingsScrollTarget(entry.targetSectionId ?? '', container)).toBeTruthy()
}
})
})
@@ -0,0 +1,49 @@
import { translate } from '@/i18n/i18n'
import { AppearanceChatSection } from './AppearanceChatSection'
import { SettingsSection } from './SettingsSection'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { matchesSettingsSearch } from './settings-search'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { SettingsSearchEntry } from './settings-search'
import { useAppStore } from '../../store'
export function ChatSettingsSection({
settings,
updateSettings,
searchEntries,
isMounted
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
searchEntries: SettingsSearchEntry[]
isMounted: boolean
}): React.JSX.Element | null {
const query = useAppStore((state) => state.settingsSearchQuery)
if (settings.experimentalStructuredNativeChat !== true) {
return null
}
const title = translate('settings.appearance.chat.title', 'Chat')
const appearanceTitle = translate('auto.components.settings.Settings.2b4474780a', 'Appearance')
return (
<SettingsSection
id="chat"
title={title}
description={translate('settings.chat.description', 'Choose how chats look.')}
searchEntries={searchEntries}
>
{isMounted ? (
<div id="chat-appearance" className="space-y-3">
<SettingsSubsectionHeader title={appearanceTitle} />
<AppearanceChatSection
settings={settings}
updateSettings={updateSettings}
forceVisiblePrimary={matchesSettingsSearch(query, [
{ title },
{ title: appearanceTitle }
])}
/>
</div>
) : null}
</SettingsSection>
)
}
@@ -4,6 +4,8 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Bot, GitBranch, Mic, Network, Puzzle } from 'lucide-react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
import type * as ShortcutLabels from '@/hooks/useShortcutLabel'
import { SettingsSidebar } from './SettingsSidebar'
import { TooltipProvider } from '../ui/tooltip'
import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress'
@@ -13,10 +15,14 @@ const mocks = vi.hoisted(() => ({
useSettingsSetupGuideProgress: vi.fn()
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: () => '⌘F',
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }]
}))
vi.mock('@/hooks/useShortcutLabel', async () => {
const actual = await vi.importActual<typeof ShortcutLabels>('@/hooks/useShortcutLabel')
return {
...actual,
useShortcutLabel: () => '⌘F',
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }]
}
})
vi.mock('./settings-setup-guide-progress', () => ({
useSettingsSetupGuideProgress: mocks.useSettingsSetupGuideProgress
@@ -128,6 +134,33 @@ describe('SettingsSidebar', () => {
document.body.innerHTML = ''
})
it.each([false, true])('renders Chat navigation only with the opt-in enabled (%s)', (enabled) => {
const settings = { ...getDefaultSettings('/tmp'), experimentalStructuredNativeChat: enabled }
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: enabled,
repos: []
}).filter((section) => section.group === 'interface')
const container = document.createElement('div')
container.innerHTML = renderToStaticMarkup(
<TooltipProvider>
<SettingsSidebar
activeSectionId="appearance"
settings={settings}
generalGroups={[{ id: 'interface', title: 'Interface', sections }]}
repoSections={[]}
hasRepos={false}
onBack={vi.fn()}
onSelectSection={vi.fn()}
/>
</TooltipProvider>
)
const buttons = Array.from(container.querySelectorAll('button'))
expect(buttons.some((button) => button.textContent === 'Chat')).toBe(enabled)
})
it('applies left sidebar appearance styles to the settings navigation', () => {
const markup = renderSidebar('orchestration', {
...getDefaultSettings('/tmp'),
@@ -1,4 +1,3 @@
import { getChatAppearanceSearchEntries } from './chat-appearance-search'
import type { SettingsSearchEntry } from './settings-search'
import { getTerminalAppearanceSearchEntries } from './terminal-search'
import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search'
@@ -258,8 +257,6 @@ export function getAppearancePaneSearchEntries(
): SettingsSearchEntry[] {
return [
...getAppearanceSectionEntries(),
{ title: translate('settings.appearance.chat.title', 'Chat') },
...getChatAppearanceSearchEntries(),
...getThemeEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTypographyEntries(),
@@ -7,10 +7,12 @@ const getChatAppearanceCatalog = createLocalizedCatalog(
() =>
({
textSize: {
targetSectionId: 'chat-text-size',
title: translate('settings.appearance.chat.textSize', 'Text size'),
keywords: [translate('settings.appearance.chat.title', 'Chat')]
},
codeTextSize: {
targetSectionId: 'chat-code-text-size',
title: translate('settings.appearance.chat.codeTextSize', 'Code text size'),
description: translate(
'settings.appearance.chat.codeTextSizeDescription',
@@ -19,6 +21,7 @@ const getChatAppearanceCatalog = createLocalizedCatalog(
keywords: [translate('settings.appearance.chat.title', 'Chat')]
},
width: {
targetSectionId: 'chat-width',
title: translate('settings.appearance.chat.width', 'Width'),
description: translate(
'settings.appearance.chat.widthDescription',
@@ -30,6 +33,7 @@ const getChatAppearanceCatalog = createLocalizedCatalog(
]
},
reset: {
targetSectionId: 'chat-reset',
title: translate('settings.appearance.chat.resetAppearance', 'Reset chat appearance'),
description: translate(
'settings.appearance.chat.resetDescription',
@@ -58,7 +62,12 @@ export function getChatAppearanceEntriesByKey(shortcuts?: { increase: string; de
}
export function getChatAppearanceSearchEntries(): SettingsSearchEntry[] {
return Object.values(getChatAppearanceEntriesByKey())
return [
{
title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance')
},
...Object.values(getChatAppearanceEntriesByKey())
]
}
export function getChatWidthOptions() {
@@ -1,5 +1,6 @@
import { StatsPane } from '../stats/StatsPane'
import { AppearancePane } from './AppearancePane'
import { ChatSettingsSection } from './ChatSettingsSection'
import { InputPane } from './InputPane'
import { NotificationsPane } from './NotificationsPane'
import { ShortcutsPane } from './ShortcutsPane'
@@ -36,6 +37,18 @@ export function renderAppearanceSettingsSection(context: SettingsRenderContext):
)
}
export function renderChatSettingsSection(context: SettingsRenderContext): React.JSX.Element {
const { model, navigation, view } = context
return (
<ChatSettingsSection
settings={model.settings}
updateSettings={model.updateSettings}
searchEntries={navigation.getSectionSearchEntries('chat')}
isMounted={view.isSectionMounted('chat')}
/>
)
}
export function renderInputSettingsSection(context: SettingsRenderContext): React.JSX.Element {
const { model, navigation } = context
return (
@@ -35,6 +35,7 @@ import {
} from './settings-interface-primary-section-renderers'
import {
renderAppearanceSettingsSection,
renderChatSettingsSection,
renderInputSettingsSection,
renderNotificationsSettingsSection,
renderShortcutsSettingsSection,
@@ -137,6 +138,7 @@ export function renderSettingsPage(context: SettingsRenderContext): React.JSX.El
{renderMobileEmulatorSettingsSection(context)}
{renderFloatingWorkspaceSettingsSection(context)}
{renderAppearanceSettingsSection(context)}
{renderChatSettingsSection(context)}
{renderInputSettingsSection(context)}
{renderNotificationsSettingsSection(context)}
{renderShortcutsSettingsSection(context)}
@@ -7,6 +7,7 @@ export type SettingsNavigationBuildOptions = {
isLocalWindowsHost: boolean
isWindowsTerminalHost: boolean
isWebClient: boolean
experimentalStructuredNativeChat: boolean
managedBrowserCreationEnabled: boolean
mobileEmulatorCreationEnabled: boolean
isDev: boolean
@@ -1,17 +1,19 @@
import { getAppearancePaneSearchEntries } from '@/components/settings/appearance-search'
import { getChatAppearanceSearchEntries } from '@/components/settings/chat-appearance-search'
import { getInputPaneSearchEntries } from '@/components/settings/input-search'
import { getNotificationsPaneSearchEntries } from '@/components/settings/notifications-search'
import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-search'
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
import { translate } from '@/i18n/i18n'
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
import { BarChart3, Bell, Keyboard, Palette, TextCursorInput } from 'lucide-react'
import { BarChart3, Bell, Keyboard, MessageSquare, Palette, TextCursorInput } from 'lucide-react'
import type { SettingsNavigationBuildOptions } from './settings-navigation-build-options'
export function buildInterfaceSettingsSections({
isMac,
isWindows,
isWebClient,
experimentalStructuredNativeChat,
managedBrowserCreationEnabled,
mobileEmulatorCreationEnabled
}: SettingsNavigationBuildOptions): SettingsNavSection[] {
@@ -32,6 +34,18 @@ export function buildInterfaceSettingsSections({
}),
group: 'interface'
},
...(experimentalStructuredNativeChat
? [
{
id: 'chat',
title: translate('settings.appearance.chat.title', 'Chat'),
description: translate('settings.chat.description', 'Choose how chats look.'),
icon: MessageSquare,
searchEntries: getChatAppearanceSearchEntries(),
group: 'interface'
}
]
: []),
{
id: 'input',
title: translate('auto.hooks.useSettingsNavigationMetadata.0c6ee88a5f', 'Input & Editing'),
@@ -1,3 +1,5 @@
import { buildCmdJSettingsResults } from '../components/cmd-j/palette-results'
import { matchesSettingsSearch } from '../components/settings/settings-search'
import { describe, expect, it } from 'vitest'
import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
import type { Repo } from '../../../shared/repo-types'
@@ -31,6 +33,47 @@ function ids(
}
describe('settings navigation metadata', () => {
it.each([false, undefined])(
'omits Chat navigation and search when the opt-in is %s',
(enabled) => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: enabled,
repos: []
})
expect(sections.some((section) => section.id === 'chat')).toBe(false)
expect(buildCmdJSettingsResults(sections).some((result) => result.sectionId === 'chat')).toBe(
false
)
for (const query of ['Code text size', 'Reset chat appearance']) {
expect(
sections.some((section) => matchesSettingsSearch(query, section.searchEntries))
).toBe(false)
}
}
)
it('places Chat directly after Appearance in Interface and moves its search entries', () => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
experimentalStructuredNativeChat: true,
repos: []
})
const appearanceIndex = sections.findIndex((section) => section.id === 'appearance')
const chat = sections[appearanceIndex + 1]
expect(chat.id).toBe('chat')
expect(chat.title).toBe('Chat')
expect(chat.group).toBe('interface')
for (const query of ['Code text size', 'Reset chat appearance']) {
expect(matchesSettingsSearch(query, chat.searchEntries)).toBe(true)
expect(matchesSettingsSearch(query, sections[appearanceIndex].searchEntries)).toBe(false)
}
})
it('puts AI capability panes at the top on desktop', () => {
expect(ids().slice(0, 10)).toEqual([
'agents',
@@ -41,6 +41,7 @@ export function buildSettingsNavigationMetadata({
isLocalWindowsHost = isWindows,
isWindowsTerminalHost = isWindows,
isWebClient,
experimentalStructuredNativeChat = false,
managedBrowserCreationEnabled = !isWebClient,
mobileEmulatorCreationEnabled = !isWebClient,
isDev = import.meta.env.DEV,
@@ -53,6 +54,7 @@ export function buildSettingsNavigationMetadata({
isLocalWindowsHost?: boolean
isWindowsTerminalHost?: boolean
isWebClient: boolean
experimentalStructuredNativeChat?: boolean
managedBrowserCreationEnabled?: boolean
mobileEmulatorCreationEnabled?: boolean
isDev?: boolean
@@ -80,6 +82,7 @@ export function buildSettingsNavigationMetadata({
isLocalWindowsHost,
isWindowsTerminalHost,
isWebClient,
experimentalStructuredNativeChat,
managedBrowserCreationEnabled,
mobileEmulatorCreationEnabled,
isDev,
@@ -119,6 +122,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
] as const
})
)
const experimentalStructuredNativeChat = settings?.experimentalStructuredNativeChat === true
const isMac = isMacUserAgent()
const isWindows = isWindowsUserAgent()
const isWebClient = isWebClientLocation()
@@ -161,6 +165,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
isLocalWindowsHost,
isWindowsTerminalHost,
isWebClient,
experimentalStructuredNativeChat,
managedBrowserCreationEnabled,
mobileEmulatorCreationEnabled,
isDev: import.meta.env.DEV,
@@ -178,6 +183,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
isLocalWindowsHost,
isWindowsTerminalHost,
isWebClient,
experimentalStructuredNativeChat,
managedBrowserCreationEnabled,
mobileEmulatorCreationEnabled,
isLinearConnected,
+4 -2
View File
@@ -174,6 +174,9 @@
"retrying": "Retrying…"
},
"settings": {
"chat": {
"description": "Choose how chats look."
},
"terminal": {
"optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences",
"optionLayoutCompose": "layout uses Option to compose characters like @, €, [, ]",
@@ -230,8 +233,7 @@
"wide": "Wide",
"full": "Full",
"pixels": "px",
"reset": "Reset",
"summary": "{{fontSize}}px · {{width}} width"
"reset": "Reset"
}
},
"browser": {
+4 -2
View File
@@ -11,6 +11,9 @@
"retrying": "Reintentando…"
},
"settings": {
"chat": {
"description": "Elige el aspecto de los chats."
},
"terminal": {
"optionLayoutAlt": "ABC o EE. UU. — Option envía secuencias Alt/Esc",
"optionLayoutCompose": "la distribución usa Option para escribir caracteres como @, €, [, ]",
@@ -67,8 +70,7 @@
"wide": "Amplio",
"full": "Completo",
"pixels": "px",
"reset": "Restablecer",
"summary": "{{fontSize}}px · Ancho {{width}}"
"reset": "Restablecer"
}
},
"browser": {
+4 -2
View File
@@ -110,6 +110,9 @@
"retrying": "Nouvelle tentative…"
},
"settings": {
"chat": {
"description": "Choisissez l’apparence des chats."
},
"terminal": {
"optionLayoutAlt": "ABC ou U.S. — Option envoie des séquences Alt/Esc",
"optionLayoutCompose": "la disposition utilise Option pour composer des caractères comme @, €, [, ]",
@@ -166,8 +169,7 @@
"wide": "Large",
"full": "Pleine",
"pixels": "px",
"reset": "Réinitialiser",
"summary": "{{fontSize}}px · Largeur {{width}}"
"reset": "Réinitialiser"
}
},
"browser": {
+4 -2
View File
@@ -11,6 +11,9 @@
"retrying": "再試行中…"
},
"settings": {
"chat": {
"description": "チャットの外観を選びます。"
},
"terminal": {
"optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します",
"optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します",
@@ -67,8 +70,7 @@
"wide": "広い",
"full": "全幅",
"pixels": "px",
"reset": "リセット",
"summary": "{{fontSize}}px · 幅: {{width}}"
"reset": "リセット"
}
},
"browser": {
+4 -2
View File
@@ -11,6 +11,9 @@
"retrying": "재시도 중…"
},
"settings": {
"chat": {
"description": "채팅 모양을 선택하세요."
},
"terminal": {
"optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다",
"optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다",
@@ -67,8 +70,7 @@
"wide": "넓게",
"full": "전체",
"pixels": "px",
"reset": "초기화",
"summary": "{{fontSize}}px · {{width}} 너비"
"reset": "초기화"
}
},
"browser": {
+4 -2
View File
@@ -11,6 +11,9 @@
"retrying": "正在重试..."
},
"settings": {
"chat": {
"description": "选择聊天的外观。"
},
"terminal": {
"optionLayoutAlt": "ABC 或 U.S. — Option 键发送 Alt/Esc 序列",
"optionLayoutCompose": "此布局使用 Option 键输入 @、€、[、] 等字符",
@@ -67,8 +70,7 @@
"wide": "宽",
"full": "全宽",
"pixels": "px",
"reset": "重置",
"summary": "{{fontSize}}px · {{width}}宽度"
"reset": "重置"
}
},
"browser": {
@@ -20,6 +20,7 @@ const SETTINGS_NAV_TARGETS = [
'git',
'tasks',
'appearance',
'chat',
'input',
'floating-workspace',
'terminal',