settings.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import { createStore, reconcile } from "solid-js/store"
  2. import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { persisted } from "@/utils/persist"
  5. import { usePlatform } from "@/context/platform"
  6. export interface NotificationSettings {
  7. agent: boolean
  8. permissions: boolean
  9. errors: boolean
  10. }
  11. export interface SoundSettings {
  12. agentEnabled: boolean
  13. agent: string
  14. permissionsEnabled: boolean
  15. permissions: string
  16. errorsEnabled: boolean
  17. errors: string
  18. }
  19. export interface Settings {
  20. general: {
  21. autoSave: boolean
  22. releaseNotes: boolean
  23. followup: "queue" | "steer"
  24. showFileTree: boolean
  25. showNavigation: boolean
  26. showSearch: boolean
  27. showStatus: boolean
  28. showTerminal: boolean
  29. showReasoningSummaries: boolean
  30. shellToolPartsExpanded: boolean
  31. editToolPartsExpanded: boolean
  32. showCustomAgents: boolean
  33. mobileTitlebarPosition: "top" | "bottom"
  34. newLayoutDesigns?: boolean
  35. layoutTransitionEligible?: boolean
  36. agentVisibilityInitialized?: boolean
  37. newInterfaceNoticeDismissed?: boolean
  38. shouldDisplayTabsToast?: boolean
  39. }
  40. appearance: {
  41. fontSize: number
  42. mono: string
  43. sans: string
  44. terminal: string
  45. }
  46. keybinds: Record<string, string>
  47. permissions: {
  48. autoApprove: boolean
  49. }
  50. notifications: NotificationSettings
  51. sounds: SoundSettings
  52. }
  53. export const monoDefault = "System Mono"
  54. export const sansDefault = "System Sans"
  55. export const terminalDefault = "JetBrainsMono Nerd Font Mono"
  56. const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
  57. export const newLayoutDesignsDefault = true
  58. // Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
  59. export const oldInterfaceSunset = new Date(2026, 8, 14)
  60. const newLayoutDesignsUpgradeCutoff = "1.17.19"
  61. function compareVersions(a: string, b: string) {
  62. const parse = (version: string) => {
  63. const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
  64. if (!match) return
  65. return match.slice(1).map(Number)
  66. }
  67. const left = parse(a)
  68. const right = parse(b)
  69. if (!left || !right) return
  70. const index = left.findIndex((part, index) => part !== right[index])
  71. return index === -1 ? 0 : left[index]! - right[index]!
  72. }
  73. export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
  74. if (!previous || !current) return false
  75. const comparison = compareVersions(current, previous)
  76. return comparison !== undefined && comparison > 0
  77. }
  78. export function shouldDisplayTabsToast(
  79. previous: string | undefined,
  80. current: string | undefined,
  81. existingInstall: boolean,
  82. ) {
  83. return isAppUpgrade(previous, current) || (!previous && existingInstall)
  84. }
  85. export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
  86. return settings !== null || previousVersion !== undefined
  87. }
  88. export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
  89. if (initialized === true) return
  90. return existing || previousVersion !== undefined
  91. }
  92. export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
  93. if (!current) return false
  94. const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
  95. if (!previous) return currentComparison !== undefined && currentComparison > 0
  96. if (!isAppUpgrade(previous, current)) return false
  97. const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
  98. return (
  99. previousComparison !== undefined &&
  100. currentComparison !== undefined &&
  101. previousComparison <= 0 &&
  102. currentComparison > 0
  103. )
  104. }
  105. export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
  106. return {
  107. available: scheduled && eligible && !retired,
  108. notice: scheduled && eligible && retired && !dismissed,
  109. }
  110. }
  111. export const maximumSunsetTimeout = 2_147_483_647
  112. export function nextSunsetCheckDelay(sunset: number, now: number) {
  113. return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
  114. }
  115. export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
  116. if (retired) return true
  117. return preference ?? fallback
  118. }
  119. const monoFallback =
  120. 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  121. const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
  122. const terminalFallback =
  123. '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  124. const monoBase = monoFallback
  125. const sansBase = sansFallback
  126. const terminalBase = terminalFallback
  127. function input(font: string | undefined) {
  128. return font ?? ""
  129. }
  130. function family(font: string) {
  131. if (/^[\w-]+$/.test(font)) return font
  132. return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
  133. }
  134. function stack(font: string | undefined, base: string) {
  135. const value = font?.trim() ?? ""
  136. if (!value) return base
  137. return `${family(value)}, ${base}`
  138. }
  139. export function monoInput(font: string | undefined) {
  140. return input(font)
  141. }
  142. export function sansInput(font: string | undefined) {
  143. return input(font)
  144. }
  145. export function monoFontFamily(font: string | undefined) {
  146. return stack(font, monoBase)
  147. }
  148. export function sansFontFamily(font: string | undefined) {
  149. return stack(font, sansBase)
  150. }
  151. export function terminalInput(font: string | undefined) {
  152. return input(font)
  153. }
  154. export function terminalFontFamily(font: string | undefined) {
  155. return stack(font, terminalBase)
  156. }
  157. const defaultSettings: Settings = {
  158. general: {
  159. autoSave: true,
  160. releaseNotes: true,
  161. followup: "steer",
  162. showFileTree: false,
  163. showNavigation: false,
  164. showSearch: false,
  165. showStatus: false,
  166. showTerminal: false,
  167. showReasoningSummaries: false,
  168. shellToolPartsExpanded: false,
  169. editToolPartsExpanded: false,
  170. showCustomAgents: false,
  171. mobileTitlebarPosition: "top",
  172. },
  173. appearance: {
  174. fontSize: 14,
  175. mono: "",
  176. sans: "",
  177. terminal: "",
  178. },
  179. keybinds: {},
  180. permissions: {
  181. autoApprove: false,
  182. },
  183. notifications: {
  184. agent: true,
  185. permissions: true,
  186. errors: false,
  187. },
  188. sounds: {
  189. agentEnabled: true,
  190. agent: "staplebops-01",
  191. permissionsEnabled: true,
  192. permissions: "staplebops-02",
  193. errorsEnabled: true,
  194. errors: "nope-03",
  195. },
  196. }
  197. function withFallback<T>(read: () => T | undefined, fallback: T) {
  198. return createMemo(() => read() ?? fallback)
  199. }
  200. export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
  201. name: "Settings",
  202. gate: false,
  203. init: () => {
  204. const platform = usePlatform()
  205. const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
  206. const [launch, setLaunch, , launchReady] = persisted(
  207. "app-version.v1",
  208. createStore<{ version?: string }>({ version: undefined }),
  209. )
  210. const [launchState, setLaunchState] = createStore({
  211. classified: false,
  212. migrationApplied: false,
  213. previous: undefined as string | undefined,
  214. })
  215. const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
  216. const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
  217. const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
  218. const showCustomAgents = withFallback(
  219. () => store.general?.showCustomAgents,
  220. defaultSettings.general.showCustomAgents,
  221. )
  222. const sunset = oldInterfaceSunset
  223. const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
  224. const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
  225. const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
  226. const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
  227. const layoutUpgrade = createMemo(() =>
  228. launchState.classified && !launchState.migrationApplied
  229. ? shouldEnableNewLayout(launchState.previous, platform.version)
  230. : false,
  231. )
  232. const layoutTransition = createMemo(() =>
  233. layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
  234. )
  235. const newLayoutDesigns = createMemo(() => {
  236. if (layoutUpgrade()) return true
  237. if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
  238. if (!layoutTransitionClassified()) {
  239. return resolveNewLayoutDesigns(
  240. oldInterfaceRetired(),
  241. store.general?.newLayoutDesigns,
  242. legacyNewLayoutDesignsDefault,
  243. )
  244. }
  245. return resolveNewLayoutDesigns(
  246. oldInterfaceRetired(),
  247. store.general?.newLayoutDesigns,
  248. layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
  249. )
  250. })
  251. const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
  252. const initializeAgentVisibility = (existing: boolean) => {
  253. const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
  254. if (initial === undefined) return
  255. batch(() => {
  256. setStore("general", "showCustomAgents", initial)
  257. setStore("general", "agentVisibilityInitialized", true)
  258. })
  259. }
  260. if (sunset && !oldInterfaceRetired()) {
  261. const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
  262. const checkSunset = () => {
  263. if (Date.now() >= sunset.getTime()) {
  264. setOldInterfaceRetired(true)
  265. return
  266. }
  267. timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
  268. }
  269. checkSunset()
  270. onCleanup(() => {
  271. if (timeout.current !== undefined) clearTimeout(timeout.current)
  272. })
  273. }
  274. createEffect(() => {
  275. if (!launchReady() || launchState.classified) return
  276. setLaunchState({
  277. classified: true,
  278. previous: launch.version,
  279. })
  280. if (!platform.version || launch.version === platform.version) return
  281. setLaunch("version", platform.version)
  282. })
  283. createEffect(() => {
  284. if (!ready() || !launchState.classified || platform.platform !== "web") return
  285. const existing = hasExistingWebState(settingsInit, launchState.previous)
  286. if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
  287. initializeAgentVisibility(existing)
  288. })
  289. createEffect(() => {
  290. if (!ready() || !launchState.classified || launchState.migrationApplied) return
  291. if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
  292. setStore("general", "newLayoutDesigns", true)
  293. }
  294. setLaunchState("migrationApplied", true)
  295. })
  296. createEffect(() => {
  297. if (!ready() || !launchState.classified) return
  298. if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
  299. if (!launchState.previous && !layoutTransitionClassified()) return
  300. setStore(
  301. "general",
  302. "shouldDisplayTabsToast",
  303. shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
  304. )
  305. })
  306. createEffect(() => {
  307. if (!ready() || !oldInterfaceRetired()) return
  308. if (store.general?.newLayoutDesigns === true) return
  309. setStore("general", "newLayoutDesigns", true)
  310. })
  311. createEffect(() => {
  312. if (typeof document === "undefined") return
  313. const root = document.documentElement
  314. root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
  315. root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
  316. })
  317. createEffect(() => {
  318. if (store.general?.followup !== "queue") return
  319. setStore("general", "followup", "steer")
  320. })
  321. return {
  322. ready,
  323. get current() {
  324. return store
  325. },
  326. general: {
  327. autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
  328. setAutoSave(value: boolean) {
  329. setStore("general", "autoSave", value)
  330. },
  331. releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
  332. setReleaseNotes(value: boolean) {
  333. setStore("general", "releaseNotes", value)
  334. },
  335. followup: withFallback(
  336. () => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
  337. defaultSettings.general.followup,
  338. ),
  339. setFollowup(value: "queue" | "steer") {
  340. setStore("general", "followup", value === "queue" ? "steer" : value)
  341. },
  342. showFileTree,
  343. setShowFileTree(value: boolean) {
  344. setStore("general", "showFileTree", value)
  345. },
  346. showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
  347. setShowNavigation(value: boolean) {
  348. setStore("general", "showNavigation", value)
  349. },
  350. showSearch,
  351. setShowSearch(value: boolean) {
  352. setStore("general", "showSearch", value)
  353. },
  354. showStatus,
  355. setShowStatus(value: boolean) {
  356. setStore("general", "showStatus", value)
  357. },
  358. showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
  359. setShowTerminal(value: boolean) {
  360. setStore("general", "showTerminal", value)
  361. },
  362. showReasoningSummaries: withFallback(
  363. () => store.general?.showReasoningSummaries,
  364. defaultSettings.general.showReasoningSummaries,
  365. ),
  366. setShowReasoningSummaries(value: boolean) {
  367. setStore("general", "showReasoningSummaries", value)
  368. },
  369. shellToolPartsExpanded: withFallback(
  370. () => store.general?.shellToolPartsExpanded,
  371. defaultSettings.general.shellToolPartsExpanded,
  372. ),
  373. setShellToolPartsExpanded(value: boolean) {
  374. setStore("general", "shellToolPartsExpanded", value)
  375. },
  376. editToolPartsExpanded: withFallback(
  377. () => store.general?.editToolPartsExpanded,
  378. defaultSettings.general.editToolPartsExpanded,
  379. ),
  380. setEditToolPartsExpanded(value: boolean) {
  381. setStore("general", "editToolPartsExpanded", value)
  382. },
  383. showCustomAgents,
  384. setShowCustomAgents(value: boolean) {
  385. setStore("general", "showCustomAgents", value)
  386. },
  387. mobileTitlebarPosition: withFallback(
  388. () => store.general?.mobileTitlebarPosition,
  389. defaultSettings.general.mobileTitlebarPosition,
  390. ),
  391. setMobileTitlebarPosition(value: "top" | "bottom") {
  392. setStore("general", "mobileTitlebarPosition", value)
  393. },
  394. newLayoutDesigns,
  395. setNewLayoutDesigns(value: boolean) {
  396. const next = oldInterfaceRetired() ? true : value
  397. if (newLayoutDesigns() === next) return
  398. setStore("general", "newLayoutDesigns", next)
  399. if (typeof window !== "undefined") setTimeout(() => window.location.reload())
  400. },
  401. layoutTransitionClassified,
  402. setOldLayoutEligible(eligible: boolean) {
  403. const current = store.general?.layoutTransitionEligible
  404. if (typeof current === "boolean") return
  405. setStore("general", "layoutTransitionEligible", eligible)
  406. },
  407. initializeAgentVisibility,
  408. layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
  409. newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
  410. dismissNewInterfaceNotice() {
  411. setStore("general", "newInterfaceNoticeDismissed", true)
  412. },
  413. shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
  414. dismissTabsToast() {
  415. setStore("general", "shouldDisplayTabsToast", false)
  416. },
  417. },
  418. visibility: {
  419. fileTree: visible(showFileTree),
  420. search: visible(showSearch),
  421. status: visible(showStatus),
  422. customAgents: visible(showCustomAgents),
  423. },
  424. appearance: {
  425. fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
  426. setFontSize(value: number) {
  427. setStore("appearance", "fontSize", value)
  428. },
  429. font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
  430. setFont(value: string) {
  431. setStore("appearance", "mono", value.trim() ? value : "")
  432. },
  433. uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
  434. setUIFont(value: string) {
  435. setStore("appearance", "sans", value.trim() ? value : "")
  436. },
  437. terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
  438. setTerminalFont(value: string) {
  439. setStore("appearance", "terminal", value.trim() ? value : "")
  440. },
  441. },
  442. keybinds: {
  443. get: (action: string) => store.keybinds?.[action],
  444. set(action: string, keybind: string) {
  445. setStore("keybinds", action, keybind)
  446. },
  447. reset(action: string) {
  448. setStore("keybinds", (current) => {
  449. if (!Object.prototype.hasOwnProperty.call(current, action)) return current
  450. const next = { ...current }
  451. delete next[action]
  452. return next
  453. })
  454. },
  455. resetAll() {
  456. setStore("keybinds", reconcile({}))
  457. },
  458. },
  459. permissions: {
  460. autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
  461. setAutoApprove(value: boolean) {
  462. setStore("permissions", "autoApprove", value)
  463. },
  464. },
  465. notifications: {
  466. agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
  467. setAgent(value: boolean) {
  468. setStore("notifications", "agent", value)
  469. },
  470. permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
  471. setPermissions(value: boolean) {
  472. setStore("notifications", "permissions", value)
  473. },
  474. errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
  475. setErrors(value: boolean) {
  476. setStore("notifications", "errors", value)
  477. },
  478. },
  479. sounds: {
  480. agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
  481. setAgentEnabled(value: boolean) {
  482. setStore("sounds", "agentEnabled", value)
  483. },
  484. agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
  485. setAgent(value: string) {
  486. setStore("sounds", "agent", value)
  487. },
  488. permissionsEnabled: withFallback(
  489. () => store.sounds?.permissionsEnabled,
  490. defaultSettings.sounds.permissionsEnabled,
  491. ),
  492. setPermissionsEnabled(value: boolean) {
  493. setStore("sounds", "permissionsEnabled", value)
  494. },
  495. permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
  496. setPermissions(value: string) {
  497. setStore("sounds", "permissions", value)
  498. },
  499. errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
  500. setErrorsEnabled(value: boolean) {
  501. setStore("sounds", "errorsEnabled", value)
  502. },
  503. errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
  504. setErrors(value: string) {
  505. setStore("sounds", "errors", value)
  506. },
  507. },
  508. }
  509. },
  510. })