diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b08b0f50..10cfa1ef09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.15.2 - 2026-11-05 + +### Added + +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### Improved + +- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + ## 0.15.1 - 2026-11-05 ### Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 09e516a704..5f0156eb0e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,22 @@ # Changelog +## 0.15.2 - 2026-11-05 + +### 新增 + +- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### 改进 + +- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + ## 0.15.1 - 2026-11-05 ### 修复 diff --git a/fastlane/metadata/android/en-US/changelogs/150021.txt b/fastlane/metadata/android/en-US/changelogs/150021.txt new file mode 100644 index 0000000000..1c9864a4ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150021.txt @@ -0,0 +1,8 @@ +Added +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step + +Improved +- Changed the theme picker to a grid of tiles with a preview of each theme + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150022.txt b/fastlane/metadata/android/en-US/changelogs/150022.txt new file mode 100644 index 0000000000..1c9864a4ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150022.txt @@ -0,0 +1,8 @@ +Added +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step + +Improved +- Changed the theme picker to a grid of tiles with a preview of each theme + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150023.txt b/fastlane/metadata/android/en-US/changelogs/150023.txt new file mode 100644 index 0000000000..1c9864a4ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150023.txt @@ -0,0 +1,8 @@ +Added +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step + +Improved +- Changed the theme picker to a grid of tiles with a preview of each theme + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150024.txt b/fastlane/metadata/android/en-US/changelogs/150024.txt new file mode 100644 index 0000000000..1c9864a4ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150024.txt @@ -0,0 +1,8 @@ +Added +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step + +Improved +- Changed the theme picker to a grid of tiles with a preview of each theme + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index db6aab7828..e4e82efad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.15.1", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.15.1", + "version": "0.15.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -37468,7 +37468,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.15.1", + "version": "0.15.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -39637,13 +39637,13 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", - "@getpaseo/server": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/server": "0.15.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -39890,11 +39890,11 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { - "@getpaseo/protocol": "0.15.1", - "@getpaseo/relay": "0.15.1", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -39906,7 +39906,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -40149,7 +40149,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.15.1", + "version": "0.15.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -40195,7 +40195,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@codemirror/language": "6.12.4", @@ -40225,7 +40225,7 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", @@ -40233,8 +40233,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -40244,8 +40244,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -40267,7 +40267,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "semver": "^7.8.5", @@ -40295,7 +40295,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "^2.4.0", @@ -40517,17 +40517,17 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.15.1", - "@getpaseo/highlight": "0.15.1", - "@getpaseo/plugin": "0.15.1", - "@getpaseo/protocol": "0.15.1", - "@getpaseo/relay": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/highlight": "0.15.2", + "@getpaseo/plugin": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -41474,7 +41474,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.15.1", + "version": "0.15.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -43084,7 +43084,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.15.1", + "version": "0.15.2", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 86b21bee50..b5fd31ae8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.15.1", + "version": "0.15.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/onboarding.spec.ts b/packages/app/e2e/browser/onboarding.spec.ts index 68d54687a9..cf9ab06442 100644 --- a/packages/app/e2e/browser/onboarding.spec.ts +++ b/packages/app/e2e/browser/onboarding.spec.ts @@ -12,11 +12,15 @@ import { openAppAsNewUser, openRelayPairingFromSetup, closeRelayPairing, + emptyHome, + loadingScreen, openTailscaleDocsFromSetup, paseoToolsSwitch, pickTheme, projectSuggestion, projectSuggestionRow, + readFirstLaunch, + recordFirstLaunch, setupModal, setupPrimaryButton, showSetupAgainFromSettings, @@ -99,6 +103,139 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => { } }); +test("a new user goes from the loading screen straight into setup", async ({ page }) => { + const host = await startSetupHost("first-launch"); + + try { + await recordFirstLaunch(page); + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + + const launch = await readFirstLaunch(page); + expect(launch.appBeforeSetup).toBe(false); + expect(launch.setupOpacityOnArrival).toBeLessThan(1); + await expect(setupModal(page)).toHaveCSS("opacity", "1"); + } finally { + await host.close(); + } +}); + +test("with reduced motion, setup appears without fading in", async ({ page }) => { + const host = await startSetupHost("reduced-motion"); + + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + await recordFirstLaunch(page); + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + + expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1); + } finally { + await host.close(); + } +}); + +test("changing the theme keeps the setup window in place on every step", async ({ page }) => { + const host = await startSetupHost("theme-change"); + + try { + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + const setupWindow = await setupModal(page).elementHandle(); + + for (const title of [ + "Welcome to Paseo", + "Your agents", + "Add your projects", + "Let agents use Paseo", + "Take your agents with you", + ]) { + if (title !== "Welcome to Paseo") await continueSetup(page); + await expectSetupStep(page, title); + const heading = setupModal(page).getByRole("heading", { name: title }); + const headingNode = await heading.elementHandle(); + const lightColor = await heading.evaluate((node) => getComputedStyle(node).color); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(heading).not.toHaveCSS("color", lightColor); + await page.emulateMedia({ colorScheme: "light" }); + await expect(heading).toHaveCSS("color", lightColor); + expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true); + expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true); + } + + await goToSetupStep(page, "Appearance"); + await pickTheme(page, "Midnight"); + await pickTheme(page, "Light"); + expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true); + } finally { + await host.close(); + } +}); + +test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => { + const host = await startSetupHost("slow-workspaces"); + const gate = await installDaemonWebSocketGate(page, { port: host.port }); + + try { + await recordFirstLaunch(page); + gate.holdNextClientRequest("fetch_workspaces_request"); + await openAppAsNewUser(page, host); + await gate.waitForHeldClientRequest(); + await expect(loadingScreen(page)).toBeVisible(); + await expect(emptyHome(page)).toHaveCount(0); + + gate.releaseHeldClientRequest(); + await expectSetupStep(page, "Welcome to Paseo"); + expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false); + } finally { + await host.close(); + } +}); + +test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => { + const host = await startSetupHost("stuck-workspaces"); + const gate = await installDaemonWebSocketGate(page, { port: host.port }); + + try { + gate.holdNextClientRequest("fetch_workspaces_request"); + await openAppAsNewUser(page, host); + await gate.waitForHeldClientRequest(); + await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 }); + await expect(setupModal(page)).toHaveCount(0); + + // Setup still opens once the host answers. + gate.releaseHeldClientRequest(); + await expectSetupStep(page, "Welcome to Paseo"); + } finally { + await host.close(); + } +}); + +test("a setup host that drops while loading still lands on the home", async ({ page }) => { + const host = await startSetupHost("dropped-workspaces"); + const gate = await installDaemonWebSocketGate(page, { port: host.port }); + + try { + gate.holdNextClientRequest("fetch_workspaces_request"); + await openAppAsNewUser(page, host); + await gate.waitForHeldClientRequest(); + await gate.drop(); + await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 }); + await expect(setupModal(page)).toHaveCount(0); + } finally { + await host.close(); + } +}); + +test("an unreachable setup host lands on the home without setup", async ({ page }) => { + const host = await startSetupHost("unreachable"); + await host.close(); + + await openAppAsNewUser(page, host); + await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 }); + await expect(setupModal(page)).toHaveCount(0); +}); + test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => { const repo = await createTempGitRepo("onboarding-history-"); const repoName = path.basename(repo.path); diff --git a/packages/app/e2e/support/helpers/onboarding.ts b/packages/app/e2e/support/helpers/onboarding.ts index 6f1d4c53b0..4226c1c27e 100644 --- a/packages/app/e2e/support/helpers/onboarding.ts +++ b/packages/app/e2e/support/helpers/onboarding.ts @@ -34,6 +34,54 @@ export async function openAppAsNewUser( await page.goto("/"); } +interface FirstLaunchRecord { + appBeforeSetup: boolean; + setupOpacityOnArrival: number | null; +} + +/** + * Watches the page from its first paint: whether the app's sidebar or home showed before setup, + * and how opaque setup was when it arrived. Call before `openAppAsNewUser`. + */ +export async function recordFirstLaunch(page: Page): Promise { + await page.addInitScript(() => { + const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null }; + Object.assign(window, { __firstLaunch: record }); + const observer = new MutationObserver(() => { + const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]'); + if (setup) { + record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity); + observer.disconnect(); + return; + } + if ( + document.querySelector( + '[data-testid="open-project-submit"], [data-testid="sidebar-settings"]', + ) + ) { + record.appBeforeSetup = true; + } + }); + observer.observe(document, { childList: true, subtree: true }); + }); +} + +export async function readFirstLaunch(page: Page): Promise { + return page.evaluate( + () => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch, + ); +} + +/** The loading screen the app shows while it starts. */ +export function loadingScreen(page: Page): Locator { + return page.getByTestId("startup-splash"); +} + +/** The empty home a host with no projects lands on. */ +export function emptyHome(page: Page): Locator { + return page.getByTestId("open-project-submit"); +} + export function setupModal(page: Page): Locator { return page.getByTestId("onboarding-modal").getByRole("dialog"); } diff --git a/packages/app/package.json b/packages/app/package.json index 3fa80be49a..2c897be9d4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.15.1", + "version": "0.15.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d5b6794c03..8548b4bb76 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -46,7 +46,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { ChangelogHost } from "@/changelog"; -import { OnboardingHost } from "@/onboarding"; +import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -151,6 +151,7 @@ export interface HostRuntimeBootstrapState { splashError: string | null; retry: () => void; hasGivenUpWaitingForHost: boolean; + isWaitingForSetup: boolean; storeReady: boolean; startupBlocker: StartupBlocker; } @@ -159,6 +160,7 @@ const HostRuntimeBootstrapContext = createContext({ splashError: null, retry: () => {}, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, storeReady: false, startupBlocker: { kind: "none" }, }); @@ -255,6 +257,9 @@ function useDaemonStartIsRunning(): boolean { } const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; +// How long the splash waits on top of host startup to learn whether setup opens. A slow host +// lands on the home instead, and setup opens over it once it is decided. +const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000; async function shouldStartBuiltInDaemon(): Promise { if (!shouldUseDesktopDaemon()) { @@ -316,6 +321,23 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }; }, [shouldRunGiveUpTimer]); + const isSetupDecisionPending = useIsSetupDecisionPending(); + const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false); + const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup; + const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none"; + + useEffect(() => { + if (!shouldRunSetupWaitTimer) { + return; + } + const handle = setTimeout(() => { + setHasGivenUpWaitingForSetup(true); + }, STARTUP_SETUP_WAIT_TIMEOUT_MS); + return () => { + clearTimeout(handle); + }; + }, [shouldRunSetupWaitTimer]); + const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon }); @@ -326,8 +348,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const storeReady = resolveStartupNavigationReady({ startupBlocker }); const state = useMemo( - () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }), - [splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker], + () => ({ + splashError, + retry, + hasGivenUpWaitingForHost, + isWaitingForSetup, + storeReady, + startupBlocker, + }), + [splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker], ); return ( @@ -529,7 +558,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon - @@ -540,6 +568,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + {/* Setup changes the theme while it is open, so it stays out of the remount above. Its + colors are CSS variables and themed props, which update in place, and setup changes no + font size. */} + ); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index e0649f65a1..f6850a2a57 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -45,11 +45,19 @@ export default function Index() { }), isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, + isWaitingForSetup: bootstrapState.isWaitingForSetup, }); + const splash = ; if (startupRoute.kind === "redirect") { - return ; + // The loading screen stays up until the target route paints; a redirect alone renders nothing. + return ( + <> + {splash} + + + ); } - return ; + return splash; } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index bff7fe34f6..7e28f3d81a 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -232,6 +232,28 @@ const styles = StyleSheet.create((theme) => ({ })); const WEB_EXIT_DURATION_MS = 160; +const WEB_ENTER_DURATION_MS = 160; +const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter"; + +// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed +// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens +// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced +// motion the keyframes are never defined, so the card appears at once. +function ensureWebEnterKeyframes() { + if (!isWeb || typeof document === "undefined") return; + if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return; + const style = document.createElement("style"); + style.id = WEB_ENTER_ANIMATION_NAME; + style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`; + document.head.appendChild(style); +} +ensureWebEnterKeyframes(); +// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts. +const WEB_ENTER_STYLE = { + animationName: WEB_ENTER_ANIMATION_NAME, + animationDuration: `${WEB_ENTER_DURATION_MS}ms`, + animationTimingFunction: "ease-out", +} as unknown as ViewStyle; /** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ export function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -662,6 +684,7 @@ export function AdaptiveModalSheet({ styles.desktopCard, desktopHeight != null && { height: desktopHeight }, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }, + isWeb && WEB_ENTER_STYLE, ], [desktopMaxWidth, desktopHeight], ); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 6e1e130090..fe7687886c 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -177,6 +177,7 @@ describe("resolveStartupRoute", () => { workspaceSelectionStatus: "unknown" as const, isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, }; const baseHostInput = { route: { kind: "host" as const, serverId: "server-saved" }, @@ -278,6 +279,28 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "redirect", href: "/h/srv-desktop" }); }); + it("holds the splash over the empty home while setup may still open", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "srv-desktop" }], + anyOnlineHostServerId: "srv-desktop", + isWaitingForSetup: true, + }), + ).toEqual({ kind: "splash" }); + }); + + it("restores a saved workspace without waiting for setup", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-1" }], + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + isWaitingForSetup: true, + }), + ).toEqual({ kind: "redirect", href: "/h/server-1" }); + }); + it("keeps a known connecting host in app-owned routing instead of showing welcome", () => { expect( resolveStartupRoute({ diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 8ee1f7bfb2..c78b40c1cc 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -117,6 +117,8 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn workspaceSelectionStatus: WorkspaceSelectionStatus; isWorkspaceSelectionLoaded: boolean; hasGivenUpWaitingForHost: boolean; + /** Setup may still open over the empty home; see `useIsSetupDecisionPending`. */ + isWaitingForSetup: boolean; } export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput { @@ -198,6 +200,12 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St }; } + // Without a workspace to restore, startup lands on the empty home. A new user's setup opens + // over it, so hold the splash until that is known instead of flashing the home first. + if (input.isWaitingForSetup) { + return { kind: "splash" }; + } + if (input.anyOnlineHostServerId) { return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) }; } diff --git a/packages/app/src/onboarding/index.ts b/packages/app/src/onboarding/index.ts index 68c80d8f0d..78d3da6016 100644 --- a/packages/app/src/onboarding/index.ts +++ b/packages/app/src/onboarding/index.ts @@ -2,8 +2,10 @@ * First-run setup: a one-time modal that walks a new user through the surfaces Settings * already has — appearance, providers, projects, Paseo tools and phone pairing. * - * The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has, - * and how completion is remembered stay inside; import from `@/onboarding` only. + * The app shell mounts the host, startup waits for its decision, and Settings reopens it. Whether + * it opens, which steps it has, and how completion is remembered stay inside; import from + * `@/onboarding` only. */ export { OnboardingHost } from "./onboarding-host"; export { useOpenOnboarding } from "./use-open-onboarding"; +export { useIsSetupDecisionPending } from "./use-setup-decision-pending"; diff --git a/packages/app/src/onboarding/use-setup-decision-pending.ts b/packages/app/src/onboarding/use-setup-decision-pending.ts new file mode 100644 index 0000000000..5918833ce6 --- /dev/null +++ b/packages/app/src/onboarding/use-setup-decision-pending.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import { isNative } from "@/constants/platform"; +import { useHostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** + * True while this launch may still open setup: it has not been decided, and the setup host is + * connecting, or connected and loading its workspaces. Startup keeps its loading screen up + * meanwhile, so a new user lands on setup rather than on the empty home first. False on phones, + * where setup never opens, when there is no setup host, and while the host is unreachable. + */ +export const useIsSetupDecisionPending: () => boolean = isNative + ? () => false + : useIsSetupDecisionPendingOnDesktop; + +function useIsSetupDecisionPendingOnDesktop(): boolean { + const model = getOnboardingModel(); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const serverId = useSetupHostServerId(); + const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); + if (state.status !== "deciding" || !serverId) return false; + return connectionStatus !== "offline" && connectionStatus !== "error"; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index bd3b13a4f1..9248988869 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo CLI - control your AI coding agents from the command line", "license": "Apache-2.0", "bin": { @@ -29,9 +29,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", - "@getpaseo/server": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/server": "0.15.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 3f6edeaf18..62e5b8c664 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo client SDK package", "license": "Apache-2.0", "files": [ @@ -48,8 +48,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.15.1", - "@getpaseo/relay": "0.15.1", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 788716cef7..7e3b01dfaa 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.15.1", + "version": "0.15.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index df44ab2a92..b9c1190375 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.15.1", + "version": "0.15.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 5f40283e3c..85d9c90471 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.15.1", + "version": "0.15.2", "license": "Apache-2.0", "files": [ "dist", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 262d57381e..ef537c35e3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo plugin SDK package", "license": "Apache-2.0", "files": [ @@ -78,8 +78,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -89,8 +89,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.15.1", - "@getpaseo/protocol": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index d802f6afe3..4667c9abe1 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo shared protocol schemas and wire types", "license": "Apache-2.0", "files": [ diff --git a/packages/relay/package.json b/packages/relay/package.json index d4f7af0c0c..6f17d20847 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo relay for bridging daemon and client connections", "license": "Apache-2.0", "files": [ diff --git a/packages/server/package.json b/packages/server/package.json index 2c9615da7a..471e558b77 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.15.1", + "version": "0.15.2", "description": "Paseo backend server", "license": "Apache-2.0", "files": [ @@ -108,11 +108,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.15.1", - "@getpaseo/highlight": "0.15.1", - "@getpaseo/plugin": "0.15.1", - "@getpaseo/protocol": "0.15.1", - "@getpaseo/relay": "0.15.1", + "@getpaseo/client": "0.15.2", + "@getpaseo/highlight": "0.15.2", + "@getpaseo/plugin": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 3d9aa70ff8..696ebb8a51 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.15.1", + "version": "0.15.2", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index 829209bd2a..1e56036650 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.15.1", + "version": "0.15.2", "private": true, "type": "module", "scripts": {