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 +}