From e7151ac61f75c8cbac32cf342203842042aec1e1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 27 Jul 2026 16:19:29 +0800 Subject: [PATCH 1/3] refactor(app): extract v2 settings controllers --- .../general-controller-behavior.ts | 73 ++ .../settings-v2/general-controllers.test.ts | 55 ++ .../settings-v2/general-controllers.ts | 198 +++++ .../src/components/settings-v2/general.tsx | 695 ++++++++---------- 4 files changed, 633 insertions(+), 388 deletions(-) create mode 100644 packages/app/src/components/settings-v2/general-controller-behavior.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.test.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.ts diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts new file mode 100644 index 000000000000..de44770a4e52 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controller-behavior.ts @@ -0,0 +1,73 @@ +import { onCleanup } from "solid-js" + +export type ShellOption = { + path: string + name: string + acceptable: boolean +} + +export type ShellSelectOption = { + id: string + value: string + label: string +} + +export function createShellOptions(input: { + shells: ShellOption[] + current: string | undefined + automatic: string + terminalOnly: string +}) { + const counts = input.shells.reduce((result, shell) => { + result.set(shell.name, (result.get(shell.name) ?? 0) + 1) + return result + }, new Map()) + const options: ShellSelectOption[] = [ + { id: "auto", value: "", label: input.automatic }, + ...input.shells.map((shell) => { + const ambiguous = (counts.get(shell.name) ?? 0) > 1 + const name = ambiguous ? shell.path : shell.name + return { + id: shell.path, + value: ambiguous ? shell.path : shell.name, + label: shell.acceptable ? name : `${name} (${input.terminalOnly})`, + } + }), + ] + if (input.current && !options.some((option) => option.value === input.current)) { + options.push({ id: input.current, value: input.current, label: input.current }) + } + return options +} + +export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { + let cleanup: (() => void) | undefined + let timeout: ReturnType | undefined + let run = 0 + + const stop = () => { + run += 1 + cleanup?.() + clearTimeout(timeout) + cleanup = undefined + timeout = undefined + } + const play = (id: string | undefined) => { + stop() + if (!id) return + const current = ++run + timeout = setTimeout(() => { + timeout = undefined + void player(id).then((next) => { + if (run === current) { + cleanup = next + return + } + next?.() + }) + }, 100) + } + + onCleanup(stop) + return { play, stop } +} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts new file mode 100644 index 000000000000..5e67fded5a7e --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, vi } from "bun:test" +import { createRoot } from "solid-js" +import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" + +describe("settings v2 controllers", () => { + test("normalizes shell names and preserves an unavailable configured shell", () => { + expect( + createShellOptions({ + shells: [ + { path: "/bin/bash", name: "bash", acceptable: true }, + { path: "/opt/bash", name: "bash", acceptable: false }, + { path: "/bin/zsh", name: "zsh", acceptable: true }, + ], + current: "fish", + automatic: "Automatic", + terminalOnly: "Terminal only", + }), + ).toEqual([ + { id: "auto", value: "", label: "Automatic" }, + { id: "/bin/bash", value: "/bin/bash", label: "/bin/bash" }, + { id: "/opt/bash", value: "/opt/bash", label: "/opt/bash (Terminal only)" }, + { id: "/bin/zsh", value: "zsh", label: "zsh" }, + { id: "fish", value: "fish", label: "fish" }, + ]) + }) + + test("debounces previews and stops owned audio on disposal", async () => { + vi.useFakeTimers() + try { + const played: string[] = [] + const stopped: string[] = [] + const owned = createRoot((dispose) => ({ + dispose, + preview: createSoundPreviewController(async (id) => { + played.push(id ?? "") + return () => stopped.push(id ?? "") + }), + })) + + owned.preview.play("first") + vi.advanceTimersByTime(99) + expect(played).toEqual([]) + + owned.preview.play("second") + vi.advanceTimersByTime(100) + await Promise.resolve() + expect(played).toEqual(["second"]) + + owned.dispose() + expect(stopped).toEqual(["second"]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts new file mode 100644 index 000000000000..4fbbe562c10f --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -0,0 +1,198 @@ +import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { + createShellOptions, + createSoundPreviewController, + type ShellOption, + type ShellSelectOption, +} from "./general-controller-behavior" + +export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" +export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" + +export function createPermissionScopeController(sessionID: Accessor) { + const permission = usePermission() + const serverSync = useServerSync() + const directory = createMemo(() => { + const id = sessionID() + if (!id) return undefined + return serverSync().session.lineage.peek(id)?.session.directory + }) + + return { + accepting: createMemo(() => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return false + return permission.isAutoAccepting(id, dir) + }), + enabled: createMemo(() => !!directory()), + set: (checked: boolean) => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return + if (checked) return permission.enableAutoAccept(id, dir) + permission.disableAutoAccept(id, dir) + }, + } +} + +export function createShellSettingsController() { + const language = useLanguage() + const serverSdk = useServerSDK() + const serverSync = useServerSync() + const [shells] = createResource( + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? [] + return [] as ShellOption[] + }, + { initialValue: [] as ShellOption[] }, + ) + const current = createMemo(() => serverSync().data.config.shell ?? "") + const options = createMemo(() => + createShellOptions({ + shells: shells.latest, + current: current(), + automatic: language.t("settings.general.row.shell.autoDefault"), + terminalOnly: language.t("settings.general.row.shell.terminalOnly"), + }), + ) + + return { + current: createMemo(() => options().find((option) => option.value === current()) ?? options()[0]), + options, + select: (option: ShellSelectOption | null) => { + if (!option || option.value === current()) return + void serverSync().updateConfig({ shell: option.value }) + }, + } +} + +export function createAppearanceSettingsController() { + const language = useLanguage() + const settings = useSettings() + const theme = useTheme() + const schemes = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + onMount(() => void theme.loadThemes()) + + return { + scheme: { + options: schemes, + current: createMemo(() => schemes().find((option) => option.value === theme.colorScheme())), + select: (option: { value: ColorScheme } | null) => option && theme.setColorScheme(option.value), + }, + theme: { + options: themes, + current: createMemo(() => themes().find((option) => option.id === theme.themeId())), + select: (option: { id: string } | null) => option && theme.setTheme(option.id), + }, + fonts: { + ui: createMemo(() => ({ + value: sansInput(settings.appearance.uiFont()), + family: sansFontFamily(settings.appearance.uiFont()), + placeholder: sansDefault, + })), + code: createMemo(() => ({ + value: monoInput(settings.appearance.font()), + family: monoFontFamily(settings.appearance.font()), + placeholder: monoDefault, + })), + terminal: createMemo(() => ({ + value: terminalInput(settings.appearance.terminalFont()), + family: terminalFontFamily(settings.appearance.terminalFont()), + placeholder: terminalDefault, + })), + setUI: (value: string) => settings.appearance.setUIFont(value), + setCode: (value: string) => settings.appearance.setFont(value), + setTerminal: (value: string) => settings.appearance.setTerminalFont(value), + }, + } +} + +const noneSound = { id: "none", label: "sound.option.none" } as const +export const soundOptions = [noneSound, ...SOUND_OPTIONS] +export type SoundSelectOption = (typeof soundOptions)[number] + +export function createSoundSettingsController() { + const language = useLanguage() + const settings = useSettings() + const preview = createSoundPreviewController(playSoundById) + const channel = ( + enabled: Accessor, + current: Accessor, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + current: createMemo(() => + enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, + ), + highlight: (option: SoundSelectOption | undefined) => { + if (!option) return + preview.play(option.id === "none" ? undefined : option.id) + }, + select: (option: SoundSelectOption | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + preview.stop() + return + } + setEnabled(true) + set(option.id) + preview.play(option.id) + }, + }) + + return { + options: soundOptions, + label: (option: SoundSelectOption) => language.t(option.label), + agent: channel( + settings.sounds.agentEnabled, + settings.sounds.agent, + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + ), + permissions: channel( + settings.sounds.permissionsEnabled, + settings.sounds.permissions, + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + ), + errors: channel( + settings.sounds.errorsEnabled, + settings.sounds.errors, + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + ), + } +} + +export type PermissionScopeController = ReturnType +export type ShellSettingsController = ReturnType +export type AppearanceSettingsController = ReturnType +export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 613ac96cbae8..b18cd0cacc23 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,182 +1,346 @@ -import { Component, Show, createMemo, createResource, onMount } from "solid-js" +import { Component, Show, createMemo, createResource, type Accessor } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "../updater-action" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { useSettings } from "@/context/settings" import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" +import { + createAppearanceSettingsController, + createPermissionScopeController, + createShellSettingsController, + createSoundSettingsController, + type AppearanceSettingsController, + type PermissionScopeController, + type ShellSelectOption, + type ShellSettingsController, + type SoundSelectOption, + type SoundSettingsController, +} from "./general-controllers" import "./settings-v2.css" -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, +const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { + const language = useLanguage() + return ( + props.controller.set(checked)} + /> + ) } -type ThemeOption = { - id: string - name: string -} +const PermissionScopeSettingView: Component<{ + title: string + description: string + checked: Accessor + enabled: Accessor + onChange: (checked: boolean) => void +}> = (props) => ( + +
+ +
+
+) -type ShellOption = { - path: string - name: string - acceptable: boolean +const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { + const language = useLanguage() + return ( + props.controller.select(option)} + /> + ) } -type ShellSelectOption = { - id: string - value: string - label: string +const ShellSettingView: Component<{ + title: string + description: string + options: Accessor + current: Accessor + onSelect: (option: ShellSelectOption | null) => void +}> = (props) => ( + + option.id} + label={(option) => option.label} + onSelect={props.onSelect} + /> + +) + +const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { + const language = useLanguage() + return ( + + ) } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined +type AppearanceSectionViewProps = { + t: ReturnType["t"] + schemeOptions: AppearanceSettingsController["scheme"]["options"] + currentScheme: AppearanceSettingsController["scheme"]["current"] + onSchemeSelect: AppearanceSettingsController["scheme"]["select"] + themeOptions: AppearanceSettingsController["theme"]["options"] + currentTheme: AppearanceSettingsController["theme"]["current"] + onThemeSelect: AppearanceSettingsController["theme"]["select"] + uiFont: AppearanceSettingsController["fonts"]["ui"] + codeFont: AppearanceSettingsController["fonts"]["code"] + terminalFont: AppearanceSettingsController["fonts"]["terminal"] + onUIFontInput: (value: string) => void + onCodeFontInput: (value: string) => void + onTerminalFontInput: (value: string) => void } -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) +const AppearanceSectionView: Component = (props) => ( +
+

{props.t("settings.general.section.appearance")}

+ + + option.value} + label={(option) => option.label} + onSelect={props.onSchemeSelect} + /> + + + + {props.t("settings.general.row.theme.description")}{" "} + + {props.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={props.onThemeSelect} + /> + + + + + + +
+) + +const FontSettingView: Component<{ + action: string + title: string + description: string + font: Accessor<{ value: string; family: string; placeholder: string }> + onInput: (value: string) => void +}> = (props) => ( + +
+ props.onInput(event.currentTarget.value)} + placeholder={props.font().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={props.title} + style={{ "font-family": props.font().family }} + /> +
+
+) + +const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { + const language = useLanguage() + return ( + props.controller.agent.highlight(option)} + onAgentSelect={(option) => props.controller.agent.select(option)} + permissionsCurrent={props.controller.permissions.current} + onPermissionsHighlight={(option) => props.controller.permissions.highlight(option)} + onPermissionsSelect={(option) => props.controller.permissions.select(option)} + errorsCurrent={props.controller.errors.current} + onErrorsHighlight={(option) => props.controller.errors.highlight(option)} + onErrorsSelect={(option) => props.controller.errors.select(option)} + /> + ) } +const SoundsSectionView: Component<{ + t: ReturnType["t"] + options: SoundSelectOption[] + label: (option: SoundSelectOption) => string + agentCurrent: Accessor + onAgentHighlight: (option: SoundSelectOption | undefined) => void + onAgentSelect: (option: SoundSelectOption | null) => void + permissionsCurrent: Accessor + onPermissionsHighlight: (option: SoundSelectOption | undefined) => void + onPermissionsSelect: (option: SoundSelectOption | null) => void + errorsCurrent: Accessor + onErrorsHighlight: (option: SoundSelectOption | undefined) => void + onErrorsSelect: (option: SoundSelectOption | null) => void +}> = (props) => ( +
+

{props.t("settings.general.section.sounds")}

+ + + + + +
+) + +const SoundSettingView: Component<{ + action: string + title: string + description: string + options: SoundSelectOption[] + label: (option: SoundSelectOption) => string + current: Accessor + onHighlight: (option: SoundSelectOption | undefined) => void + onSelect: (option: SoundSelectOption | null) => void +}> = (props) => ( + + option.id} + label={props.label} + onHighlight={props.onHighlight} + onSelect={props.onSelect} + placement="bottom-end" + gutter={6} + /> + +) + export const SettingsGeneralV2: Component<{ sessionID?: string }> = (props) => { - const theme = useTheme() const language = useLanguage() - const permission = usePermission() const platform = usePlatform() const dialog = useDialog() const settings = useSettings() - const serverSync = useServerSync() - const serverSdk = useServerSDK() const mobile = createMediaQuery("(max-width: 767px)") - const updater = useUpdaterAction() - - const dir = createMemo(() => { - if (!props.sessionID) return undefined - return serverSync().session.lineage.peek(props.sessionID)?.session.directory - }) - const accepting = createMemo(() => { - const value = dir() - if (!value || !props.sessionID) return false - return permission.isAutoAccepting(props.sessionID, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value || !props.sessionID) return - - if (checked) { - permission.enableAutoAccept(props.sessionID, value) - return - } - - permission.disableAutoAccept(props.sessionID, value) - } + const permissionScope = createPermissionScopeController(() => props.sessionID) + const shell = createShellSettingsController() + const appearance = createAppearanceSettingsController() + const sounds = createSoundSettingsController() const desktop = createMemo(() => platform.platform === "desktop") - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.shells()).data ?? [] - } - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => desktop() && "getPinchZoomEnabled" in platform, () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync().data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = serverSync().data.config.shell - - const nameCounts = new Map() - for (const s of list) { - nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) - } - - const options = [ - autoOption, - ...list.map((s) => { - const ambiguousName = (nameCounts.get(s.name) || 0) > 1 - const text = ambiguousName ? s.path : s.name - const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` - return { - id: s.path, - // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. - value: ambiguousName ? s.path : s.name, - label, - } - }), - ] - - if (current && !options.some((o) => o.value === current)) { - options.push({ id: current, value: current, label: current }) - } - - return options - }) - const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -184,12 +348,6 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - const languageOptions = createMemo(() => language.locales.map((locale) => ({ value: locale, @@ -197,39 +355,6 @@ export const SettingsGeneralV2: Component<{ })), ) - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - const mono = () => monoInput(settings.appearance.font()) - const sans = () => sansInput(settings.appearance.uiFont()) - const terminal = () => terminalInput(settings.appearance.terminalFont()) - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - }) - const InterfaceSection = () => ( settings.general.dismissNewInterfaceNotice()} /> ) @@ -275,35 +400,9 @@ export const SettingsGeneralV2: Component<{ /> - -
- -
-
+ - - o.value === currentShell()) ?? autoOption} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.label} - onSelect={(option) => { - if (!option) return - if (option.value === currentShell()) return - serverSync().updateConfig({ shell: option.value }) - }} - /> - + ) - const AppearanceSection = () => ( -
-

{language.t("settings.general.section.appearance")}

- - - - o.value === theme.colorScheme())} - placement="bottom-end" - gutter={6} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - o.id === theme.themeId())} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - /> - - - -
- settings.appearance.setUIFont(event.currentTarget.value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.uiFont.title")} - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
-
- - -
- settings.appearance.setFont(event.currentTarget.value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.font.title")} - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
-
- - -
- settings.appearance.setTerminalFont(event.currentTarget.value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.terminalFont.title")} - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
-
-
-
- ) - const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -576,68 +557,6 @@ export const SettingsGeneralV2: Component<{
) - const SoundsSection = () => ( -
-

{language.t("settings.general.section.sounds")}

- - - - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - placement="bottom-end" - gutter={6} - /> - - -
- ) - const UpdatesSection = () => (

{language.t("settings.general.section.updates")}

@@ -659,7 +578,7 @@ export const SettingsGeneralV2: Component<{ title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - + updater.run()}> {language.t(updater.action().label)} @@ -704,11 +623,11 @@ export const SettingsGeneralV2: Component<{ - + - + From 8ac4abf986a6ac81c233e9e47f568142f8b06d07 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 15:39:19 +0800 Subject: [PATCH 2/3] refactor(app): simplify v2 settings views --- .../settings-v2/general-controllers.ts | 41 +- .../src/components/settings-v2/general.tsx | 503 ++++++++---------- 2 files changed, 225 insertions(+), 319 deletions(-) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts index 4fbbe562c10f..ae77fa332f22 100644 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -1,7 +1,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js" import type { ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context" -import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -18,12 +17,7 @@ import { useSettings, } from "@/context/settings" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { - createShellOptions, - createSoundPreviewController, - type ShellOption, - type ShellSelectOption, -} from "./general-controller-behavior" +import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" @@ -56,7 +50,6 @@ export function createPermissionScopeController(sessionID: Accessor serverSync().data.config.shell ?? "") - const options = createMemo(() => - createShellOptions({ - shells: shells.latest, - current: current(), - automatic: language.t("settings.general.row.shell.autoDefault"), - terminalOnly: language.t("settings.general.row.shell.terminalOnly"), - }), - ) return { - current: createMemo(() => options().find((option) => option.value === current()) ?? options()[0]), - options, - select: (option: ShellSelectOption | null) => { - if (!option || option.value === current()) return - void serverSync().updateConfig({ shell: option.value }) + shells: () => shells.latest, + current, + select: (value: string) => { + if (value === current()) return + void serverSync().updateConfig({ shell: value }) }, } } export function createAppearanceSettingsController() { - const language = useLanguage() const settings = useSettings() const theme = useTheme() - const schemes = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) onMount(() => void theme.loadThemes()) return { scheme: { - options: schemes, - current: createMemo(() => schemes().find((option) => option.value === theme.colorScheme())), - select: (option: { value: ColorScheme } | null) => option && theme.setColorScheme(option.value), + current: theme.colorScheme, + select: (value: ColorScheme) => theme.setColorScheme(value), }, theme: { options: themes, @@ -139,7 +117,6 @@ export const soundOptions = [noneSound, ...SOUND_OPTIONS] export type SoundSelectOption = (typeof soundOptions)[number] export function createSoundSettingsController() { - const language = useLanguage() const settings = useSettings() const preview = createSoundPreviewController(playSoundById) const channel = ( @@ -169,8 +146,6 @@ export function createSoundSettingsController() { }) return { - options: soundOptions, - label: (option: SoundSelectOption) => language.t(option.label), agent: channel( settings.sounds.agentEnabled, settings.sounds.agent, diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index b18cd0cacc23..9601c9d507c4 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,4 +1,4 @@ -import { Component, Show, createMemo, createResource, type Accessor } from "solid-js" +import { Component, Show, createMemo, createResource } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" @@ -16,13 +16,13 @@ import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-tran import { createAppearanceSettingsController, createPermissionScopeController, + createShellOptions, createShellSettingsController, createSoundSettingsController, + soundOptions, type AppearanceSettingsController, type PermissionScopeController, - type ShellSelectOption, type ShellSettingsController, - type SoundSelectOption, type SoundSettingsController, } from "./general-controllers" import "./settings-v2.css" @@ -30,295 +30,248 @@ import "./settings-v2.css" const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { const language = useLanguage() return ( - props.controller.set(checked)} - /> + > +
+ +
+ ) } -const PermissionScopeSettingView: Component<{ - title: string - description: string - checked: Accessor - enabled: Accessor - onChange: (checked: boolean) => void -}> = (props) => ( - -
- -
-
-) - const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const language = useLanguage() + const options = createMemo(() => + createShellOptions({ + shells: props.controller.shells(), + current: props.controller.current(), + automatic: language.t("settings.general.row.shell.autoDefault"), + terminalOnly: language.t("settings.general.row.shell.terminalOnly"), + }), + ) return ( - props.controller.select(option)} - /> + > + option.value === props.controller.current()) ?? options()[0]} + placement="bottom-end" + gutter={6} + value={(option) => option.id} + label={(option) => option.label} + onSelect={(option) => option && props.controller.select(option.value)} + /> + ) } -const ShellSettingView: Component<{ - title: string - description: string - options: Accessor - current: Accessor - onSelect: (option: ShellSelectOption | null) => void -}> = (props) => ( - - option.id} - label={(option) => option.label} - onSelect={props.onSelect} - /> - -) - const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { const language = useLanguage() + const schemes = createMemo(() => [ + { value: "system" as const, label: language.t("theme.scheme.system") }, + { value: "light" as const, label: language.t("theme.scheme.light") }, + { value: "dark" as const, label: language.t("theme.scheme.dark") }, + ]) return ( - - ) -} +
+

{language.t("settings.general.section.appearance")}

+ + + option.value === props.controller.scheme.current())} + placement="bottom-end" + gutter={6} + value={(option) => option.value} + label={(option) => option.label} + onSelect={(option) => option && props.controller.scheme.select(option.value)} + /> + -type AppearanceSectionViewProps = { - t: ReturnType["t"] - schemeOptions: AppearanceSettingsController["scheme"]["options"] - currentScheme: AppearanceSettingsController["scheme"]["current"] - onSchemeSelect: AppearanceSettingsController["scheme"]["select"] - themeOptions: AppearanceSettingsController["theme"]["options"] - currentTheme: AppearanceSettingsController["theme"]["current"] - onThemeSelect: AppearanceSettingsController["theme"]["select"] - uiFont: AppearanceSettingsController["fonts"]["ui"] - codeFont: AppearanceSettingsController["fonts"]["code"] - terminalFont: AppearanceSettingsController["fonts"]["terminal"] - onUIFontInput: (value: string) => void - onCodeFontInput: (value: string) => void - onTerminalFontInput: (value: string) => void + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={props.controller.theme.select} + /> + + + + + + +
+ ) } -const AppearanceSectionView: Component = (props) => ( -
-

{props.t("settings.general.section.appearance")}

- - - option.value} - label={(option) => option.label} - onSelect={props.onSchemeSelect} - /> - - - - {props.t("settings.general.row.theme.description")}{" "} - - {props.t("common.learnMore")} - - - } - > - option.id} - label={(option) => option.name} - onSelect={props.onThemeSelect} +const FontSetting: Component<{ + kind: "ui" | "code" | "terminal" + fonts: AppearanceSettingsController["fonts"] +}> = (props) => { + const language = useLanguage() + const config = createMemo( + () => + ({ + ui: { + action: "settings-ui-font", + title: language.t("settings.general.row.uiFont.title"), + description: language.t("settings.general.row.uiFont.description"), + font: props.fonts.ui, + input: props.fonts.setUI, + }, + code: { + action: "settings-code-font", + title: language.t("settings.general.row.font.title"), + description: language.t("settings.general.row.font.description"), + font: props.fonts.code, + input: props.fonts.setCode, + }, + terminal: { + action: "settings-terminal-font", + title: language.t("settings.general.row.terminalFont.title"), + description: language.t("settings.general.row.terminalFont.description"), + font: props.fonts.terminal, + input: props.fonts.setTerminal, + }, + })[props.kind], + ) + return ( + +
+ config().input(event.currentTarget.value)} + placeholder={config().font().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={config().title} + style={{ "font-family": config().font().family }} /> - - - - - - -
-) - -const FontSettingView: Component<{ - action: string - title: string - description: string - font: Accessor<{ value: string; family: string; placeholder: string }> - onInput: (value: string) => void -}> = (props) => ( - -
- props.onInput(event.currentTarget.value)} - placeholder={props.font().placeholder} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={props.title} - style={{ "font-family": props.font().family }} - /> -
-
-) +
+ + ) +} const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { const language = useLanguage() return ( - props.controller.agent.highlight(option)} - onAgentSelect={(option) => props.controller.agent.select(option)} - permissionsCurrent={props.controller.permissions.current} - onPermissionsHighlight={(option) => props.controller.permissions.highlight(option)} - onPermissionsSelect={(option) => props.controller.permissions.select(option)} - errorsCurrent={props.controller.errors.current} - onErrorsHighlight={(option) => props.controller.errors.highlight(option)} - onErrorsSelect={(option) => props.controller.errors.select(option)} - /> +
+

{language.t("settings.general.section.sounds")}

+ + + + + +
) } -const SoundsSectionView: Component<{ - t: ReturnType["t"] - options: SoundSelectOption[] - label: (option: SoundSelectOption) => string - agentCurrent: Accessor - onAgentHighlight: (option: SoundSelectOption | undefined) => void - onAgentSelect: (option: SoundSelectOption | null) => void - permissionsCurrent: Accessor - onPermissionsHighlight: (option: SoundSelectOption | undefined) => void - onPermissionsSelect: (option: SoundSelectOption | null) => void - errorsCurrent: Accessor - onErrorsHighlight: (option: SoundSelectOption | undefined) => void - onErrorsSelect: (option: SoundSelectOption | null) => void -}> = (props) => ( -
-

{props.t("settings.general.section.sounds")}

- - - = (props) => { + const language = useLanguage() + const config = createMemo( + () => + ({ + agent: { + action: "settings-sounds-agent", + title: language.t("settings.general.sounds.agent.title"), + description: language.t("settings.general.sounds.agent.description"), + }, + permissions: { + action: "settings-sounds-permissions", + title: language.t("settings.general.sounds.permissions.title"), + description: language.t("settings.general.sounds.permissions.description"), + }, + errors: { + action: "settings-sounds-errors", + title: language.t("settings.general.sounds.errors.title"), + description: language.t("settings.general.sounds.errors.description"), + }, + })[props.kind], + ) + return ( + + option.id} + label={(option) => language.t(option.label)} + onHighlight={props.channel.highlight} + onSelect={props.channel.select} + placement="bottom-end" + gutter={6} /> - + ) +} + +const LanguageSetting = () => { + const language = useLanguage() + const options = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + return ( + + option.value === language.locale())} + value={(option) => option.value} + label={(option) => option.label} + onSelect={(option) => option && language.setLocale(option.value)} /> - -
-) - -const SoundSettingView: Component<{ - action: string - title: string - description: string - options: SoundSelectOption[] - label: (option: SoundSelectOption) => string - current: Accessor - onHighlight: (option: SoundSelectOption | undefined) => void - onSelect: (option: SoundSelectOption | null) => void -}> = (props) => ( - - option.id} - label={props.label} - onHighlight={props.onHighlight} - onSelect={props.onSelect} - placement="bottom-end" - gutter={6} - /> - -) + + ) +} export const SettingsGeneralV2: Component<{ sessionID?: string @@ -348,13 +301,6 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - const InterfaceSection = () => ( (
- - o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - + From 7218c9bf0fee7a04bbe9f9bc0b0afbb071ea4e98 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 16:04:26 +0800 Subject: [PATCH 3/3] refactor(app): localize settings labels in views --- .../general-controller-behavior.ts | 17 +-- .../settings-v2/general-controllers.test.ts | 12 +- .../src/components/settings-v2/general.tsx | 130 +++++++++--------- 3 files changed, 76 insertions(+), 83 deletions(-) diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts index de44770a4e52..64bd4c118755 100644 --- a/packages/app/src/components/settings-v2/general-controller-behavior.ts +++ b/packages/app/src/components/settings-v2/general-controller-behavior.ts @@ -9,33 +9,30 @@ export type ShellOption = { export type ShellSelectOption = { id: string value: string - label: string + name: string + terminalOnly: boolean } -export function createShellOptions(input: { - shells: ShellOption[] - current: string | undefined - automatic: string - terminalOnly: string -}) { +export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) { const counts = input.shells.reduce((result, shell) => { result.set(shell.name, (result.get(shell.name) ?? 0) + 1) return result }, new Map()) const options: ShellSelectOption[] = [ - { id: "auto", value: "", label: input.automatic }, + { id: "auto", value: "", name: "", terminalOnly: false }, ...input.shells.map((shell) => { const ambiguous = (counts.get(shell.name) ?? 0) > 1 const name = ambiguous ? shell.path : shell.name return { id: shell.path, value: ambiguous ? shell.path : shell.name, - label: shell.acceptable ? name : `${name} (${input.terminalOnly})`, + name, + terminalOnly: !shell.acceptable, } }), ] if (input.current && !options.some((option) => option.value === input.current)) { - options.push({ id: input.current, value: input.current, label: input.current }) + options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) } return options } diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts index 5e67fded5a7e..09c8d21bd538 100644 --- a/packages/app/src/components/settings-v2/general-controllers.test.ts +++ b/packages/app/src/components/settings-v2/general-controllers.test.ts @@ -12,15 +12,13 @@ describe("settings v2 controllers", () => { { path: "/bin/zsh", name: "zsh", acceptable: true }, ], current: "fish", - automatic: "Automatic", - terminalOnly: "Terminal only", }), ).toEqual([ - { id: "auto", value: "", label: "Automatic" }, - { id: "/bin/bash", value: "/bin/bash", label: "/bin/bash" }, - { id: "/opt/bash", value: "/opt/bash", label: "/opt/bash (Terminal only)" }, - { id: "/bin/zsh", value: "zsh", label: "zsh" }, - { id: "fish", value: "fish", label: "fish" }, + { id: "auto", value: "", name: "", terminalOnly: false }, + { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, + { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, + { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, + { id: "fish", value: "fish", name: "fish", terminalOnly: false }, ]) }) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 9601c9d507c4..e50bd316c9ab 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -27,6 +27,48 @@ import { } from "./general-controllers" import "./settings-v2.css" +const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const fontSettings = { + ui: { + action: "settings-ui-font", + title: "settings.general.row.uiFont.title", + description: "settings.general.row.uiFont.description", + font: "ui", + input: "setUI", + }, + code: { + action: "settings-code-font", + title: "settings.general.row.font.title", + description: "settings.general.row.font.description", + font: "code", + input: "setCode", + }, + terminal: { + action: "settings-terminal-font", + title: "settings.general.row.terminalFont.title", + description: "settings.general.row.terminalFont.description", + font: "terminal", + input: "setTerminal", + }, +} as const +const soundSettings = { + agent: { + action: "settings-sounds-agent", + title: "settings.general.sounds.agent.title", + description: "settings.general.sounds.agent.description", + }, + permissions: { + action: "settings-sounds-permissions", + title: "settings.general.sounds.permissions.title", + description: "settings.general.sounds.permissions.description", + }, + errors: { + action: "settings-sounds-errors", + title: "settings.general.sounds.errors.title", + description: "settings.general.sounds.errors.description", + }, +} as const + const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { const language = useLanguage() return ( @@ -51,8 +93,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) createShellOptions({ shells: props.controller.shells(), current: props.controller.current(), - automatic: language.t("settings.general.row.shell.autoDefault"), - terminalOnly: language.t("settings.general.row.shell.terminalOnly"), }), ) return ( @@ -68,7 +108,11 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) placement="bottom-end" gutter={6} value={(option) => option.id} - label={(option) => option.label} + label={(option) => { + if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") + if (!option.terminalOnly) return option.name + return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` + }} onSelect={(option) => option && props.controller.select(option.value)} /> @@ -77,11 +121,6 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { const language = useLanguage() - const schemes = createMemo(() => [ - { value: "system" as const, label: language.t("theme.scheme.system") }, - { value: "light" as const, label: language.t("theme.scheme.light") }, - { value: "dark" as const, label: language.t("theme.scheme.dark") }, - ]) return (

{language.t("settings.general.section.appearance")}

@@ -93,13 +132,16 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> option.value === props.controller.scheme.current())} + options={schemeOptions} + current={schemeOptions.find((option) => option === props.controller.scheme.current())} placement="bottom-end" gutter={6} - value={(option) => option.value} - label={(option) => option.label} - onSelect={(option) => option && props.controller.scheme.select(option.value)} + label={(option) => { + if (option === "system") return language.t("theme.scheme.system") + if (option === "light") return language.t("theme.scheme.light") + return language.t("theme.scheme.dark") + }} + onSelect={(option) => option && props.controller.scheme.select(option)} /> @@ -140,48 +182,23 @@ const FontSetting: Component<{ fonts: AppearanceSettingsController["fonts"] }> = (props) => { const language = useLanguage() - const config = createMemo( - () => - ({ - ui: { - action: "settings-ui-font", - title: language.t("settings.general.row.uiFont.title"), - description: language.t("settings.general.row.uiFont.description"), - font: props.fonts.ui, - input: props.fonts.setUI, - }, - code: { - action: "settings-code-font", - title: language.t("settings.general.row.font.title"), - description: language.t("settings.general.row.font.description"), - font: props.fonts.code, - input: props.fonts.setCode, - }, - terminal: { - action: "settings-terminal-font", - title: language.t("settings.general.row.terminalFont.title"), - description: language.t("settings.general.row.terminalFont.description"), - font: props.fonts.terminal, - input: props.fonts.setTerminal, - }, - })[props.kind], - ) + const config = () => fontSettings[props.kind] return ( - +
config().input(event.currentTarget.value)} - placeholder={config().font().placeholder} + value={props.fonts[config().font]().value} + onInput={(event) => props.fonts[config().input](event.currentTarget.value)} + placeholder={props.fonts[config().font]().placeholder} spellcheck={false} autocorrect="off" autocomplete="off" autocapitalize="off" - aria-label={config().title} - style={{ "font-family": config().font().family }} + aria-label={language.t(config().title)} + style={{ "font-family": props.fonts[config().font]().family }} />
@@ -207,28 +224,9 @@ const SoundSetting: Component<{ channel: SoundSettingsController["agent"] }> = (props) => { const language = useLanguage() - const config = createMemo( - () => - ({ - agent: { - action: "settings-sounds-agent", - title: language.t("settings.general.sounds.agent.title"), - description: language.t("settings.general.sounds.agent.description"), - }, - permissions: { - action: "settings-sounds-permissions", - title: language.t("settings.general.sounds.permissions.title"), - description: language.t("settings.general.sounds.permissions.description"), - }, - errors: { - action: "settings-sounds-errors", - title: language.t("settings.general.sounds.errors.title"), - description: language.t("settings.general.sounds.errors.description"), - }, - })[props.kind], - ) + const config = () => soundSettings[props.kind] return ( - +