Skip to content

Commit 4a181c3

Browse files
authored
desktop v2 migration finalising (#36912)
1 parent bfddf05 commit 4a181c3

9 files changed

Lines changed: 155 additions & 46 deletions

File tree

packages/app/e2e/performance/timeline-stability/fixture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ export async function setupTimeline(
136136
},
137137
}),
138138
)
139+
if (settings.newLayoutDesigns === false) {
140+
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
141+
}
139142
}, input.settings ?? {})
140143
if (input.locale) {
141144
await page.addInitScript((locale) => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
2424
await page.addInitScript(
2525
({ directory, draftID, server }) => {
2626
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
27+
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
2728
localStorage.setItem(
2829
"opencode.window.browser.dat:tabs",
2930
JSON.stringify([{ type: "draft", draftID, server, directory }]),

packages/app/src/components/help-button.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
22
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
33
import { createSignal, Show } from "solid-js"
4-
import { createStore } from "solid-js/store"
54
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
65
import { usePlatform } from "@/context/platform"
6+
import { useSettings } from "@/context/settings"
77
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
88
import homeImage from "@/assets/help/home.png"
99
import tabsImage from "@/assets/help/tabs.png"
10-
import { Persist, persisted } from "@/utils/persist"
1110

1211
const helpIcon = (
1312
<svg
@@ -56,15 +55,12 @@ export function HelpButton() {
5655

5756
// can remove this after the tabs rollout has been out for a while
5857
export function TabsInfoPopup() {
59-
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
60-
61-
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
62-
// setState({ dismissed: false }) // for testing
58+
const settings = useSettings()
6359
const [drawerOpen, setDrawerOpen] = createSignal(false)
6460

6561
return (
6662
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
67-
<Show when={!state.dismissed}>
63+
<Show when={settings.general.shouldDisplayTabsToast()}>
6864
<div
6965
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)]"
7066
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
@@ -73,7 +69,7 @@ export function TabsInfoPopup() {
7369
type="button"
7470
aria-label="Dismiss Tabs information"
7571
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
76-
onClick={() => setState("dismissed", true)}
72+
onClick={settings.general.dismissTabsToast}
7773
>
7874
<svg
7975
width="16"
@@ -90,7 +86,7 @@ export function TabsInfoPopup() {
9086
type="button"
9187
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
9288
onClick={() => {
93-
setState("dismissed", true)
89+
settings.general.dismissTabsToast()
9490
setDrawerOpen(true)
9591
}}
9692
>

packages/app/src/context/settings.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { describe, expect, test } from "bun:test"
22
import {
3+
isAppUpgrade,
34
layoutTransitionState,
45
maximumSunsetTimeout,
56
newLayoutDesignsDefault,
67
nextSunsetCheckDelay,
78
resolveNewLayoutDesigns,
9+
shouldDisplayTabsToast,
10+
shouldEnableNewLayout,
811
} from "./settings"
912

1013
describe("layout transition", () => {
@@ -37,4 +40,33 @@ describe("layout transition", () => {
3740
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
3841
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
3942
})
43+
44+
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
45+
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
46+
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
47+
})
48+
49+
test("enables the new layout when no previous version was recorded", () => {
50+
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
51+
})
52+
53+
test("detects upgrades only when a previous version is older", () => {
54+
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
55+
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
56+
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
57+
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
58+
})
59+
60+
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
61+
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
62+
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
63+
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
64+
})
65+
66+
test("does not enable the new layout without a qualifying upgrade", () => {
67+
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
68+
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
69+
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
70+
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
71+
})
4072
})

packages/app/src/context/settings.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
22
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
33
import { createSimpleContext } from "@opencode-ai/ui/context"
44
import { persisted } from "@/utils/persist"
5+
import { usePlatform } from "@/context/platform"
56

67
export interface NotificationSettings {
78
agent: boolean
@@ -36,6 +37,7 @@ export interface Settings {
3637
newLayoutDesigns?: boolean
3738
layoutTransitionEligible?: boolean
3839
newInterfaceNoticeDismissed?: boolean
40+
shouldDisplayTabsToast?: boolean
3941
}
4042
appearance: {
4143
fontSize: number
@@ -57,7 +59,49 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
5759
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
5860
export const newLayoutDesignsDefault = true
5961
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
60-
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
62+
export const oldInterfaceSunset = new Date(2026, 8, 14)
63+
const newLayoutDesignsUpgradeCutoff = "1.17.19"
64+
65+
function compareVersions(a: string, b: string) {
66+
const parse = (version: string) => {
67+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
68+
if (!match) return
69+
return match.slice(1).map(Number)
70+
}
71+
const left = parse(a)
72+
const right = parse(b)
73+
if (!left || !right) return
74+
const index = left.findIndex((part, index) => part !== right[index])
75+
return index === -1 ? 0 : left[index]! - right[index]!
76+
}
77+
78+
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
79+
if (!previous || !current) return false
80+
const comparison = compareVersions(current, previous)
81+
return comparison !== undefined && comparison > 0
82+
}
83+
84+
export function shouldDisplayTabsToast(
85+
previous: string | undefined,
86+
current: string | undefined,
87+
existingInstall: boolean,
88+
) {
89+
return isAppUpgrade(previous, current) || (!previous && existingInstall)
90+
}
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+
}
61105

62106
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
63107
return {
@@ -175,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
175219
name: "Settings",
176220
gate: false,
177221
init: () => {
222+
const platform = usePlatform()
178223
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
224+
const [launch, setLaunch, , launchReady] = persisted(
225+
"app-version.v1",
226+
createStore<{ version?: string }>({ version: undefined }),
227+
)
228+
const [launchState, setLaunchState] = createStore({
229+
classified: false,
230+
migrationApplied: false,
231+
previous: undefined as string | undefined,
232+
})
179233
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
180234
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
181235
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -188,10 +242,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
188242
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
189243
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
190244
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
245+
const layoutUpgrade = createMemo(() =>
246+
launchState.classified && !launchState.migrationApplied
247+
? shouldEnableNewLayout(launchState.previous, platform.version)
248+
: false,
249+
)
191250
const layoutTransition = createMemo(() =>
192251
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
193252
)
194253
const newLayoutDesigns = createMemo(() => {
254+
if (layoutUpgrade()) return true
195255
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
196256
if (!layoutTransitionClassified()) {
197257
return resolveNewLayoutDesigns(
@@ -223,6 +283,35 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
223283
})
224284
}
225285

286+
createEffect(() => {
287+
if (!launchReady() || launchState.classified) return
288+
setLaunchState({
289+
classified: true,
290+
previous: launch.version,
291+
})
292+
if (!platform.version || launch.version === platform.version) return
293+
setLaunch("version", platform.version)
294+
})
295+
296+
createEffect(() => {
297+
if (!ready() || !launchState.classified || launchState.migrationApplied) return
298+
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
299+
setStore("general", "newLayoutDesigns", true)
300+
}
301+
setLaunchState("migrationApplied", true)
302+
})
303+
304+
createEffect(() => {
305+
if (!ready() || !launchState.classified) return
306+
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
307+
if (!launchState.previous && !layoutTransitionClassified()) return
308+
setStore(
309+
"general",
310+
"shouldDisplayTabsToast",
311+
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
312+
)
313+
})
314+
226315
createEffect(() => {
227316
if (!ready() || !oldInterfaceRetired()) return
228317
if (store.general?.newLayoutDesigns === true) return
@@ -316,7 +405,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
316405
},
317406
newLayoutDesigns,
318407
setNewLayoutDesigns(value: boolean) {
319-
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
408+
const next = oldInterfaceRetired() ? true : value
409+
if (newLayoutDesigns() === next) return
410+
setStore("general", "newLayoutDesigns", next)
411+
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
320412
},
321413
layoutTransitionClassified,
322414
setOldLayoutEligible(eligible: boolean) {
@@ -329,6 +421,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
329421
dismissNewInterfaceNotice() {
330422
setStore("general", "newInterfaceNoticeDismissed", true)
331423
},
424+
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
425+
dismissTabsToast() {
426+
setStore("general", "shouldDisplayTabsToast", false)
427+
},
332428
},
333429
visibility: {
334430
fileTree: visible(showFileTree),

packages/app/src/context/tabs.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,18 +208,19 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
208208
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
209209
return tab
210210
},
211-
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
211+
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
212212
const draftID = uuid()
213213
const tab = { type: "draft" as const, draftID, ...draft }
214214
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
215-
void startTransition(() => {
215+
await startTransition(() => {
216216
setStore(
217217
produce((tabs) => {
218218
tabs.push(tab)
219219
}),
220220
)
221221
navigate(draftHref(draftID))
222222
})
223+
return tab
223224
},
224225
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
225226
void startTransition(() => {

packages/desktop/src/main/onboarding.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from ".
77
import { write as writeLog } from "./logging"
88
import { hasExistingAppState } from "./install-state"
99

10-
const DEFAULT_PROJECT_DIR = "New OpenCode Project"
10+
const DEFAULT_PROJECT_DIR = "Default Project"
1111

1212
export function initializeOldLayoutEligibility(userDataPath: string) {
1313
const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []

packages/desktop/src/main/windows.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ protocol.registerSchemesAsPrivileged([
3636
secure: true,
3737
standard: true,
3838
supportFetchAPI: true,
39+
stream: true,
3940
},
4041
},
4142
])
@@ -266,7 +267,10 @@ export function registerRendererProtocol() {
266267
}
267268

268269
try {
269-
const response = await net.fetch(pathToFileURL(file).toString())
270+
const range = request.headers.get("range")
271+
const response = await net.fetch(pathToFileURL(file).toString(), {
272+
headers: range ? { range } : undefined,
273+
})
270274
if (response.status >= 400) {
271275
writeLog(
272276
"protocol",

0 commit comments

Comments
 (0)