diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 26772341..c1e47ad2 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,18 +1,14 @@ -import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" -import { canCheckUpdate, canUseDisplayBackend, usePlatform } from "@/context/platform" +import { canUseDisplayBackend, usePlatform } from "@/context/platform" import { monoDefault, monoFontFamily, @@ -23,49 +19,19 @@ import { useSettings, } from "@/context/settings" import { decode64 } from "@/utils/base64" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" -import { DialogConnectWebSearch } from "./dialog-connect-websearch" import { SettingsList } from "./settings-list" - -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} +import { SettingsNotificationsSection } from "./settings-notifications-section" +import { SettingsRow } from "./settings-row" +import { SettingsSoundsSection } from "./settings-sounds-section" +import { SettingsUpdatesSection } from "./settings-updates-section" +import { SettingsWebSearchRow } from "./settings-web-search-row" type ThemeOption = { id: string name: string } -// 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 -} - -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) -} - export const SettingsGeneral: Component = () => { const theme = useTheme() const language = useLanguage() @@ -73,16 +39,11 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const params = useParams() const settings = useSettings() - const dialog = useDialog() onMount(() => { void theme.loadThemes() }) - const [store, setStore] = createStore({ - checking: false, - }) - const linux = createMemo(() => platform.os === "linux" && canUseDisplayBackend(platform)) const dir = createMemo(() => decode64(params.dir)) const accepting = createMemo(() => { @@ -110,96 +71,6 @@ export const SettingsGeneral: Component = () => { permission.disableAutoAccept(params.id, value) } - const check = () => { - const checkUpdate = platform.checkUpdate - if (!canCheckUpdate(platform) || !checkUpdate) return - setStore("checking", true) - - void checkUpdate() - .then((result) => { - if (result.status === "busy") { - showToast({ - title: language.t("settings.updates.toast.busy.title"), - description: language.t("settings.updates.toast.busy.description"), - }) - return - } - - if (result.status === "disabled") { - showToast({ - title: language.t("settings.updates.toast.disabled.title"), - description: language.t("settings.updates.toast.disabled.description"), - }) - return - } - - if (result.status === "failed") { - showToast({ - title: language.t("common.requestFailed"), - description: result.message || language.t("settings.updates.toast.failed.description"), - }) - return - } - - if (!result.updateAvailable) { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("settings.updates.toast.latest.title"), - description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), - }) - return - } - - const actions = platform.update - ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - - showToast({ - persistent: true, - icon: "download", - title: language.t("toast.update.title"), - description: language.t("toast.update.description", { version: result.version ?? "" }), - actions, - }) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setStore("checking", false)) - } - - const [webSearchStatusResource, webSearchStatusActions] = createResource(() => window.api?.webSearchStatus?.()) - const webSearchStatus = createMemo(() => webSearchStatusResource.latest) - // Chip label for the current web search auth state. - const webSearchChipText = createMemo(() => { - const s = webSearchStatus() - if (!s) return language.t("settings.general.webSearch.chip.loading") - if (s.source === "saved" && s.quotaExceeded) return language.t("settings.general.webSearch.chip.savedQuota") - if (s.source === "saved" && s.needsAttention) return language.t("settings.general.webSearch.chip.invalid") - if (s.source === "saved") return language.t("settings.general.webSearch.chip.personal") - if (s.source === "env") return language.t("settings.general.webSearch.chip.env") - if (s.quotaExceeded) return language.t("settings.general.webSearch.chip.exhausted") - return language.t("settings.general.webSearch.chip.free") - }) - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ @@ -215,41 +86,9 @@ export const SettingsGeneral: 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 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] | undefined) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - variant: "secondary" as const, - size: "small" as const, - triggerVariant: "settings" as const, - }) - const GeneralSection = () => (
@@ -279,53 +118,7 @@ export const SettingsGeneral: Component = () => {
- - {language.t("settings.general.webSearch.title")} - - {webSearchChipText()} - - - } - description={ - <> - {language.t("settings.general.webSearch.description")} - {webSearchStatus()?.source === "saved" && webSearchStatus()?.quotaExceeded && ( - - {language.t("settings.general.webSearch.secondary.savedQuota")} - - )} - {webSearchStatus()?.source === "saved" && webSearchStatus()?.needsAttention && ( - - {language.t("settings.general.webSearch.secondary.failed")} - - )} - {webSearchStatus()?.source === "anonymous" && webSearchStatus()?.quotaExceeded && ( - - {language.t("settings.general.webSearch.secondary.exhausted")} - - )} - - } - > -
- -
- settings.general.setWebSearchEnabled(checked)} - /> -
-
-
+ { ) - const NotificationsSection = () => ( -
-

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

- - - -
- settings.notifications.setAgent(checked)} - /> -
-
- - -
- settings.notifications.setPermissions(checked)} - /> -
-
- - -
- settings.notifications.setErrors(checked)} - /> -
-
-
-
- ) - - const SoundsSection = () => ( -
-

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

- - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - /> - - - - settings.sounds.agentEnabled(), + () => settings.sounds.agent(), + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + )} + /> + + + + settings.sounds.errorsEnabled(), + () => settings.sounds.errors(), + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + )} + /> + + +
+ ) +} diff --git a/packages/app/src/components/settings-updates-section.tsx b/packages/app/src/components/settings-updates-section.tsx new file mode 100644 index 00000000..0ffca429 --- /dev/null +++ b/packages/app/src/components/settings-updates-section.tsx @@ -0,0 +1,139 @@ +import { type Component } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Switch } from "@opencode-ai/ui/switch" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +import { canCheckUpdate, usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { SettingsList } from "./settings-list" +import { SettingsRow } from "./settings-row" + +export const SettingsUpdatesSection: Component = () => { + const language = useLanguage() + const platform = usePlatform() + const settings = useSettings() + const [store, setStore] = createStore({ + checking: false, + }) + + const check = () => { + const checkUpdate = platform.checkUpdate + if (!canCheckUpdate(platform) || !checkUpdate) return + setStore("checking", true) + + void checkUpdate() + .then((result) => { + if (result.status === "busy") { + showToast({ + title: language.t("settings.updates.toast.busy.title"), + description: language.t("settings.updates.toast.busy.description"), + }) + return + } + + if (result.status === "disabled") { + showToast({ + title: language.t("settings.updates.toast.disabled.title"), + description: language.t("settings.updates.toast.disabled.description"), + }) + return + } + + if (result.status === "failed") { + showToast({ + title: language.t("common.requestFailed"), + description: result.message || language.t("settings.updates.toast.failed.description"), + }) + return + } + + if (!result.updateAvailable) { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.updates.toast.latest.title"), + description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), + }) + return + } + + const actions = platform.update + ? [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + }, + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + : [ + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + + showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: result.version ?? "" }), + actions, + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("checking", false)) + } + + return ( +
+

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

+ + + +
+ settings.updates.setStartup(checked)} + /> +
+
+ + +
+ settings.general.setReleaseNotes(checked)} + /> +
+
+ + + + +
+
+ ) +} diff --git a/packages/app/src/components/settings-web-search-row.tsx b/packages/app/src/components/settings-web-search-row.tsx new file mode 100644 index 00000000..718df157 --- /dev/null +++ b/packages/app/src/components/settings-web-search-row.tsx @@ -0,0 +1,75 @@ +import { createMemo, createResource, type Component } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Switch } from "@opencode-ai/ui/switch" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { DialogConnectWebSearch } from "./dialog-connect-websearch" +import { SettingsRow } from "./settings-row" + +export const SettingsWebSearchRow: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const settings = useSettings() + + const [webSearchStatusResource, webSearchStatusActions] = createResource(() => window.api?.webSearchStatus?.()) + const webSearchStatus = createMemo(() => webSearchStatusResource.latest) + const webSearchChipText = createMemo(() => { + const status = webSearchStatus() + if (!status) return language.t("settings.general.webSearch.chip.loading") + if (status.source === "saved" && status.quotaExceeded) return language.t("settings.general.webSearch.chip.savedQuota") + if (status.source === "saved" && status.needsAttention) return language.t("settings.general.webSearch.chip.invalid") + if (status.source === "saved") return language.t("settings.general.webSearch.chip.personal") + if (status.source === "env") return language.t("settings.general.webSearch.chip.env") + if (status.quotaExceeded) return language.t("settings.general.webSearch.chip.exhausted") + return language.t("settings.general.webSearch.chip.free") + }) + + return ( + + {language.t("settings.general.webSearch.title")} + {webSearchChipText()} + + } + description={ + <> + {language.t("settings.general.webSearch.description")} + {webSearchStatus()?.source === "saved" && webSearchStatus()?.quotaExceeded && ( + + {language.t("settings.general.webSearch.secondary.savedQuota")} + + )} + {webSearchStatus()?.source === "saved" && webSearchStatus()?.needsAttention && ( + + {language.t("settings.general.webSearch.secondary.failed")} + + )} + {webSearchStatus()?.source === "anonymous" && webSearchStatus()?.quotaExceeded && ( + + {language.t("settings.general.webSearch.secondary.exhausted")} + + )} + + } + > +
+ +
+ settings.general.setWebSearchEnabled(checked)} + /> +
+
+
+ ) +} diff --git a/packages/app/src/pages/session/settings-websearch-source.test.ts b/packages/app/src/pages/session/settings-websearch-source.test.ts index b41e3bec..f6b528ef 100644 --- a/packages/app/src/pages/session/settings-websearch-source.test.ts +++ b/packages/app/src/pages/session/settings-websearch-source.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs" const settingsSource = readFileSync(new URL("../../context/settings.tsx", import.meta.url), "utf8") const generalSource = readFileSync(new URL("../../components/settings-general.tsx", import.meta.url), "utf8") +const webSearchRowSource = readFileSync(new URL("../../components/settings-web-search-row.tsx", import.meta.url), "utf8") const appSource = readFileSync(new URL("../../app.tsx", import.meta.url), "utf8") const enSource = readFileSync(new URL("../../i18n/en.ts", import.meta.url), "utf8") const zhSource = readFileSync(new URL("../../i18n/zh.ts", import.meta.url), "utf8") @@ -16,10 +17,11 @@ describe("settings web search source contract", () => { }) test("renders the General Web Search controls without persisting API key input in settings state", () => { - expect(generalSource).toContain('data-action="settings-web-search-enabled"') - expect(generalSource).toContain('data-action="settings-web-search-manage"') - expect(generalSource).toContain("DialogConnectWebSearch") - expect(generalSource).not.toContain("savingExaKey") + expect(generalSource).toContain("") + expect(webSearchRowSource).toContain('data-action="settings-web-search-enabled"') + expect(webSearchRowSource).toContain('data-action="settings-web-search-manage"') + expect(webSearchRowSource).toContain("DialogConnectWebSearch") + expect(webSearchRowSource).not.toContain("savingExaKey") expect(settingsSource).not.toContain("exaApiKey") }) @@ -62,16 +64,16 @@ describe("settings web search source contract", () => { }) test("refreshes the General row status after dialog credential changes", () => { - expect(generalSource).toContain("webSearchStatusActions") - expect(generalSource).toContain("onStatusChanged={webSearchStatusActions.refetch}") - expect(generalSource).toContain("settings.general.webSearch.chip.savedQuota") + expect(webSearchRowSource).toContain("webSearchStatusActions") + expect(webSearchRowSource).toContain("onStatusChanged={webSearchStatusActions.refetch}") + expect(webSearchRowSource).toContain("settings.general.webSearch.chip.savedQuota") }) test("prioritizes saved quota over saved invalid-key attention in General status copy", () => { - const quotaChip = generalSource.indexOf("settings.general.webSearch.chip.savedQuota") - const invalidChip = generalSource.indexOf("settings.general.webSearch.chip.invalid") - const quotaSecondary = generalSource.indexOf("settings.general.webSearch.secondary.savedQuota") - const failedSecondary = generalSource.indexOf("settings.general.webSearch.secondary.failed") + const quotaChip = webSearchRowSource.indexOf("settings.general.webSearch.chip.savedQuota") + const invalidChip = webSearchRowSource.indexOf("settings.general.webSearch.chip.invalid") + const quotaSecondary = webSearchRowSource.indexOf("settings.general.webSearch.secondary.savedQuota") + const failedSecondary = webSearchRowSource.indexOf("settings.general.webSearch.secondary.failed") expect(quotaChip).toBeGreaterThan(-1) expect(invalidChip).toBeGreaterThan(-1) diff --git a/packages/app/src/pages/update-install-flow-source.test.ts b/packages/app/src/pages/update-install-flow-source.test.ts index e6c3fc7e..b690a824 100644 --- a/packages/app/src/pages/update-install-flow-source.test.ts +++ b/packages/app/src/pages/update-install-flow-source.test.ts @@ -3,24 +3,24 @@ import { describe, expect, test } from "bun:test" const layout = readFileSync(new URL("./layout.tsx", import.meta.url), "utf8") const errorPage = readFileSync(new URL("./error.tsx", import.meta.url), "utf8") -const settings = readFileSync(new URL("../components/settings-general.tsx", import.meta.url), "utf8") +const settingsUpdates = readFileSync(new URL("../components/settings-updates-section.tsx", import.meta.url), "utf8") const platform = readFileSync(new URL("../context/platform.tsx", import.meta.url), "utf8") describe("update install renderer contracts", () => { test("renderer install actions do not relaunch after platform update", () => { expect(layout).not.toMatch(/await\s+platform\.restart!\(\)/) - expect(settings).not.toMatch(/await\s+platform\.restart!\(\)/) + expect(settingsUpdates).not.toMatch(/await\s+platform\.restart!\(\)/) expect(errorPage).not.toMatch(/await\s+platform\.restart!\(\)/) expect(layout).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) - expect(settings).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) + expect(settingsUpdates).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) expect(errorPage).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) }) test("renderer update prompts only require platform update", () => { expect(layout).toContain("if (!platform.checkUpdate || !platform.update) return") expect(layout).not.toContain("if (!platform.checkUpdate || !platform.update || !platform.restart) return") - expect(settings).toContain("platform.update") - expect(settings).not.toContain("platform.update && platform.restart") + expect(settingsUpdates).toContain("platform.update") + expect(settingsUpdates).not.toContain("platform.update && platform.restart") }) test("cache update failures are part of the renderer-facing type", () => {