From b75213100b5358bedb7e16b48afd30645e54de14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:43:07 -0700 Subject: [PATCH] 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 --- .../settings/AppearanceChatSection.test.tsx | 12 +- .../settings/AppearanceChatSection.tsx | 24 ++- .../settings/AppearancePane.test.tsx | 18 ++- .../components/settings/AppearancePane.tsx | 45 +----- .../ChatSettingsSection.navigation.test.tsx | 127 +++++++++++++++ .../settings/ChatSettingsSection.test.tsx | 147 ++++++++++++++++++ .../settings/ChatSettingsSection.tsx | 49 ++++++ .../settings/SettingsSidebar.test.tsx | 41 ++++- .../components/settings/appearance-search.ts | 3 - .../settings/chat-appearance-search.ts | 11 +- ...-interface-secondary-section-renderers.tsx | 13 ++ .../settings/settings-page-renderer.tsx | 2 + .../settings-navigation-build-options.ts | 1 + .../settings-navigation-interface-sections.ts | 16 +- .../useSettingsNavigationMetadata.test.ts | 43 +++++ .../hooks/useSettingsNavigationMetadata.ts | 6 + src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/settings-navigation-types.ts | 1 + 23 files changed, 519 insertions(+), 76 deletions(-) create mode 100644 src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx create mode 100644 src/renderer/src/components/settings/ChatSettingsSection.test.tsx create mode 100644 src/renderer/src/components/settings/ChatSettingsSection.tsx diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index 7ad07917e96..ba53111824c 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx @@ -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', diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index ccd629151a6..067b0768cb7 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -38,7 +38,11 @@ export function AppearanceChatSection({ } return (
- + update({ fontSize })} /> - + update({ codeFontSize })} /> - + - + { 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('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('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' diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 9ef0888fb7f..f6fd1b426ee 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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({ ) : null} - {chatMatches ? ( - - ) : null} - {windowMatches ? ( ({ + 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('chat') + const pendingScrollTargetRef = useRef('chat-code-text-size') + const contentScrollRef = useRef(null) + const pendingScrollTargetWatchRef = useRef(null) + const pendingSubsectionScrollFrameRef = useRef(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 = { 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 ( + + {activeSectionId} + + + ) +} + +describe('Chat settings deep links', () => { + it('activates Chat and renders the moved row for a deep link', async () => { + render() + 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() + 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() + expect(screen.getByRole('status', { name: 'Selected settings page' }).textContent).toBe( + 'appearance' + ) + expect(container.querySelector('#chat')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx new file mode 100644 index 00000000000..9299cfca1d3 --- /dev/null +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -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) => { + if (state.settings) { + state.settings = { ...state.settings, ...updates } + } + }) + const element = (active = 'chat') => ( + + + + ) + 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( + + + + ) + 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() + } + }) +}) diff --git a/src/renderer/src/components/settings/ChatSettingsSection.tsx b/src/renderer/src/components/settings/ChatSettingsSection.tsx new file mode 100644 index 00000000000..5ab9d60e055 --- /dev/null +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -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) => 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 ( + + {isMounted ? ( +
+ + +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index b852fe6cfe3..8d7a12ef555 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -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('@/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( + + + + ) + 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'), diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 549a644fa5e..3f01e73ab35 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -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(), diff --git a/src/renderer/src/components/settings/chat-appearance-search.ts b/src/renderer/src/components/settings/chat-appearance-search.ts index 134840d029d..a35a08aa912 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -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() { diff --git a/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx b/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx index 2a5b710247b..8643be591df 100644 --- a/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx @@ -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 ( + + ) +} + export function renderInputSettingsSection(context: SettingsRenderContext): React.JSX.Element { const { model, navigation } = context return ( diff --git a/src/renderer/src/components/settings/settings-page-renderer.tsx b/src/renderer/src/components/settings/settings-page-renderer.tsx index 4fb00229072..809cad1b78c 100644 --- a/src/renderer/src/components/settings/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/settings-page-renderer.tsx @@ -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)} diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index b11d040a65b..9ea40e53593 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -7,6 +7,7 @@ export type SettingsNavigationBuildOptions = { isLocalWindowsHost: boolean isWindowsTerminalHost: boolean isWebClient: boolean + experimentalStructuredNativeChat: boolean managedBrowserCreationEnabled: boolean mobileEmulatorCreationEnabled: boolean isDev: boolean diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index 015a8c9c686..3607cf4e394 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -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'), diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 00db574c5e0..1f8df823650 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -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', diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 494e2ec613c..e7ae73e1b2a 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44df977da4a..f9071f05610 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2668b63ebf9..0da94aca473 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 868943016d4..1213c89d445 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2114a6ad839..066c752ceb8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d46c5bbc508..a043ec36ea9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3c032d9624a..3af1cb962ed 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 9aafdc3649c..358f59d29db 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -20,6 +20,7 @@ const SETTINGS_NAV_TARGETS = [ 'git', 'tasks', 'appearance', + 'chat', 'input', 'floating-workspace', 'terminal',