Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan c215c42fe0 refactor(app): initialize feature visibility at runtime 2026-07-15 14:48:28 +00:00
Brendan Allan f8a5fdcc26 fix(app): default advanced features for new users 2026-07-15 12:42:52 +00:00
6 changed files with 75 additions and 11 deletions
@@ -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")
})
})
+3
View File
@@ -0,0 +1,3 @@
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+6 -4
View File
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js" import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@@ -62,6 +64,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const models = useModels() const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) 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 variant?: string | null
} }
}>({ }>({
current: list()[0]?.name, current: resolveAgent(list())?.name,
draft: undefined, draft: undefined,
last: undefined, last: undefined,
}) })
@@ -107,9 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const pickAgent = (name: string | undefined) => { const pickAgent = (name: string | undefined) => {
const items = list() return resolveAgent(list(), name)
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
} }
createEffect(() => { createEffect(() => {
@@ -181,6 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = { const agent = {
list, list,
current() { current() {
if (!settings.visibility.customAgents()) return pickAgent("build")
return pickAgent(scope()?.agent ?? store.current) return pickAgent(scope()?.agent ?? store.current)
}, },
set(name: string | undefined) { set(name: string | undefined) {
+15
View File
@@ -8,8 +8,23 @@ import {
resolveNewLayoutDesigns, resolveNewLayoutDesigns,
shouldDisplayTabsToast, shouldDisplayTabsToast,
shouldEnableNewLayout, shouldEnableNewLayout,
shouldEnableFeatureVisibility,
} from "./settings" } from "./settings"
describe("feature visibility", () => {
test("enables features for profiles with persisted settings", () => {
expect(shouldEnableFeatureVisibility("{}", undefined)).toBe(true)
})
test("enables features for profiles with a recorded app version", () => {
expect(shouldEnableFeatureVisibility(null, "1.18.1")).toBe(true)
})
test("keeps features hidden for new profiles", () => {
expect(shouldEnableFeatureVisibility(null, undefined)).toBe(false)
})
})
describe("layout transition", () => { describe("layout transition", () => {
test("blank profiles default to the new layout", () => { test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true) expect(newLayoutDesignsDefault).toBe(true)
+31 -7
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" 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 { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -36,6 +36,7 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom" mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean layoutTransitionEligible?: boolean
featureVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean shouldDisplayTabsToast?: boolean
} }
@@ -62,6 +63,13 @@ export const newLayoutDesignsDefault = true
export const oldInterfaceSunset = new Date(2026, 8, 14) export const oldInterfaceSunset = new Date(2026, 8, 14)
const newLayoutDesignsUpgradeCutoff = "1.17.19" const newLayoutDesignsUpgradeCutoff = "1.17.19"
export function shouldEnableFeatureVisibility(
settings: string | Promise<string> | null,
previousVersion: string | undefined,
) {
return settings !== null || previousVersion !== undefined
}
function compareVersions(a: string, b: string) { function compareVersions(a: string, b: string) {
const parse = (version: string) => { const parse = (version: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim()) const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
@@ -220,7 +228,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings)) const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted( const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1", "app-version.v1",
createStore<{ version?: string }>({ version: undefined }), createStore<{ version?: string }>({ version: undefined }),
@@ -266,7 +274,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault, 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()) { if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -318,6 +336,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newLayoutDesigns", true) setStore("general", "newLayoutDesigns", true)
}) })
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform === "desktop") return
initializeFeatureVisibility(shouldEnableFeatureVisibility(settingsInit, launchState.previous))
})
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const root = document.documentElement const root = document.documentElement
@@ -416,6 +439,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible) setStore("general", "layoutTransitionEligible", eligible)
}, },
initializeFeatureVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() { dismissNewInterfaceNotice() {
@@ -427,10 +451,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}, },
}, },
visibility: { visibility: {
fileTree: visible(showFileTree), fileTree: showFileTree,
search: visible(showSearch), search: showSearch,
status: visible(showStatus), status: showStatus,
customAgents: visible(showCustomAgents), customAgents: showCustomAgents,
}, },
appearance: { appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
@@ -17,6 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
) )
const existingInstall = await window.api.isOldLayoutEligible() const existingInstall = await window.api.isOldLayoutEligible()
settings.general.setOldLayoutEligible(existingInstall) settings.general.setOldLayoutEligible(existingInstall)
settings.general.initializeFeatureVisibility(existingInstall)
if (!server.isLocal()) return if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending() const pending = await window.api.isFirstLaunchOnboardingPending()