fix(app): default advanced features for new users

This commit is contained in:
Brendan Allan
2026-07-15 12:42:52 +00:00
parent 04bdf7732b
commit f8a5fdcc26
6 changed files with 111 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 { 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) {
+34
View File
@@ -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)
+48 -7
View File
@@ -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<string, unknown> {
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<Settings>(defaultSettings))
const [store, setStore, _, ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
createStore<Settings>(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<typeof setTimeout> | 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),
@@ -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()