Forráskód Böngészése

fix(desktop): gate first launch onboarding (#34930)

Brendan Allan 1 hónapja
szülő
commit
dffecb6478

+ 53 - 34
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 (
     <SelectedServerProviders>
-      <LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
+      <LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
     </SelectedServerProviders>
   )
 }
@@ -263,12 +263,14 @@ function DesktopCommands() {
 type ServerScopedShellProps = ParentProps<{
   directory?: () => string | undefined
   sessionID?: () => string | undefined
+  serverScoped?: JSX.Element
 }>
 
 function ServerScopedProviders(props: ServerScopedShellProps) {
   return (
     <PermissionProvider directory={props.directory}>
       <LayoutProvider>
+        {props.serverScoped}
         <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
       </LayoutProvider>
     </PermissionProvider>
@@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
 
 function LegacyServerScopedShell(props: ServerScopedShellProps) {
   return (
-    <ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
+    <ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
       <LegacyLayout>{props.children}</LegacyLayout>
     </ServerScopedProviders>
   )
 }
 
-function NewAppLayout(props: ParentProps) {
+function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
   return (
     <SelectedServerProviders>
-      <ServerScopedProviders>
+      <ServerScopedProviders serverScoped={props.serverScoped}>
         <NewLayout>{props.children}</NewLayout>
       </ServerScopedProviders>
     </SelectedServerProviders>
@@ -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<void> }>) {
   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 (
-    <Show
-      when={!checking()}
-      fallback={
-        <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+    <>
+      <Show when={!checking()}>
+        <Show
+          when={startupHealthCheck.latest}
+          fallback={
+            <ConnectionError
+              onRetry={() => {
+                if (checkMode() === "background") void healthCheckActions.refetch()
+              }}
+              onServerSelected={(key) => {
+                setCheckMode("blocking")
+                server.setActive(key)
+                void healthCheckActions.refetch()
+              }}
+            />
+          }
+        >
+          {props.children}
+        </Show>
+      </Show>
+      <Show when={loading()}>
+        <div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
           <Splash class="w-16 h-20 opacity-50 animate-pulse" />
         </div>
-      }
-    >
-      <Show
-        when={startupHealthCheck.latest}
-        fallback={
-          <ConnectionError
-            onRetry={() => {
-              if (checkMode() === "background") void healthCheckActions.refetch()
-            }}
-            onServerSelected={(key) => {
-              setCheckMode("blocking")
-              server.setActive(key)
-              void healthCheckActions.refetch()
-            }}
-          />
-        }
-      >
-        {props.children}
       </Show>
-    </Show>
+    </>
   )
 }
 
@@ -470,6 +483,8 @@ export function AppInterface(props: {
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
   disableHealthCheck?: boolean
+  startup?: Promise<void>
+  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: {
     >
       <GlobalProvider>
         <SettingsProvider>
-          <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+          <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
             <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
               <Dynamic
                 component={props.router ?? Router}
@@ -500,14 +515,14 @@ export function AppInterface(props: {
                     <NotificationProvider>
                       <ServerShell>
                         <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
-                          <NewAppLayout>{routerProps.children}</NewAppLayout>
+                          <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
                         </Show>
                       </ServerShell>
                     </NotificationProvider>
                   </TabsProvider>
                 )}
               >
-                <Routes />
+                <Routes serverScoped={props.serverScoped} />
               </Dynamic>
             </Show>
           </ConnectionGate>
@@ -517,12 +532,16 @@ export function AppInterface(props: {
   )
 }
 
-function Routes() {
+function Routes(props: { serverScoped?: JSX.Element }) {
   const settings = useSettings()
 
   return (
     <>
-      <Route component={LegacyServerLayout}>
+      <Route
+        component={(routeProps) => (
+          <LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
+        )}
+      >
         <Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
         <Route path="/:dir" component={DirectoryLayout}>
           <Route path="/" component={() => <Navigate href="session" />} />

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

+ 6 - 0
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"

+ 8 - 6
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() {
                     </div>
                   }
                 >
-                  <div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
+                  <div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
                     <PromptInput
                       controls={inputController()}
                       variant="new-session"
@@ -143,15 +145,15 @@ export default function NewSessionPage() {
                       <div
                         class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
                         classList={{
-                          "flex-col justify-center sm:flex-row": showWorkspaceBar,
-                          "justify-start": !showWorkspaceBar,
+                          "flex-col justify-center sm:flex-row": showWorkspaceBar(),
+                          "justify-start": !showWorkspaceBar(),
                         }}
                       >
                         <PromptProjectSelector
                           controller={projectController}
-                          placement={showWorkspaceBar ? "bottom" : "bottom-start"}
+                          placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
                         />
-                        <Show when={showWorkspaceBar}>
+                        <Show when={showWorkspaceBar()}>
                           <PromptWorkspaceSelector
                             value={newSessionWorktree()}
                             projectRoot={projectRoot()}

+ 3 - 0
packages/desktop/src/main/index.ts

@@ -19,6 +19,7 @@ import { forwardInitializationFailure } from "./initialization"
 import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
+import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
 import {
   getDefaultServerUrl,
   preferAppEnv,
@@ -277,6 +278,8 @@ const main = Effect.gen(function* () {
     consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
     getDefaultServerUrl: () => getDefaultServerUrl(),
     setDefaultServerUrl: (url) => setDefaultServerUrl(url),
+    isFirstLaunchOnboardingPending,
+    finishFirstLaunchOnboarding,
     getDisplayBackend: async () => null,
     setDisplayBackend: async () => undefined,
     parseMarkdown: async (markdown) => parseMarkdown(markdown),

+ 6 - 0
packages/desktop/src/main/ipc.ts

@@ -27,6 +27,8 @@ type Deps = {
   consumeInitialDeepLinks: () => Promise<string[]> | string[]
   getDefaultServerUrl: () => Promise<string | null> | string | null
   setDefaultServerUrl: (url: string | null) => Promise<void> | void
+  isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
+  finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
   getDisplayBackend: () => Promise<string | null>
   setDisplayBackend: (backend: string | null) => Promise<void> | void
   parseMarkdown: (markdown: string) => Promise<string> | 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),

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

+ 1 - 0
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"

+ 3 - 0
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),

+ 2 - 0
packages/desktop/src/preload/types.ts

@@ -49,6 +49,8 @@ export type ElectronAPI = {
   consumeInitialDeepLinks: () => Promise<string[]>
   getDefaultServerUrl: () => Promise<string | null>
   setDefaultServerUrl: (url: string | null) => Promise<void>
+  isFirstLaunchOnboardingPending: () => Promise<boolean>
+  finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
   getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
   setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
   parseMarkdownCommand: (markdown: string) => Promise<string>

+ 14 - 2
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) => (
     <DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
   )
+  const onboarding = Promise.withResolvers<void>()
 
   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 (
       <Show when={ready()} fallback={<LoadingSplash />}>
         <Show when={effectiveDefaultServer()} keyed>
           {(key) => (
-            <AppInterface defaultServer={key} servers={servers()} router={router}>
+            <AppInterface
+              defaultServer={key}
+              servers={servers()}
+              router={router}
+              startup={onboarding.promise}
+              serverScoped={
+                <DesktopFirstLaunchOnboarding
+                  initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
+                  onLoaded={onboarding.resolve}
+                />
+              }
+            >
               <Inner />
             </AppInterface>
           )}

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