fix(desktop): gate first launch onboarding (#34930)
This commit is contained in:
parent
7f57d2a9ac
commit
dffecb6478
12 changed files with 204 additions and 43 deletions
|
|
@ -126,10 +126,10 @@ function SelectedServerProviders(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerLayout(props: ParentProps) {
|
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +263,14 @@ function DesktopCommands() {
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
sessionID?: () => string | undefined
|
||||||
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
|
{props.serverScoped}
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
|
|
@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewAppLayout(props: ParentProps) {
|
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<NewLayout>{props.children}</NewLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</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 server = useServer()
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
|
@ -376,34 +378,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||||
const checking = createMemo(
|
const checking = createMemo(
|
||||||
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
() => 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 (
|
return (
|
||||||
<Show
|
<>
|
||||||
when={!checking()}
|
<Show when={!checking()}>
|
||||||
fallback={
|
<Show
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
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" />
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
</div>
|
</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>
|
||||||
</Show>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,6 +483,8 @@ export function AppInterface(props: {
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
|
startup?: Promise<void>
|
||||||
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual new layout lives in the router root so it remains mounted across
|
// 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
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
|
|
@ -491,7 +506,7 @@ export function AppInterface(props: {
|
||||||
>
|
>
|
||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
|
|
@ -500,14 +515,14 @@ export function AppInterface(props: {
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ServerShell>
|
<ServerShell>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</Show>
|
</Show>
|
||||||
</ServerShell>
|
</ServerShell>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes />
|
<Routes serverScoped={props.serverScoped} />
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</Show>
|
</Show>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
|
|
@ -517,12 +532,16 @@ export function AppInterface(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes() {
|
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route component={LegacyServerLayout}>
|
<Route
|
||||||
|
component={(routeProps) => (
|
||||||
|
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
|
|
||||||
|
|
@ -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 scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
const projects = createServerProjects({ scope, store, setStore })
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
export { AppBaseProviders, AppInterface } from "./app"
|
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 { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
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
|
* 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 [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
|
|
||||||
|
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
|
if (!showWorkspaceBar()) return "main"
|
||||||
if (store.worktree) return store.worktree
|
if (store.worktree) return store.worktree
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||||
|
|
@ -123,7 +125,7 @@ export default function NewSessionPage() {
|
||||||
</div>
|
</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
|
<PromptInput
|
||||||
controls={inputController()}
|
controls={inputController()}
|
||||||
variant="new-session"
|
variant="new-session"
|
||||||
|
|
@ -143,15 +145,15 @@ export default function NewSessionPage() {
|
||||||
<div
|
<div
|
||||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||||
classList={{
|
classList={{
|
||||||
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||||
"justify-start": !showWorkspaceBar,
|
"justify-start": !showWorkspaceBar(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PromptProjectSelector
|
<PromptProjectSelector
|
||||||
controller={projectController}
|
controller={projectController}
|
||||||
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
||||||
/>
|
/>
|
||||||
<Show when={showWorkspaceBar}>
|
<Show when={showWorkspaceBar()}>
|
||||||
<PromptWorkspaceSelector
|
<PromptWorkspaceSelector
|
||||||
value={newSessionWorktree()}
|
value={newSessionWorktree()}
|
||||||
projectRoot={projectRoot()}
|
projectRoot={projectRoot()}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { forwardInitializationFailure } from "./initialization"
|
||||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||||
import { parseMarkdown } from "./markdown"
|
import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
|
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
|
||||||
import {
|
import {
|
||||||
getDefaultServerUrl,
|
getDefaultServerUrl,
|
||||||
preferAppEnv,
|
preferAppEnv,
|
||||||
|
|
@ -277,6 +278,8 @@ const main = Effect.gen(function* () {
|
||||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||||
|
isFirstLaunchOnboardingPending,
|
||||||
|
finishFirstLaunchOnboarding,
|
||||||
getDisplayBackend: async () => null,
|
getDisplayBackend: async () => null,
|
||||||
setDisplayBackend: async () => undefined,
|
setDisplayBackend: async () => undefined,
|
||||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ type Deps = {
|
||||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||||
|
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
|
||||||
|
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
|
||||||
getDisplayBackend: () => Promise<string | null>
|
getDisplayBackend: () => Promise<string | null>
|
||||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
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) =>
|
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||||
deps.setDefaultServerUrl(url),
|
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("get-display-backend", () => deps.getDisplayBackend())
|
||||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||||
deps.setDisplayBackend(backend),
|
deps.setDisplayBackend(backend),
|
||||||
|
|
|
||||||
28
packages/desktop/src/main/onboarding.ts
Normal file
28
packages/desktop/src/main/onboarding.ts
Normal file
|
|
@ -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,5 +1,6 @@
|
||||||
export const SETTINGS_STORE = "opencode.settings"
|
export const SETTINGS_STORE = "opencode.settings"
|
||||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||||
|
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
|
||||||
export const WSL_SERVERS_KEY = "wslServers"
|
export const WSL_SERVERS_KEY = "wslServers"
|
||||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||||
export const WINDOW_IDS_KEY = "windowIds"
|
export const WINDOW_IDS_KEY = "windowIds"
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ const api: ElectronAPI = {
|
||||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", 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"),
|
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
||||||
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
||||||
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export type ElectronAPI = {
|
||||||
consumeInitialDeepLinks: () => Promise<string[]>
|
consumeInitialDeepLinks: () => Promise<string[]>
|
||||||
getDefaultServerUrl: () => Promise<string | null>
|
getDefaultServerUrl: () => Promise<string | null>
|
||||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||||
|
isFirstLaunchOnboardingPending: () => Promise<boolean>
|
||||||
|
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
|
||||||
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
||||||
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
||||||
parseMarkdownCommand: (markdown: string) => Promise<string>
|
parseMarkdownCommand: (markdown: string) => Promise<string>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { render } from "solid-js/web"
|
||||||
import pkg from "../../package.json"
|
import pkg from "../../package.json"
|
||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
import { initializationData, initializationReady } from "./initialization"
|
import { initializationData, initializationReady } from "./initialization"
|
||||||
|
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||||
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
||||||
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
|
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
|
|
@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
||||||
const router = (props: BaseRouterProps) => (
|
const router = (props: BaseRouterProps) => (
|
||||||
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
|
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
|
||||||
)
|
)
|
||||||
|
const onboarding = Promise.withResolvers<void>()
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
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(() =>
|
const effectiveDefaultServer = createMemo(() =>
|
||||||
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
|
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={ready()} fallback={<LoadingSplash />}>
|
<Show when={ready()} fallback={<LoadingSplash />}>
|
||||||
<Show when={effectiveDefaultServer()} keyed>
|
<Show when={effectiveDefaultServer()} keyed>
|
||||||
{(key) => (
|
{(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 />
|
<Inner />
|
||||||
</AppInterface>
|
</AppInterface>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
79
packages/desktop/src/renderer/onboarding.tsx
Normal file
79
packages/desktop/src/renderer/onboarding.tsx
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue