فهرست منبع

desktop v2 migration finalising (#36912)

Brendan Allan 1 ماه پیش
والد
کامیت
4a181c3574

+ 3 - 0
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -136,6 +136,9 @@ export async function setupTimeline(
         },
       }),
     )
+    if (settings.newLayoutDesigns === false) {
+      localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
+    }
   }, input.settings ?? {})
   if (input.locale) {
     await page.addInitScript((locale) => {

+ 1 - 0
packages/app/e2e/regression/legacy-new-session.spec.ts

@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
   await page.addInitScript(
     ({ directory, draftID, server }) => {
       localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
+      localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
       localStorage.setItem(
         "opencode.window.browser.dat:tabs",
         JSON.stringify([{ type: "draft", draftID, server, directory }]),

+ 5 - 9
packages/app/src/components/help-button.tsx

@@ -1,13 +1,12 @@
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { createSignal, Show } from "solid-js"
-import { createStore } from "solid-js/store"
 import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
 import { usePlatform } from "@/context/platform"
+import { useSettings } from "@/context/settings"
 import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
 import homeImage from "@/assets/help/home.png"
 import tabsImage from "@/assets/help/tabs.png"
-import { Persist, persisted } from "@/utils/persist"
 
 const helpIcon = (
   <svg
@@ -56,15 +55,12 @@ export function HelpButton() {
 
 // can remove this after the tabs rollout has been out for a while
 export function TabsInfoPopup() {
-  if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
-
-  const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
-  // setState({ dismissed: false }) // for testing
+  const settings = useSettings()
   const [drawerOpen, setDrawerOpen] = createSignal(false)
 
   return (
     <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
-      <Show when={!state.dismissed}>
+      <Show when={settings.general.shouldDisplayTabsToast()}>
         <div
           class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
           aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
@@ -73,7 +69,7 @@ export function TabsInfoPopup() {
             type="button"
             aria-label="Dismiss Tabs information"
             class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
-            onClick={() => setState("dismissed", true)}
+            onClick={settings.general.dismissTabsToast}
           >
             <svg
               width="16"
@@ -90,7 +86,7 @@ export function TabsInfoPopup() {
             type="button"
             class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
             onClick={() => {
-              setState("dismissed", true)
+              settings.general.dismissTabsToast()
               setDrawerOpen(true)
             }}
           >

+ 32 - 0
packages/app/src/context/settings.test.ts

@@ -1,10 +1,13 @@
 import { describe, expect, test } from "bun:test"
 import {
+  isAppUpgrade,
   layoutTransitionState,
   maximumSunsetTimeout,
   newLayoutDesignsDefault,
   nextSunsetCheckDelay,
   resolveNewLayoutDesigns,
+  shouldDisplayTabsToast,
+  shouldEnableNewLayout,
 } from "./settings"
 
 describe("layout transition", () => {
@@ -37,4 +40,33 @@ describe("layout transition", () => {
     expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
     expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
   })
+
+  test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
+    expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
+    expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
+  })
+
+  test("enables the new layout when no previous version was recorded", () => {
+    expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
+  })
+
+  test("detects upgrades only when a previous version is older", () => {
+    expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
+    expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
+    expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
+    expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
+  })
+
+  test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
+    expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
+    expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
+    expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
+  })
+
+  test("does not enable the new layout without a qualifying upgrade", () => {
+    expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
+    expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
+    expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
+    expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
+  })
 })

+ 98 - 2
packages/app/src/context/settings.tsx

@@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
 import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { persisted } from "@/utils/persist"
+import { usePlatform } from "@/context/platform"
 
 export interface NotificationSettings {
   agent: boolean
@@ -36,6 +37,7 @@ export interface Settings {
     newLayoutDesigns?: boolean
     layoutTransitionEligible?: boolean
     newInterfaceNoticeDismissed?: boolean
+    shouldDisplayTabsToast?: boolean
   }
   appearance: {
     fontSize: number
@@ -57,7 +59,49 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
 const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 export const newLayoutDesignsDefault = true
 // Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
-export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
+export const oldInterfaceSunset = new Date(2026, 8, 14)
+const newLayoutDesignsUpgradeCutoff = "1.17.19"
+
+function compareVersions(a: string, b: string) {
+  const parse = (version: string) => {
+    const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
+    if (!match) return
+    return match.slice(1).map(Number)
+  }
+  const left = parse(a)
+  const right = parse(b)
+  if (!left || !right) return
+  const index = left.findIndex((part, index) => part !== right[index])
+  return index === -1 ? 0 : left[index]! - right[index]!
+}
+
+export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
+  if (!previous || !current) return false
+  const comparison = compareVersions(current, previous)
+  return comparison !== undefined && comparison > 0
+}
+
+export function shouldDisplayTabsToast(
+  previous: string | undefined,
+  current: string | undefined,
+  existingInstall: boolean,
+) {
+  return isAppUpgrade(previous, current) || (!previous && existingInstall)
+}
+
+export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
+  if (!current) return false
+  const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
+  if (!previous) return currentComparison !== undefined && currentComparison > 0
+  if (!isAppUpgrade(previous, current)) return false
+  const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
+  return (
+    previousComparison !== undefined &&
+    currentComparison !== undefined &&
+    previousComparison <= 0 &&
+    currentComparison > 0
+  )
+}
 
 export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
   return {
@@ -175,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
   name: "Settings",
   gate: false,
   init: () => {
+    const platform = usePlatform()
     const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
+    const [launch, setLaunch, , launchReady] = persisted(
+      "app-version.v1",
+      createStore<{ version?: string }>({ version: undefined }),
+    )
+    const [launchState, setLaunchState] = createStore({
+      classified: false,
+      migrationApplied: false,
+      previous: undefined as string | undefined,
+    })
     const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
     const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
     const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -188,10 +242,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
     const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
     const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
     const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
+    const layoutUpgrade = createMemo(() =>
+      launchState.classified && !launchState.migrationApplied
+        ? shouldEnableNewLayout(launchState.previous, platform.version)
+        : false,
+    )
     const layoutTransition = createMemo(() =>
       layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
     )
     const newLayoutDesigns = createMemo(() => {
+      if (layoutUpgrade()) return true
       if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
       if (!layoutTransitionClassified()) {
         return resolveNewLayoutDesigns(
@@ -223,6 +283,35 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
       })
     }
 
+    createEffect(() => {
+      if (!launchReady() || launchState.classified) return
+      setLaunchState({
+        classified: true,
+        previous: launch.version,
+      })
+      if (!platform.version || launch.version === platform.version) return
+      setLaunch("version", platform.version)
+    })
+
+    createEffect(() => {
+      if (!ready() || !launchState.classified || launchState.migrationApplied) return
+      if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
+        setStore("general", "newLayoutDesigns", true)
+      }
+      setLaunchState("migrationApplied", true)
+    })
+
+    createEffect(() => {
+      if (!ready() || !launchState.classified) return
+      if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
+      if (!launchState.previous && !layoutTransitionClassified()) return
+      setStore(
+        "general",
+        "shouldDisplayTabsToast",
+        shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
+      )
+    })
+
     createEffect(() => {
       if (!ready() || !oldInterfaceRetired()) return
       if (store.general?.newLayoutDesigns === true) return
@@ -316,7 +405,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         },
         newLayoutDesigns,
         setNewLayoutDesigns(value: boolean) {
-          setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
+          const next = oldInterfaceRetired() ? true : value
+          if (newLayoutDesigns() === next) return
+          setStore("general", "newLayoutDesigns", next)
+          if (typeof window !== "undefined") setTimeout(() => window.location.reload())
         },
         layoutTransitionClassified,
         setOldLayoutEligible(eligible: boolean) {
@@ -329,6 +421,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         dismissNewInterfaceNotice() {
           setStore("general", "newInterfaceNoticeDismissed", true)
         },
+        shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
+        dismissTabsToast() {
+          setStore("general", "shouldDisplayTabsToast", false)
+        },
       },
       visibility: {
         fileTree: visible(showFileTree),

+ 3 - 2
packages/app/src/context/tabs.tsx

@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
         return tab
       },
-      newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
+      async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
         const draftID = uuid()
         const tab = { type: "draft" as const, draftID, ...draft }
         memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
-        void startTransition(() => {
+        await startTransition(() => {
           setStore(
             produce((tabs) => {
               tabs.push(tab)
@@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
           )
           navigate(draftHref(draftID))
         })
+        return tab
       },
       updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
         void startTransition(() => {

+ 1 - 1
packages/desktop/src/main/onboarding.ts

@@ -7,7 +7,7 @@ import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from ".
 import { write as writeLog } from "./logging"
 import { hasExistingAppState } from "./install-state"
 
-const DEFAULT_PROJECT_DIR = "New OpenCode Project"
+const DEFAULT_PROJECT_DIR = "Default Project"
 
 export function initializeOldLayoutEligibility(userDataPath: string) {
   const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []

+ 5 - 1
packages/desktop/src/main/windows.ts

@@ -36,6 +36,7 @@ protocol.registerSchemesAsPrivileged([
       secure: true,
       standard: true,
       supportFetchAPI: true,
+      stream: true,
     },
   },
 ])
@@ -266,7 +267,10 @@ export function registerRendererProtocol() {
     }
 
     try {
-      const response = await net.fetch(pathToFileURL(file).toString())
+      const range = request.headers.get("range")
+      const response = await net.fetch(pathToFileURL(file).toString(), {
+        headers: range ? { range } : undefined,
+      })
       if (response.status >= 400) {
         writeLog(
           "protocol",

+ 7 - 31
packages/desktop/src/renderer/onboarding.tsx

@@ -1,22 +1,14 @@
 import {
   ServerConnection,
-  useLayout,
-  useProviders,
   useServer,
-  useServerSDK,
-  useServerSync,
   useSettings,
   useTabs,
 } from "@opencode-ai/app"
-import { onMount, startTransition } from "solid-js"
+import { onMount } from "solid-js"
 
 export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
   const server = useServer()
-  const serverSDK = useServerSDK()
-  const serverSync = useServerSync()
   const settings = useSettings()
-  const layout = useLayout()
-  const providers = useProviders()
   const tabs = useTabs()
 
   onMount(() => {
@@ -26,40 +18,26 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
   async function runFirstLaunchOnboarding() {
     try {
       await Promise.all(
-        [server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map(
-          (p) => p ?? Promise.resolve(),
-        ),
+        [server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
       )
-      settings.general.setOldLayoutEligible(await window.api.isOldLayoutEligible())
+      const existingInstall = await window.api.isOldLayoutEligible()
+      settings.general.setOldLayoutEligible(existingInstall)
       if (!server.isLocal()) return
 
       const pending = await window.api.isFirstLaunchOnboardingPending()
       if (!pending) return
 
-      const sessions = await serverSDK()
-        .client.session.list()
-        .then((x) => x.data ?? [])
-        .catch(() => undefined)
-      const connectedProviders = providers.connected()
-      const paidProviders = providers.paid()
-      const persistedProjects = layout.projects.list()
       const shouldTrigger =
+        !existingInstall &&
         props.initialUrl === "/" &&
-        sessions?.length === 0 &&
-        paidProviders.length === 0 &&
-        persistedProjects.length === 0 &&
         tabs.store.length === 0 &&
         server.list.every(ServerConnection.builtin)
 
       console.info("[desktop-onboarding] first launch onboarding evaluated", {
         pending,
         shouldTrigger,
+        existingInstall,
         initialUrl: props.initialUrl,
-        sessions: sessions?.length,
-        connectedProviders: connectedProviders.length,
-        paidProviders: paidProviders.length,
-        serverProjects: serverSync().data.project.length,
-        persistedProjects: persistedProjects.length,
         tabs: tabs.store.length,
         servers: server.list.map(ServerConnection.key),
       })
@@ -70,9 +48,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
       console.info("[desktop-onboarding] starting first launch draft", { directory })
       server.projects.open(directory)
       server.projects.touch(directory)
-      await startTransition(() => {
-        tabs.newDraft({ server: server.key, directory })
-      })
+      tabs.select(await tabs.newDraft({ server: server.key, directory }))
     } catch (error) {
       console.error("[desktop-onboarding] first launch onboarding failed", error)
     }