diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts new file mode 100644 index 0000000000..04e303beb3 --- /dev/null +++ b/packages/app/src/context/local-agent.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { resolveAgent } from "./local-agent" + +describe("resolveAgent", () => { + const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] + + test("uses the requested available agent", () => { + expect(resolveAgent(agents, "custom")?.name).toBe("custom") + }) + + test("defaults to build", () => { + expect(resolveAgent(agents)?.name).toBe("build") + expect(resolveAgent(agents, "missing")?.name).toBe("build") + }) + + test("uses the first agent when build is unavailable", () => { + expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom") + }) +}) diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts new file mode 100644 index 0000000000..9a38b10aa1 --- /dev/null +++ b/packages/app/src/context/local-agent.ts @@ -0,0 +1,3 @@ +export function resolveAgent(items: T[], name?: string) { + return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] +} diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a12fd832fd..03505bacc3 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo, startTransition } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" +import { useSettings } from "@/context/settings" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" +import { resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -62,6 +64,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const models = useModels() + const settings = useSettings() const id = createMemo(() => params.id || undefined) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) @@ -88,7 +91,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant?: string | null } }>({ - current: list()[0]?.name, + current: resolveAgent(list())?.name, draft: undefined, last: undefined, }) @@ -107,9 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const pickAgent = (name: string | undefined) => { - const items = list() - if (items.length === 0) return - return items.find((item) => item.name === name) ?? items[0] + return resolveAgent(list(), name) } createEffect(() => { @@ -181,6 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = { list, current() { + if (!settings.visibility.customAgents()) return pickAgent("build") return pickAgent(scope()?.agent ?? store.current) }, set(name: string | undefined) { diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts index ba0161a6cd..961440ec7d 100644 --- a/packages/app/src/context/settings.test.ts +++ b/packages/app/src/context/settings.test.ts @@ -3,6 +3,7 @@ import { isAppUpgrade, layoutTransitionState, maximumSunsetTimeout, + migrateSettings, newLayoutDesignsDefault, nextSunsetCheckDelay, resolveNewLayoutDesigns, @@ -10,6 +11,39 @@ import { shouldEnableNewLayout, } from "./settings" +describe("feature visibility", () => { + test("enables features once for profiles created before the visibility defaults", () => { + expect( + migrateSettings({ + general: { + showFileTree: false, + showSearch: false, + showStatus: false, + showCustomAgents: false, + }, + }), + ).toEqual({ + general: { + showFileTree: true, + showSearch: true, + showStatus: true, + showCustomAgents: true, + featureVisibilityInitialized: true, + }, + }) + }) + + test("preserves preferences after the visibility defaults are initialized", () => { + const value = { + general: { + showFileTree: false, + featureVisibilityInitialized: true, + }, + } + expect(migrateSettings(value)).toBe(value) + }) +}) + describe("layout transition", () => { test("blank profiles default to the new layout", () => { expect(newLayoutDesignsDefault).toBe(true) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index c2b5680418..7bb175c2ef 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" @@ -36,6 +36,7 @@ export interface Settings { mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean + featureVisibilityInitialized?: boolean newInterfaceNoticeDismissed?: boolean shouldDisplayTabsToast?: boolean } @@ -62,6 +63,27 @@ export const newLayoutDesignsDefault = true export const oldInterfaceSunset = new Date(2026, 8, 14) const newLayoutDesignsUpgradeCutoff = "1.17.19" +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function migrateSettings(value: unknown) { + if (!isRecord(value)) return value + const general = isRecord(value.general) ? value.general : {} + if (general.featureVisibilityInitialized === true) return value + return { + ...value, + general: { + ...general, + showFileTree: true, + showSearch: true, + showStatus: true, + showCustomAgents: true, + featureVisibilityInitialized: true, + }, + } +} + function compareVersions(a: string, b: string) { const parse = (version: string) => { const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim()) @@ -220,7 +242,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont gate: false, init: () => { const platform = usePlatform() - const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + const [store, setStore, _, ready] = persisted( + { key: "settings.v3", migrate: migrateSettings }, + createStore(defaultSettings), + ) const [launch, setLaunch, , launchReady] = persisted( "app-version.v1", createStore<{ version?: string }>({ version: undefined }), @@ -266,7 +291,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault, ) }) - const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) + + const initializeFeatureVisibility = (existing: boolean) => { + if (store.general?.featureVisibilityInitialized === true) return + batch(() => { + setStore("general", "showFileTree", existing) + setStore("general", "showSearch", existing) + setStore("general", "showStatus", existing) + setStore("general", "showCustomAgents", existing) + setStore("general", "featureVisibilityInitialized", true) + }) + } if (sunset && !oldInterfaceRetired()) { const timeout = { current: undefined as ReturnType | undefined } @@ -318,6 +353,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("general", "newLayoutDesigns", true) }) + createEffect(() => { + if (!ready() || platform.platform === "desktop") return + initializeFeatureVisibility(false) + }) + createEffect(() => { if (typeof document === "undefined") return const root = document.documentElement @@ -416,6 +456,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont if (typeof current === "boolean") return setStore("general", "layoutTransitionEligible", eligible) }, + initializeFeatureVisibility, layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), dismissNewInterfaceNotice() { @@ -427,10 +468,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, visibility: { - fileTree: visible(showFileTree), - search: visible(showSearch), - status: visible(showStatus), - customAgents: visible(showCustomAgents), + fileTree: showFileTree, + search: showSearch, + status: showStatus, + customAgents: showCustomAgents, }, appearance: { fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx index 76c7ff683a..a72cfa63d8 100644 --- a/packages/desktop/src/renderer/onboarding.tsx +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -17,6 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad ) const existingInstall = await window.api.isOldLayoutEligible() settings.general.setOldLayoutEligible(existingInstall) + settings.general.initializeFeatureVisibility(existingInstall) if (!server.isLocal()) return const pending = await window.api.isFirstLaunchOnboardingPending()