fix(app): default advanced features for new users
This commit is contained in:
parent
04bdf7732b
commit
f8a5fdcc26
6 changed files with 111 additions and 11 deletions
19
packages/app/src/context/local-agent.test.ts
Normal file
19
packages/app/src/context/local-agent.test.ts
Normal file
|
|
@ -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
packages/app/src/context/local-agent.ts
Normal file
3
packages/app/src/context/local-agent.ts
Normal 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]
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
isAppUpgrade,
|
isAppUpgrade,
|
||||||
layoutTransitionState,
|
layoutTransitionState,
|
||||||
maximumSunsetTimeout,
|
maximumSunsetTimeout,
|
||||||
|
migrateSettings,
|
||||||
newLayoutDesignsDefault,
|
newLayoutDesignsDefault,
|
||||||
nextSunsetCheckDelay,
|
nextSunsetCheckDelay,
|
||||||
resolveNewLayoutDesigns,
|
resolveNewLayoutDesigns,
|
||||||
|
|
@ -10,6 +11,39 @@ import {
|
||||||
shouldEnableNewLayout,
|
shouldEnableNewLayout,
|
||||||
} from "./settings"
|
} 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", () => {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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,27 @@ 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"
|
||||||
|
|
||||||
|
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) {
|
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 +242,10 @@ 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, _, ready] = persisted(
|
||||||
|
{ key: "settings.v3", migrate: migrateSettings },
|
||||||
|
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 +291,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 +353,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
setStore("general", "newLayoutDesigns", true)
|
setStore("general", "newLayoutDesigns", true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || platform.platform === "desktop") return
|
||||||
|
initializeFeatureVisibility(false)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
|
@ -416,6 +456,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 +468,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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue