diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index c7cdc73f9b..9c2e7d6a1f 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -126,10 +126,10 @@ function SelectedServerProviders(props: ParentProps) {
)
}
-function LegacyServerLayout(props: ParentProps) {
+function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
- {props.children}
+ {props.children}
)
}
@@ -263,12 +263,14 @@ function DesktopCommands() {
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
+ serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
+ {props.serverScoped}
{props.children}
@@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
-
+
{props.children}
)
}
-function NewAppLayout(props: ParentProps) {
+function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
-
+
{props.children}
@@ -347,7 +349,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
)
}
-function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
+function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -376,34 +378,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
+ const [startup] = createResource(async () => {
+ if (!props.startup) return true
+ await props.startup.catch((error) => {
+ console.error("[startup] startup gate failed", error)
+ })
+ return true
+ })
+ const startupChecking = createMemo(
+ () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
+ )
+ const loading = createMemo(() => checking() || startupChecking())
return (
-
+ <>
+
+ {
+ if (checkMode() === "background") void healthCheckActions.refetch()
+ }}
+ onServerSelected={(key) => {
+ setCheckMode("blocking")
+ server.setActive(key)
+ void healthCheckActions.refetch()
+ }}
+ />
+ }
+ >
+ {props.children}
+
+
+
+
- }
- >
- {
- if (checkMode() === "background") void healthCheckActions.refetch()
- }}
- onServerSelected={(key) => {
- setCheckMode("blocking")
- server.setActive(key)
- void healthCheckActions.refetch()
- }}
- />
- }
- >
- {props.children}
-
+ >
)
}
@@ -470,6 +483,8 @@ export function AppInterface(props: {
servers?: Array
router?: Component
disableHealthCheck?: boolean
+ startup?: Promise
+ serverScoped?: JSX.Element
}) {
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
@@ -491,7 +506,7 @@ export function AppInterface(props: {
>
-
+
- {routerProps.children}
+ {routerProps.children}
)}
>
-
+
@@ -517,12 +532,16 @@ export function AppInterface(props: {
)
}
-function Routes() {
+function Routes(props: { serverScoped?: JSX.Element }) {
const settings = useSettings()
return (
<>
-
+ (
+ {routeProps.children}
+ )}
+ >
{}
} />
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 95c6940d70..960617f443 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -312,7 +312,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
- const isReady = createMemo(() => ready() && !!state.active)
+ const isReady = Object.assign(createMemo(() => ready() && !!state.active), { promise: ready.promise })
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 2bc9262fde..ced001cecc 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,4 +1,10 @@
export { AppBaseProviders, AppInterface } from "./app"
+export { useLayout } from "./context/layout"
+export { useServerSDK } from "./context/server-sdk"
+export { useServerSync } from "./context/server-sync"
+export { useServer } from "./context/server"
+export { useTabs } from "./context/tabs"
+export { useProviders } from "./hooks/use-providers"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx
index fdb9929eca..461be60450 100644
--- a/packages/app/src/pages/new-session.tsx
+++ b/packages/app/src/pages/new-session.tsx
@@ -26,7 +26,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
-const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -63,7 +63,9 @@ export default function NewSessionPage() {
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
+ const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const newSessionWorktree = createMemo(() => {
+ if (!showWorkspaceBar()) return "main"
if (store.worktree) return store.worktree
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
@@ -123,7 +125,7 @@ export default function NewSessionPage() {
}
>
-
+
-
+
pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
+ isFirstLaunchOnboardingPending,
+ finishFirstLaunchOnboarding,
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 22560ff7da..073d288e5c 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -27,6 +27,8 @@ type Deps = {
consumeInitialDeepLinks: () => Promise | string[]
getDefaultServerUrl: () => Promise | string | null
setDefaultServerUrl: (url: string | null) => Promise | void
+ isFirstLaunchOnboardingPending: () => Promise | boolean
+ finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null
getDisplayBackend: () => Promise
setDisplayBackend: (backend: string | null) => Promise | void
parseMarkdown: (markdown: string) => Promise | string
@@ -50,6 +52,10 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
+ ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
+ ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
+ deps.finishFirstLaunchOnboarding(createDefaultProject),
+ )
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
diff --git a/packages/desktop/src/main/onboarding.ts b/packages/desktop/src/main/onboarding.ts
new file mode 100644
index 0000000000..926506e7b9
--- /dev/null
+++ b/packages/desktop/src/main/onboarding.ts
@@ -0,0 +1,28 @@
+import { mkdir } from "node:fs/promises"
+import { join } from "node:path"
+import { app } from "electron"
+import { getStore } from "./store"
+import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
+import { write as writeLog } from "./logging"
+
+const DEFAULT_PROJECT_DIR = "New OpenCode Project"
+
+export function isFirstLaunchOnboardingPending() {
+ const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
+ writeLog("onboarding", "first launch onboarding pending checked", { pending })
+ return pending
+}
+
+export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
+ if (!isFirstLaunchOnboardingPending()) {
+ writeLog("onboarding", "first launch onboarding already completed")
+ return null
+ }
+
+ const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
+ if (defaultProject) await mkdir(defaultProject, { recursive: true })
+
+ getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
+ writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
+ return defaultProject
+}
diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts
index 270ffe7504..506924b74a 100644
--- a/packages/desktop/src/main/store-keys.ts
+++ b/packages/desktop/src/main/store-keys.ts
@@ -1,5 +1,6 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
+export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds"
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 2cb8a9ee07..47a757a557 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -59,6 +59,9 @@ const api: ElectronAPI = {
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
+ isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
+ finishFirstLaunchOnboarding: (createDefaultProject) =>
+ ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index 5401c2070d..d7509580ad 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -49,6 +49,8 @@ export type ElectronAPI = {
consumeInitialDeepLinks: () => Promise
getDefaultServerUrl: () => Promise
setDefaultServerUrl: (url: string | null) => Promise
+ isFirstLaunchOnboardingPending: () => Promise
+ finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise
getDisplayBackend: () => Promise
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise
parseMarkdownCommand: (markdown: string) => Promise
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 5369ab21e3..966fb0c0bf 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -23,6 +23,7 @@ import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
+import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
import "./styles.css"
@@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const router = (props: BaseRouterProps) => (
)
+ const onboarding = Promise.withResolvers()
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -400,12 +402,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const effectiveDefaultServer = createMemo(() =>
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
)
-
return (
}>
{(key) => (
-
+
+ }
+ >
)}
diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx
new file mode 100644
index 0000000000..47758bc788
--- /dev/null
+++ b/packages/desktop/src/renderer/onboarding.tsx
@@ -0,0 +1,79 @@
+import {
+ ServerConnection,
+ useLayout,
+ useProviders,
+ useServer,
+ useServerSDK,
+ useServerSync,
+ useTabs,
+} from "@opencode-ai/app"
+import { onMount, startTransition } from "solid-js"
+
+export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
+ const server = useServer()
+ const serverSDK = useServerSDK()
+ const serverSync = useServerSync()
+ const layout = useLayout()
+ const providers = useProviders()
+ const tabs = useTabs()
+
+ onMount(() => {
+ void runFirstLaunchOnboarding().finally(props.onLoaded)
+ })
+
+ async function runFirstLaunchOnboarding() {
+ try {
+ await Promise.all(
+ [server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map(
+ (p) => p ?? Promise.resolve(),
+ ),
+ )
+ 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 =
+ 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,
+ 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),
+ })
+
+ const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger)
+ if (!shouldTrigger || !directory) return
+
+ 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 })
+ })
+ } catch (error) {
+ console.error("[desktop-onboarding] first launch onboarding failed", error)
+ }
+ }
+
+ return null
+}