diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index b0d86e164e..5e07f5bac0 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -9,9 +9,11 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) => await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await openSettingsSection(page, "appearance"); - const themeTrigger = page.getByLabel("Theme: System", { exact: true }); - await themeTrigger.click(); - await expect(page.getByText("Pure black", { exact: true })).toBeVisible(); + await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked(); + await expect( + page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(), + ).toHaveAccessibleName("System"); + await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("appearance-theme-picker.png"), fullPage: true, diff --git a/packages/app/e2e/browser/onboarding.spec.ts b/packages/app/e2e/browser/onboarding.spec.ts new file mode 100644 index 0000000000..56d2d4cd6c --- /dev/null +++ b/packages/app/e2e/browser/onboarding.spec.ts @@ -0,0 +1,154 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { expect, metroTest as test } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { + addProjectFromSetup, + continueSetup, + expectSetupStep, + finishSetup, + goToSetupStep, + openAppAsNewUser, + openTailscaleDocsFromSetup, + paseoToolsSwitch, + pickTheme, + setupModal, + showSetupAgainFromSettings, + skipSetup, +} from "../support/helpers/onboarding"; +import { enableRelayAndExpectOffer } from "../support/helpers/pair-device"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openSettingsHostSection } from "../support/helpers/settings"; +import { createTempGitRepo } from "../support/helpers/workspace"; + +// Midnight's muted foreground (#9a9db0), which the step description is drawn in. +const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)"; + +function isolatedServerId(label: string): string { + return `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`; +} + +test("a new user is walked through setup once", async ({ page }, testInfo) => { + const serverId = isolatedServerId("fresh"); + const daemon = await startIsolatedHostDaemon(serverId); + const repo = await createTempGitRepo("onboarding-folder-"); + + try { + await openAppAsNewUser(page, { serverId, port: daemon.port }); + + await test.step("setup opens on Appearance and a theme applies at once", async () => { + await expectSetupStep(page, "Welcome to Paseo"); + await pickTheme(page, "Midnight"); + await expect( + setupModal(page).getByText(/^Run Claude Code, Codex and other agents/), + ).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND); + await page.screenshot({ path: testInfo.outputPath("1-appearance.png") }); + }); + + await test.step("Providers shows the same provider list as Settings", async () => { + await continueSetup(page); + await expectSetupStep(page, "Your agents"); + await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("2-providers.png") }); + }); + + await test.step("Add project opens the regular flow and the folder shows as added", async () => { + await continueSetup(page); + await expectSetupStep(page, "Add your projects"); + await addProjectFromSetup(page, repo.path); + await expectSetupStep(page, "Add your projects"); + await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible(); + await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("3-projects.png") }); + }); + + await test.step("Orchestration shows the Paseo tools switch", async () => { + await continueSetup(page); + await expectSetupStep(page, "Let agents use Paseo"); + await expect( + setupModal(page).getByRole("switch", { name: "Inject Paseo tools", exact: true }), + ).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") }); + }); + + await test.step("Skip setup closes it for good", async () => { + await skipSetup(page); + await page.reload(); + await expect( + page.locator('[data-testid^="sidebar-project-row-"]').filter({ + hasText: path.basename(repo.path), + }), + ).toBeVisible({ timeout: 60_000 }); + await expect(setupModal(page)).toHaveCount(0); + }); + } finally { + await daemon.close(); + await repo.cleanup(); + } +}); + +test("an existing user only sees setup when they ask for it", async ({ page }) => { + const serverId = isolatedServerId("existing"); + const daemon = await startIsolatedHostDaemon(serverId); + const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: daemon.port }); + + try { + await openAppAsNewUser(page, { serverId, port: daemon.port }); + await expect( + page.getByTestId(`sidebar-workspace-row-${serverId}:${workspace.workspaceId}`), + ).toBeVisible({ timeout: 60_000 }); + await expect(setupModal(page)).toHaveCount(0); + + await showSetupAgainFromSettings(page); + await expectSetupStep(page, "Welcome to Paseo"); + await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute( + "aria-selected", + "true", + ); + } finally { + await workspace.cleanup(); + await daemon.close(); + } +}); + +test("setup choices carry over to Settings and the phone step pairs over relay", async ({ + page, +}, testInfo) => { + const serverId = isolatedServerId("phone"); + const daemon = await startIsolatedHostDaemon(serverId, { mutableRelay: { enabled: false } }); + + try { + await openAppAsNewUser(page, { serverId, port: daemon.port }); + await expectSetupStep(page, "Welcome to Paseo"); + + await test.step("Paseo tools turned on in setup", async () => { + await goToSetupStep(page, "Orchestration"); + await expectSetupStep(page, "Let agents use Paseo"); + await expect(paseoToolsSwitch(setupModal(page))).not.toBeChecked(); + await paseoToolsSwitch(setupModal(page)).click(); + await expect(paseoToolsSwitch(setupModal(page))).toBeChecked(); + }); + + await test.step("Phone asks for relay consent, then shows the pairing QR", async () => { + await continueSetup(page); + await expectSetupStep(page, "Take your agents with you"); + await expect(setupModal(page).getByText("Enable relay?", { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("5-phone-consent.png") }); + expect(await openTailscaleDocsFromSetup(page)).toBe( + "https://paseo.sh/docs/connectivity#tailscale", + ); + await enableRelayAndExpectOffer(page); + await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") }); + }); + + await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => { + await finishSetup(page); + await page.locator('[data-testid="sidebar-settings"]:visible').first().click(); + await openSettingsHostSection(page, serverId, "agents"); + await expect(paseoToolsSwitch(page)).toBeChecked(); + await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") }); + }); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/browser/plugin-theme.spec.ts b/packages/app/e2e/browser/plugin-theme.spec.ts index a5ef533326..5fbad282dd 100644 --- a/packages/app/e2e/browser/plugin-theme.spec.ts +++ b/packages/app/e2e/browser/plugin-theme.spec.ts @@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; import { openSettingsSection } from "../support/helpers/settings"; @@ -69,8 +68,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async await openSettingsSection(page, "appearance"); const sectionTitle = page.getByText("Theme", { exact: true }).first(); - await page.getByLabel("Theme: System", { exact: true }).click(); - const mochaItem = page.getByText("Catppuccin Mocha", { exact: true }); + const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true }); await expect(mochaItem).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: testInfo.outputPath("plugin-theme-picker.png"), @@ -79,14 +77,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async }); await test.step("a contributed light theme uses the light palette", async () => { - await page.getByText("Catppuccin Latte", { exact: true }).click(); - await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible(); + const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true }); + await latteItem.click(); + await expect(latteItem).toBeChecked(); await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND); - await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click(); }); await mochaItem.click(); - await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible(); + await expect(mochaItem).toBeChecked(); await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND); await page.screenshot({ path: testInfo.outputPath("plugin-theme-applied.png"), @@ -95,15 +93,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async await test.step("the selection survives a reload", async () => { await page.reload(); - await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({ - timeout: 30_000, - }); + await expect(mochaItem).toBeChecked({ timeout: 30_000 }); await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND); }); await test.step("removing the plugin falls back to the default theme", async () => { await client.removePlugin(PLUGIN_ID); - await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({ + await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({ timeout: 30_000, }); await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND); @@ -147,15 +143,7 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) { return () => {}; }`; -async function scrollThemeMenuToLastTheme(page: Page) { - const firstTheme = page.getByText("Pack theme 1", { exact: true }); - await expect(firstTheme).toBeVisible({ timeout: 30_000 }); - await firstTheme.hover(); - await page.mouse.wheel(0, 4000); - await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 }); -} - -test("scrolls to the last theme when a plugin contributes more themes than fit", async ({ +test("reaches the last theme when a plugin contributes more themes than fit", async ({ page, }, testInfo) => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-")); @@ -175,15 +163,16 @@ test("scrolls to the last theme when a plugin contributes more themes than fit", await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await openSettingsSection(page, "appearance"); - await page.getByLabel("Theme: System", { exact: true }).click(); - await scrollThemeMenuToLastTheme(page); + const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true }); + await expect(lastTheme).toBeAttached({ timeout: 30_000 }); + await lastTheme.scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath("plugin-theme-picker-scrolled.png"), animations: "disabled", }); - await page.getByText(LAST_THEME_NAME, { exact: true }).click(); - await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible(); + await lastTheme.click(); + await expect(lastTheme).toBeChecked(); } finally { await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined); await client diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index 89cfb6937d..26e6e4d2d2 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -107,6 +107,8 @@ const daemonTest = metroTest.extend< }); const test = daemonTest.extend<{ + /** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */ + e2eOnboardingCompleted: boolean; paseoE2ESetup: void; outdatedDaemon: OutdatedDaemon; desktopManagedOutdatedDaemon: OutdatedDaemon; @@ -114,8 +116,9 @@ const test = daemonTest.extend<{ projectPickerFixture: TrackedProjectPickerFixture; withWorkspace: WithWorkspace; }>({ + e2eOnboardingCompleted: [true, { option: true }], paseoE2ESetup: [ - async ({ page }, provide, testInfo) => { + async ({ page, e2eOnboardingCompleted }, provide, testInfo) => { const daemonPort = getE2EDaemonPort(); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { @@ -157,7 +160,7 @@ const test = daemonTest.extend<{ const createAgentPreferences = buildCreateAgentPreferences(); await page.addInitScript( - ({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => { + ({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => { // `addInitScript` runs on every navigation (including reloads). Some tests intentionally // override storage and reload; they can opt out of seeding for the *next* navigation by // setting this flag before the reload. @@ -185,12 +188,20 @@ const test = daemonTest.extend<{ if (!localStorage.getItem("@paseo:app-settings")) { localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" })); } + // The shared daemon often has no projects, which would open first-run setup over the app. + if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) { + localStorage.setItem( + "@paseo:onboarding", + JSON.stringify({ completedAt: new Date(0).toISOString() }), + ); + } }, { daemon: testDaemon, preferences: createAgentPreferences, seedNonce, extraHostsKey: EXTRA_HOSTS_KEY, + onboardingCompleted: e2eOnboardingCompleted, }, ); diff --git a/packages/app/e2e/support/helpers/onboarding.ts b/packages/app/e2e/support/helpers/onboarding.ts new file mode 100644 index 0000000000..816d7b4e80 --- /dev/null +++ b/packages/app/e2e/support/helpers/onboarding.ts @@ -0,0 +1,97 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow"; +import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; + +/** Points a fresh browser profile at one host, with no first-run record and no app settings. */ +export async function openAppAsNewUser( + page: Page, + host: { serverId: string; port: number }, +): Promise { + const seededHost = buildSeededHost({ + serverId: host.serverId, + endpoint: `127.0.0.1:${host.port}`, + nowIso: new Date().toISOString(), + }); + await page.route(/:6767\b/, (route) => route.abort()); + await page.routeWebSocket(/:6767\b/, async (webSocket) => { + await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." }); + }); + await page.addInitScript( + ({ registryHost, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { registryHost: seededHost, preferences: buildCreateAgentPreferences() }, + ); + await page.goto("/"); +} + +export function setupModal(page: Page): Locator { + return page.getByRole("dialog").filter({ hasText: "Set up Paseo" }); +} + +export async function expectSetupStep(page: Page, title: string): Promise { + await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({ + timeout: 60_000, + }); +} + +export async function pickTheme(page: Page, name: string): Promise { + const tile = setupModal(page).getByRole("radio", { name, exact: true }); + await tile.click(); + await expect(tile).toBeChecked(); +} + +export async function continueSetup(page: Page): Promise { + await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click(); +} + +export async function skipSetup(page: Page): Promise { + await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click(); + await expect(setupModal(page)).toHaveCount(0); +} + +/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */ +export async function addProjectFromSetup(page: Page, projectPath: string): Promise { + await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click(); + await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 }); + await chooseAddProjectMethod(page, "directory-search"); + await addProjectFlowInput(page).fill(projectPath); + await page.keyboard.press("Enter"); + await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export async function showSetupAgainFromSettings(page: Page): Promise { + await page.locator('[data-testid="sidebar-settings"]:visible').first().click(); + await page.getByRole("button", { name: "Show setup again", exact: true }).click(); +} + +export async function goToSetupStep(page: Page, label: string): Promise { + await setupModal(page).getByRole("tab", { name: label, exact: true }).click(); +} + +export function paseoToolsSwitch(scope: Page | Locator): Locator { + return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true }); +} + +export async function finishSetup(page: Page): Promise { + await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click(); + await expect(setupModal(page)).toHaveCount(0); +} + +/** Clicks the Tailscale docs link in the Phone step and returns the page it opened. */ +export async function openTailscaleDocsFromSetup(page: Page): Promise { + await page + .context() + .route("https://paseo.sh/**", (route) => + route.fulfill({ contentType: "text/html", body: "Paseo docs" }), + ); + const popup = page.waitForEvent("popup"); + await setupModal(page).getByRole("link", { name: "Read how to connect over Tailscale" }).click(); + const docs = await popup; + await docs.waitForLoadState("domcontentloaded"); + const url = docs.url(); + await docs.close(); + return url; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 083c6b2144..1b260368d8 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -35,6 +35,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 { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -494,6 +495,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/appearance/theme-picker.tsx b/packages/app/src/appearance/theme-picker.tsx new file mode 100644 index 0000000000..b6d393782a --- /dev/null +++ b/packages/app/src/appearance/theme-picker.tsx @@ -0,0 +1,280 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import type { PluginThemeOption } from "@/plugins/themes"; +import { + THEME_OPTIONS, + PLUGIN_THEME_PREFERENCE, + darkTheme, + lightTheme, + type Theme, +} from "@/styles/theme"; +import { useContributedThemes } from "./provider"; + +type BuiltInThemePreference = Exclude; + +// System leads the grid because it is the default. +const PICKER_THEME_OPTIONS = [ + ...THEME_OPTIONS.filter((option) => option.name === "auto"), + ...THEME_OPTIONS.filter((option) => option.name !== "auto"), +]; + +const PREVIEW_WIDTH = 128; +const PREVIEW_HEIGHT = 72; + +/** + * Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies + * the theme to the whole app at once. Settings → Appearance and first-run setup share it. + */ +export function ThemePicker() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes(); + // A selected contribution that is no longer installed shows the fallback the app renders. + const builtInValue = + settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme; + + const selectBuiltIn = useCallback( + (theme: BuiltInThemePreference) => { + void updateSettings({ theme }); + }, + [updateSettings], + ); + + return ( + + {PICKER_THEME_OPTIONS.map((option) => ( + + ))} + {pluginThemes.map((option) => ( + + ))} + + ); +} + +function BuiltInThemeTile({ + value, + selected, + onSelect, +}: { + value: BuiltInThemePreference; + selected: boolean; + onSelect: (value: BuiltInThemePreference) => void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => onSelect(value), [onSelect, value]); + const option = THEME_OPTIONS.find((candidate) => candidate.name === value); + return ( + + ); +} + +function PluginThemeTile({ + option, + selected, + onSelect, +}: { + option: PluginThemeOption; + selected: boolean; + onSelect: (option: PluginThemeOption) => void; +}) { + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + return ( + + ); +} + +function ThemeTile({ + label, + selected, + onPress, + previewTheme, +}: { + label: string; + selected: boolean; + onPress: () => void; + /** "system" follows the OS, so its preview shows both halves. */ + previewTheme: Theme | "system"; +}) { + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const tileStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.tile, + hovered && !selected && styles.tileHovered, + selected && styles.tileSelected, + ], + [selected], + ); + return ( + + + {previewTheme === "system" ? : } + + + {label} + + + ); +} + +/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */ +function ThemePreview({ theme }: { theme: Theme }) { + const colors = theme.colors; + const previewStyles = useMemo( + () => ({ + root: [styles.preview, { backgroundColor: colors.surface0 }], + sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }], + mutedLine: { backgroundColor: colors.foregroundMuted }, + strongLine: { backgroundColor: colors.foreground }, + bubble: [styles.previewBubble, { backgroundColor: colors.accent }], + }), + [colors], + ); + return ( + + + + + + + + + + + + + + ); +} + +/** "System" follows the OS: light on the left half, dark on the right. */ +function SystemThemePreview() { + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + grid: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[3], + }, + tile: { + width: PREVIEW_WIDTH + theme.spacing[1] * 2 + theme.borderWidth[2] * 2, + padding: theme.spacing[1], + gap: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[2], + borderColor: "transparent", + }, + tileHovered: { + backgroundColor: theme.colors.surface2, + }, + tileSelected: { + borderColor: theme.colors.accent, + }, + previewFrame: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + label: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[1], + }, + preview: { + width: PREVIEW_WIDTH, + height: PREVIEW_HEIGHT, + flexDirection: "row", + overflow: "hidden", + }, + previewSidebar: { + width: 36, + paddingHorizontal: 6, + paddingVertical: 8, + gap: 5, + }, + previewMain: { + flex: 1, + padding: 8, + gap: 5, + }, + previewLine: { + height: 4, + borderRadius: 2, + opacity: 0.6, + }, + previewTitle: { + width: "55%", + height: 5, + borderRadius: 2, + }, + sidebarLineWide: { width: "100%" }, + sidebarLineNarrow: { width: "60%" }, + sidebarLineMedium: { width: "80%" }, + mainLineWide: { width: "90%" }, + mainLineNarrow: { width: "65%" }, + previewBubble: { + marginTop: "auto", + alignSelf: "flex-end", + width: "45%", + height: 10, + borderRadius: 5, + }, + systemDarkHalf: { + position: "absolute", + top: 0, + bottom: 0, + left: PREVIEW_WIDTH / 2, + width: PREVIEW_WIDTH / 2, + overflow: "hidden", + }, + systemDarkOffset: { + marginLeft: -PREVIEW_WIDTH / 2, + }, +})); diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx index 9214bbd428..1274ab3d3e 100644 --- a/packages/app/src/components/sidebar-agent-list-skeleton.tsx +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef } from "react"; -import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; +import { useMemo } from "react"; +import { Animated, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { projectIconRadius } from "@/components/project-icon-view"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; const PROJECT_ICON_SIZE = 16; @@ -11,17 +12,6 @@ const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.ma [0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`), ); -function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp }) { - const opacity = pulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.4, 0.8], - }); - - const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); - - return ; -} - function SkeletonSection({ pulse, sectionOpacity, @@ -57,27 +47,7 @@ function SkeletonSection({ } export function SidebarAgentListSkeleton() { - const pulse = useRef(new Animated.Value(0)).current; - - useEffect(() => { - const animation = Animated.loop( - Animated.sequence([ - Animated.timing(pulse, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(pulse, { - toValue: 0, - duration: 1000, - useNativeDriver: true, - }), - ]), - ); - - animation.start(); - return () => animation.stop(); - }, [pulse]); + const pulse = useSkeletonPulse(); return ( diff --git a/packages/app/src/components/ui/skeleton.tsx b/packages/app/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000..76b710913d --- /dev/null +++ b/packages/app/src/components/ui/skeleton.tsx @@ -0,0 +1,34 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Animated, type StyleProp, type ViewStyle } from "react-native"; + +/** One looping value per skeleton, shared by its shapes so they pulse together. */ +export function useSkeletonPulse(): Animated.Value { + const pulse = useRef(new Animated.Value(0)).current; + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }), + Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [pulse]); + return pulse; +} + +/** A placeholder shape; `style` sets its size, radius and color. */ +export function SkeletonPulse({ + pulse, + style, +}: { + pulse: Animated.Value; + style: StyleProp; +}) { + const opacity = pulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.4, 0.8], + }); + const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); + return ; +} diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 982469008a..b960222bb9 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/text-input"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; +const TAILSCALE_DOCS_URL = "https://paseo.sh/docs/connectivity#tailscale"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedShieldCheck = withUnistyles(ShieldCheck); @@ -219,7 +220,14 @@ function RelayConsent(props: PairDeviceBodyProps) { - {t("pairing.device.directConnectionHint")} + + {t("pairing.device.directConnectionHint")} + + ); @@ -338,8 +346,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, marginTop: 1, // optical: seats the glyph on the hint's first text line }, - directHint: { + directContent: { flex: 1, + alignItems: "flex-start", + gap: theme.spacing[2], + }, + directHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.5, diff --git a/packages/app/src/git/pull-request-panel/activity-skeleton.tsx b/packages/app/src/git/pull-request-panel/activity-skeleton.tsx index 72c315f3b6..473d6e6e02 100644 --- a/packages/app/src/git/pull-request-panel/activity-skeleton.tsx +++ b/packages/app/src/git/pull-request-panel/activity-skeleton.tsx @@ -1,39 +1,9 @@ -import { useEffect, useMemo, useRef } from "react"; -import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; +import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`); -export function useSkeletonPulse(): Animated.Value { - const pulse = useRef(new Animated.Value(0)).current; - useEffect(() => { - const animation = Animated.loop( - Animated.sequence([ - Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }), - Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }), - ]), - ); - animation.start(); - return () => animation.stop(); - }, [pulse]); - return pulse; -} - -export function SkeletonPulse({ - pulse, - style, -}: { - pulse: Animated.Value; - style: StyleProp; -}) { - const opacity = pulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.4, 0.8], - }); - const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); - return ; -} - export function PrActivitySkeleton() { const pulse = useSkeletonPulse(); diff --git a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx index 9ddeeadf2c..d7ab363e49 100644 --- a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx +++ b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx @@ -2,7 +2,8 @@ import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; +import { PrActivitySkeleton } from "./activity-skeleton"; const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8d7c2a2950..ec35081cc7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1641,6 +1641,69 @@ export const ar: TranslationResources = { }, }, onboarding: { + setup: { + title: "إعداد Paseo", + footnote: "كل ما هنا موجود أيضًا في الإعدادات", + stepCounter: "الخطوة {{current}} من {{total}}", + skip: "تخطي الإعداد", + continue: "متابعة", + finish: "إنهاء", + steps: { + appearance: { + label: "المظهر", + title: "مرحبًا بك في Paseo", + description: + "شغّل Claude Code وCodex ووكلاء آخرين على هذا الكمبيوتر، من مكتبك أو من هاتفك. اختر مظهرًا أولًا.", + }, + providers: { + label: "المزوّدون", + title: "وكلاؤك", + description: + "يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر. أوقف ما لا تريده في منتقي الوكلاء.", + }, + projects: { + label: "المشاريع", + title: "أضف مشاريعك", + description: + "أضف المجلدات التي تريد أن يعمل فيها الوكلاء. يمكنك إضافة المزيد لاحقًا من الشريط الجانبي.", + addProject: "إضافة مشروع", + added: "تمت الإضافة", + }, + orchestration: { + label: "التنسيق", + title: "اسمح للوكلاء باستخدام Paseo", + description: + "باستخدام أدوات Paseo، يمكن للوكيل تسليم العمل لوكلاء آخرين وإنشاء مساحات العمل والجداول بنفسه.", + capabilities: { + agents: { + title: "تشغيل وكلاء آخرين وتوجيههم", + description: + "شغّل وكلاء Codex أو Claude Code أو OpenCode وأرسل لهم الطلبات وانتظر نتائجهم.", + }, + workspaces: { + title: "إنشاء مساحات عمل", + description: "افتح worktree لكل مهمة حتى لا يتعارض الوكلاء العاملون بالتوازي.", + }, + schedules: { + title: "الجداول ونبضات المتابعة", + description: "تحقق من CI كل 10 دقائق وأصلح الإخفاقات.", + }, + terminals: { + title: "الطرفيات والسكربتات", + description: "شغّل خادم التطوير لمساحة العمل واقرأ مخرجاته.", + }, + }, + }, + phone: { + label: "الهاتف", + title: "خذ وكلاءك معك", + description: + "يتصل Paseo لنظامي iOS وAndroid بهذا الكمبيوتر. يواصل الوكلاء العمل هنا، ويتابعهم هاتفك ويوافق على الأذونات ويرسل الطلبات.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "مرحبا بكم في Paseo", subtitle: "قم بتوصيل جهاز الكمبيوتر الخاص بك للبدء", actions: { @@ -1859,6 +1922,8 @@ export const ar: TranslationResources = { notNow: "ليس الآن", directConnectionHint: "بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.", + tailscaleDocs: "إعداد Tailscale", + tailscaleDocsAccessibility: "اقرأ كيفية الاتصال عبر Tailscale", updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.", unavailable: "عرض الاقتران غير متاح.", hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.", @@ -2358,6 +2423,11 @@ export const ar: TranslationResources = { general: { title: "عام", sending: "الإرسال", + setup: { + label: "الإعداد الأولي", + description: "أعد المرور على المظهر والمزوّدين والمشاريع وإقران الهاتف", + action: "إظهار الإعداد مجددًا", + }, browserData: { title: "بيانات المتصفح", siteData: "ملفات تعريف الارتباط وبيانات المواقع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1f61a83f12..86257b9a85 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1666,6 +1666,69 @@ export const en = { }, }, onboarding: { + setup: { + title: "Set up Paseo", + footnote: "Everything here is also in Settings", + stepCounter: "Step {{current}} of {{total}}", + skip: "Skip setup", + continue: "Continue", + finish: "Finish", + steps: { + appearance: { + label: "Appearance", + title: "Welcome to Paseo", + description: + "Run Claude Code, Codex and other agents on this computer, from your desk or your phone. First, pick a look.", + }, + providers: { + label: "Providers", + title: "Your agents", + description: + "Paseo runs the agent CLIs already on this computer. Turn off any you don't want in the agent picker.", + }, + projects: { + label: "Projects", + title: "Add your projects", + description: + "Add the folders you want agents to work in. You can add more later from the sidebar.", + addProject: "Add project", + added: "Added", + }, + orchestration: { + label: "Orchestration", + title: "Let agents use Paseo", + description: + "With Paseo tools, an agent can hand work to other agents and set up its own workspaces and schedules.", + capabilities: { + agents: { + title: "Start and steer other agents", + description: + "Launch Codex, Claude Code or OpenCode agents, send them prompts and wait for their results.", + }, + workspaces: { + title: "Create workspaces", + description: "Open a worktree for each task so parallel agents don't collide.", + }, + schedules: { + title: "Schedules and heartbeats", + description: "Check CI every 10 minutes and fix failures.", + }, + terminals: { + title: "Terminals and scripts", + description: "Run your workspace's dev server and read its output.", + }, + }, + }, + phone: { + label: "Phone", + title: "Take your agents with you", + description: + "Paseo for iOS and Android connects to this computer. Agents keep running here; your phone watches them, approves permissions and sends prompts.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Welcome to Paseo", subtitle: "Connect your computer to get started", actions: { @@ -1884,6 +1947,8 @@ export const en = { notNow: "Not now", directConnectionHint: "Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.", + tailscaleDocs: "Set up Tailscale", + tailscaleDocsAccessibility: "Read how to connect over Tailscale", updateRequired: "Update the host to enable relay from Paseo Desktop.", unavailable: "Pairing offer unavailable.", hint: "Scan this QR code with Paseo on your phone, or copy the link below.", @@ -2447,6 +2512,11 @@ export const en = { general: { title: "General", sending: "Sending", + setup: { + label: "First-run setup", + description: "Walk through appearance, providers, projects and phone pairing again", + action: "Show setup again", + }, browserData: { title: "Browser data", siteData: "Cookies and site data", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7e5a1d41e6..22025cd99e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1687,6 +1687,71 @@ export const es: TranslationResources = { }, }, onboarding: { + setup: { + title: "Configurar Paseo", + footnote: "Todo lo de aquí también está en Ajustes", + stepCounter: "Paso {{current}} de {{total}}", + skip: "Omitir configuración", + continue: "Continuar", + finish: "Finalizar", + steps: { + appearance: { + label: "Apariencia", + title: "Te damos la bienvenida a Paseo", + description: + "Ejecuta Claude Code, Codex y otros agentes en este ordenador, desde tu escritorio o tu teléfono. Primero, elige un aspecto.", + }, + providers: { + label: "Proveedores", + title: "Tus agentes", + description: + "Paseo usa las CLI de agentes que ya están en este ordenador. Desactiva las que no quieras ver en el selector de agentes.", + }, + projects: { + label: "Proyectos", + title: "Añade tus proyectos", + description: + "Añade las carpetas en las que quieres que trabajen los agentes. Puedes añadir más después desde la barra lateral.", + addProject: "Añadir proyecto", + added: "Añadido", + }, + orchestration: { + label: "Orquestación", + title: "Deja que los agentes usen Paseo", + description: + "Con las herramientas de Paseo, un agente puede pasar trabajo a otros agentes y crear sus propios espacios de trabajo y programaciones.", + capabilities: { + agents: { + title: "Iniciar y dirigir otros agentes", + description: + "Lanza agentes de Codex, Claude Code u OpenCode, envíales instrucciones y espera sus resultados.", + }, + workspaces: { + title: "Crear espacios de trabajo", + description: + "Abre un worktree por tarea para que los agentes en paralelo no choquen.", + }, + schedules: { + title: "Programaciones y heartbeats", + description: "Revisa la CI cada 10 minutos y corrige los fallos.", + }, + terminals: { + title: "Terminales y scripts", + description: + "Ejecuta el servidor de desarrollo de tu espacio de trabajo y lee su salida.", + }, + }, + }, + phone: { + label: "Teléfono", + title: "Lleva tus agentes contigo", + description: + "Paseo para iOS y Android se conecta a este ordenador. Los agentes siguen ejecutándose aquí; tu teléfono los supervisa, aprueba permisos y envía instrucciones.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Bienvenido aPaseo", subtitle: "Conecte su computadora para comenzar", actions: { @@ -1907,6 +1972,8 @@ export const es: TranslationResources = { notNow: "Ahora no", directConnectionHint: "Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.", + tailscaleDocs: "Configurar Tailscale", + tailscaleDocsAccessibility: "Leer cómo conectarse con Tailscale", updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.", unavailable: "Oferta de maridaje no disponible.", hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.", @@ -2370,6 +2437,12 @@ export const es: TranslationResources = { general: { title: "General", sending: "Envío", + setup: { + label: "Configuración inicial", + description: + "Vuelve a recorrer apariencia, proveedores, proyectos y emparejamiento del teléfono", + action: "Mostrar configuración de nuevo", + }, browserData: { title: "Datos del navegador", siteData: "Cookies y datos de sitios", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 551f4a4158..00cabf828c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1685,6 +1685,71 @@ export const fr: TranslationResources = { }, }, onboarding: { + setup: { + title: "Configurer Paseo", + footnote: "Tout ceci se trouve aussi dans les Réglages", + stepCounter: "Étape {{current}} sur {{total}}", + skip: "Passer la configuration", + continue: "Continuer", + finish: "Terminer", + steps: { + appearance: { + label: "Apparence", + title: "Bienvenue dans Paseo", + description: + "Lancez Claude Code, Codex et d'autres agents sur cet ordinateur, depuis votre bureau ou votre téléphone. Choisissez d'abord un style.", + }, + providers: { + label: "Fournisseurs", + title: "Vos agents", + description: + "Paseo utilise les CLI d'agents déjà installées sur cet ordinateur. Désactivez celles que vous ne voulez pas dans le sélecteur d'agents.", + }, + projects: { + label: "Projets", + title: "Ajoutez vos projets", + description: + "Ajoutez les dossiers dans lesquels les agents doivent travailler. Vous pourrez en ajouter d'autres depuis la barre latérale.", + addProject: "Ajouter un projet", + added: "Ajouté", + }, + orchestration: { + label: "Orchestration", + title: "Laissez les agents utiliser Paseo", + description: + "Avec les outils Paseo, un agent peut confier du travail à d'autres agents et créer ses propres espaces de travail et planifications.", + capabilities: { + agents: { + title: "Lancer et piloter d'autres agents", + description: + "Lancez des agents Codex, Claude Code ou OpenCode, envoyez-leur des instructions et attendez leurs résultats.", + }, + workspaces: { + title: "Créer des espaces de travail", + description: + "Ouvrez un worktree par tâche pour que les agents en parallèle ne se gênent pas.", + }, + schedules: { + title: "Planifications et heartbeats", + description: "Vérifier la CI toutes les 10 minutes et corriger les échecs.", + }, + terminals: { + title: "Terminaux et scripts", + description: + "Lancez le serveur de développement de votre espace de travail et lisez sa sortie.", + }, + }, + }, + phone: { + label: "Téléphone", + title: "Emportez vos agents partout", + description: + "Paseo pour iOS et Android se connecte à cet ordinateur. Les agents continuent de tourner ici ; votre téléphone les suit, approuve les permissions et envoie des instructions.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Bienvenue dans Paseo", subtitle: "Connectez votre ordinateur pour commencer", actions: { @@ -1905,6 +1970,8 @@ export const fr: TranslationResources = { notNow: "Pas maintenant", directConnectionHint: "Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.", + tailscaleDocs: "Configurer Tailscale", + tailscaleDocsAccessibility: "Lire comment se connecter via Tailscale", updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", unavailable: "Offre d’association indisponible.", hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.", @@ -2370,6 +2437,12 @@ export const fr: TranslationResources = { general: { title: "Général", sending: "Envoi", + setup: { + label: "Configuration initiale", + description: + "Reprendre l'apparence, les fournisseurs, les projets et l'appairage du téléphone", + action: "Afficher la configuration", + }, browserData: { title: "Données du navigateur", siteData: "Cookies et données des sites", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4b1b8b6f64..8594d545a4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1658,6 +1658,70 @@ export const ja: TranslationResources = { }, }, onboarding: { + setup: { + title: "Paseo のセットアップ", + footnote: "ここでの設定はすべて設定画面でも変更できます", + stepCounter: "ステップ {{current}}/{{total}}", + skip: "セットアップをスキップ", + continue: "続ける", + finish: "完了", + steps: { + appearance: { + label: "外観", + title: "Paseo へようこそ", + description: + "このコンピューターで Claude Code、Codex などのエージェントを、デスクからでもスマートフォンからでも実行できます。まずは見た目を選びましょう。", + }, + providers: { + label: "プロバイダー", + title: "エージェント", + description: + "Paseo はこのコンピューターにインストール済みのエージェント CLI を使います。エージェント選択に表示したくないものはオフにしてください。", + }, + projects: { + label: "プロジェクト", + title: "プロジェクトを追加", + description: + "エージェントに作業させるフォルダーを追加します。後からサイドバーで追加することもできます。", + addProject: "プロジェクトを追加", + added: "追加済み", + }, + orchestration: { + label: "オーケストレーション", + title: "エージェントに Paseo を使わせる", + description: + "Paseo ツールを使うと、エージェントは他のエージェントに作業を任せたり、自分でワークスペースやスケジュールを作成したりできます。", + capabilities: { + agents: { + title: "他のエージェントを起動・操作", + description: + "Codex、Claude Code、OpenCode のエージェントを起動し、プロンプトを送って結果を待ちます。", + }, + workspaces: { + title: "ワークスペースを作成", + description: + "タスクごとに worktree を作成し、並行するエージェント同士の衝突を防ぎます。", + }, + schedules: { + title: "スケジュールとハートビート", + description: "10 分ごとに CI を確認して失敗を修正する。", + }, + terminals: { + title: "ターミナルとスクリプト", + description: "ワークスペースの開発サーバーを起動し、その出力を読み取ります。", + }, + }, + }, + phone: { + label: "スマートフォン", + title: "エージェントを持ち歩く", + description: + "iOS と Android 版 Paseo はこのコンピューターに接続します。エージェントはここで動き続け、スマートフォンで状況を確認し、権限を承認し、プロンプトを送れます。", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Paseoへようこそ", subtitle: "始めるにはコンピューターに接続してください", actions: { @@ -1878,6 +1942,8 @@ export const ja: TranslationResources = { notNow: "今はしない", directConnectionHint: "リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。", + tailscaleDocs: "Tailscale を設定", + tailscaleDocsAccessibility: "Tailscale で接続する方法を読む", updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。", unavailable: "ペアリングオファーが利用できません。", hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。", @@ -2343,6 +2409,12 @@ export const ja: TranslationResources = { general: { title: "一般", sending: "送信", + setup: { + label: "初回セットアップ", + description: + "外観、プロバイダー、プロジェクト、スマートフォンのペアリングをもう一度設定します", + action: "セットアップを再表示", + }, browserData: { title: "ブラウザーデータ", siteData: "Cookie とサイトデータ", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index b9e33b4eba..2fe36cf05f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1651,6 +1651,69 @@ export const ko: TranslationResources = { }, }, onboarding: { + setup: { + title: "Paseo 설정", + footnote: "여기의 모든 항목은 설정에서도 변경할 수 있습니다", + stepCounter: "{{total}}단계 중 {{current}}단계", + skip: "설정 건너뛰기", + continue: "계속", + finish: "완료", + steps: { + appearance: { + label: "모양", + title: "Paseo에 오신 것을 환영합니다", + description: + "이 컴퓨터에서 Claude Code, Codex 등 여러 에이전트를 책상에서도, 휴대폰에서도 실행하세요. 먼저 모양을 고르세요.", + }, + providers: { + label: "제공자", + title: "에이전트", + description: + "Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다. 에이전트 선택기에 표시하고 싶지 않은 항목은 끄세요.", + }, + projects: { + label: "프로젝트", + title: "프로젝트 추가", + description: + "에이전트가 작업할 폴더를 추가하세요. 나중에 사이드바에서 더 추가할 수 있습니다.", + addProject: "프로젝트 추가", + added: "추가됨", + }, + orchestration: { + label: "오케스트레이션", + title: "에이전트가 Paseo를 사용하도록 허용", + description: + "Paseo 도구를 사용하면 에이전트가 다른 에이전트에게 작업을 넘기고 직접 워크스페이스와 일정을 만들 수 있습니다.", + capabilities: { + agents: { + title: "다른 에이전트 시작 및 조정", + description: + "Codex, Claude Code, OpenCode 에이전트를 시작하고 프롬프트를 보낸 뒤 결과를 기다립니다.", + }, + workspaces: { + title: "워크스페이스 만들기", + description: "작업마다 worktree를 열어 병렬 에이전트끼리 충돌하지 않게 합니다.", + }, + schedules: { + title: "일정과 하트비트", + description: "10분마다 CI를 확인하고 실패를 수정합니다.", + }, + terminals: { + title: "터미널과 스크립트", + description: "워크스페이스의 개발 서버를 실행하고 출력을 읽습니다.", + }, + }, + }, + phone: { + label: "휴대폰", + title: "어디서나 에이전트와 함께", + description: + "iOS 및 Android용 Paseo가 이 컴퓨터에 연결됩니다. 에이전트는 여기서 계속 실행되고, 휴대폰으로 상태를 보고 권한을 승인하며 프롬프트를 보냅니다.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Paseo에 오신 것을 환영합니다", subtitle: "시작하려면 컴퓨터를 연결하세요", actions: { @@ -1869,6 +1932,8 @@ export const ko: TranslationResources = { notNow: "지금은 아님", directConnectionHint: "릴레이 없이 TCP, Tailscale 또는 다른 VPN을 통해 직접 연결하세요. QR 코드가 생성되지 않습니다.", + tailscaleDocs: "Tailscale 설정", + tailscaleDocsAccessibility: "Tailscale로 연결하는 방법 읽기", updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.", unavailable: "페어링 정보를 사용할 수 없습니다.", hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.", @@ -2332,6 +2397,11 @@ export const ko: TranslationResources = { general: { title: "일반", sending: "전송", + setup: { + label: "초기 설정", + description: "모양, 제공자, 프로젝트, 휴대폰 페어링을 다시 진행합니다", + action: "설정 다시 보기", + }, browserData: { title: "브라우저 데이터", siteData: "쿠키 및 사이트 데이터", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2fa6c30c8e..9b8bb9cb07 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1672,6 +1672,70 @@ export const ptBR: TranslationResources = { }, }, onboarding: { + setup: { + title: "Configurar o Paseo", + footnote: "Tudo aqui também está em Configurações", + stepCounter: "Etapa {{current}} de {{total}}", + skip: "Pular configuração", + continue: "Continuar", + finish: "Concluir", + steps: { + appearance: { + label: "Aparência", + title: "Boas-vindas ao Paseo", + description: + "Execute Claude Code, Codex e outros agentes neste computador, da sua mesa ou do seu celular. Primeiro, escolha um visual.", + }, + providers: { + label: "Provedores", + title: "Seus agentes", + description: + "O Paseo usa as CLIs de agentes já instaladas neste computador. Desative as que você não quer no seletor de agentes.", + }, + projects: { + label: "Projetos", + title: "Adicione seus projetos", + description: + "Adicione as pastas em que os agentes devem trabalhar. Você pode adicionar mais depois pela barra lateral.", + addProject: "Adicionar projeto", + added: "Adicionado", + }, + orchestration: { + label: "Orquestração", + title: "Deixe os agentes usarem o Paseo", + description: + "Com as ferramentas do Paseo, um agente pode passar trabalho para outros agentes e criar seus próprios workspaces e agendamentos.", + capabilities: { + agents: { + title: "Iniciar e guiar outros agentes", + description: + "Inicie agentes do Codex, Claude Code ou OpenCode, envie prompts e aguarde os resultados.", + }, + workspaces: { + title: "Criar workspaces", + description: + "Abra um worktree por tarefa para que agentes em paralelo não entrem em conflito.", + }, + schedules: { + title: "Agendamentos e heartbeats", + description: "Verificar a CI a cada 10 minutos e corrigir falhas.", + }, + terminals: { + title: "Terminais e scripts", + description: "Execute o servidor de desenvolvimento do workspace e leia a saída.", + }, + }, + }, + phone: { + label: "Celular", + title: "Leve seus agentes com você", + description: + "O Paseo para iOS e Android se conecta a este computador. Os agentes continuam rodando aqui; seu celular os acompanha, aprova permissões e envia prompts.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Bem-vindo ao Paseo", subtitle: "Conecte seu computador para começar", actions: { @@ -1892,6 +1956,8 @@ export const ptBR: TranslationResources = { notNow: "Agora não", directConnectionHint: "Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.", + tailscaleDocs: "Configurar Tailscale", + tailscaleDocsAccessibility: "Ler como conectar pelo Tailscale", updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.", unavailable: "Oferta de pareamento indisponível.", hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.", @@ -2355,6 +2421,11 @@ export const ptBR: TranslationResources = { general: { title: "Geral", sending: "Envio", + setup: { + label: "Configuração inicial", + description: "Rever aparência, provedores, projetos e pareamento do celular", + action: "Mostrar configuração novamente", + }, browserData: { title: "Dados do navegador", siteData: "Cookies e dados de sites", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9a6eb5b0e2..700baf7c4e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1670,6 +1670,70 @@ export const ru: TranslationResources = { }, }, onboarding: { + setup: { + title: "Настройка Paseo", + footnote: "Всё это также есть в настройках", + stepCounter: "Шаг {{current}} из {{total}}", + skip: "Пропустить настройку", + continue: "Продолжить", + finish: "Готово", + steps: { + appearance: { + label: "Оформление", + title: "Добро пожаловать в Paseo", + description: + "Запускайте Claude Code, Codex и других агентов на этом компьютере — за столом или с телефона. Сначала выберите оформление.", + }, + providers: { + label: "Провайдеры", + title: "Ваши агенты", + description: + "Paseo использует CLI агентов, уже установленные на этом компьютере. Отключите те, которые не нужны в списке агентов.", + }, + projects: { + label: "Проекты", + title: "Добавьте проекты", + description: + "Добавьте папки, в которых будут работать агенты. Позже можно добавить ещё из боковой панели.", + addProject: "Добавить проект", + added: "Добавлено", + }, + orchestration: { + label: "Оркестрация", + title: "Дайте агентам доступ к Paseo", + description: + "С инструментами Paseo агент может передавать работу другим агентам и сам создавать рабочие пространства и расписания.", + capabilities: { + agents: { + title: "Запуск других агентов и управление ими", + description: + "Запускайте агентов Codex, Claude Code или OpenCode, отправляйте им запросы и ждите результатов.", + }, + workspaces: { + title: "Создание рабочих пространств", + description: + "Отдельный worktree для каждой задачи, чтобы параллельные агенты не мешали друг другу.", + }, + schedules: { + title: "Расписания и heartbeat", + description: "Проверять CI каждые 10 минут и исправлять сбои.", + }, + terminals: { + title: "Терминалы и скрипты", + description: "Запускайте dev-сервер рабочего пространства и читайте его вывод.", + }, + }, + }, + phone: { + label: "Телефон", + title: "Агенты всегда под рукой", + description: + "Paseo для iOS и Android подключается к этому компьютеру. Агенты продолжают работать здесь, а телефон следит за ними, одобряет разрешения и отправляет запросы.", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "Добро пожаловать в Paseo", subtitle: "Подключите компьютер, чтобы начать", actions: { @@ -1891,6 +1955,8 @@ export const ru: TranslationResources = { notNow: "Не сейчас", directConnectionHint: "Без ретранслятора подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код создаваться не будет.", + tailscaleDocs: "Настроить Tailscale", + tailscaleDocsAccessibility: "Узнать, как подключиться через Tailscale", updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.", unavailable: "Данные для сопряжения недоступны.", hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.", @@ -2357,6 +2423,11 @@ export const ru: TranslationResources = { general: { title: "Основные", sending: "Отправка", + setup: { + label: "Первоначальная настройка", + description: "Снова пройти оформление, провайдеры, проекты и привязку телефона", + action: "Показать настройку снова", + }, browserData: { title: "Данные браузера", siteData: "Файлы cookie и данные сайтов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 553d8212b4..e4db59b904 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1624,6 +1624,66 @@ export const zhCN: TranslationResources = { }, }, onboarding: { + setup: { + title: "设置 Paseo", + footnote: "这里的所有选项也都在设置中", + stepCounter: "第 {{current}} 步,共 {{total}} 步", + skip: "跳过设置", + continue: "继续", + finish: "完成", + steps: { + appearance: { + label: "外观", + title: "欢迎使用 Paseo", + description: + "在这台电脑上运行 Claude Code、Codex 和其他代理,无论在桌前还是用手机。先选一个外观。", + }, + providers: { + label: "提供方", + title: "你的代理", + description: "Paseo 使用这台电脑上已安装的代理 CLI。关闭不想在代理选择器中显示的项。", + }, + projects: { + label: "项目", + title: "添加项目", + description: "添加你希望代理工作的文件夹。之后也可以从侧边栏添加更多。", + addProject: "添加项目", + added: "已添加", + }, + orchestration: { + label: "编排", + title: "让代理使用 Paseo", + description: + "启用 Paseo 工具后,代理可以把工作交给其他代理,并自行创建工作区和计划任务。", + capabilities: { + agents: { + title: "启动并引导其他代理", + description: "启动 Codex、Claude Code 或 OpenCode 代理,向它们发送提示并等待结果。", + }, + workspaces: { + title: "创建工作区", + description: "为每个任务打开一个 worktree,避免并行代理相互冲突。", + }, + schedules: { + title: "计划任务和心跳", + description: "每 10 分钟检查一次 CI 并修复失败。", + }, + terminals: { + title: "终端和脚本", + description: "运行工作区的开发服务器并读取输出。", + }, + }, + }, + phone: { + label: "手机", + title: "随身带上你的代理", + description: + "iOS 和 Android 版 Paseo 会连接到这台电脑。代理继续在这里运行,手机用来查看进度、批准权限和发送提示。", + appStore: "App Store", + googlePlay: "Google Play", + }, + }, + }, title: "欢迎使用 Paseo", subtitle: "连接你的电脑即可开始", actions: { @@ -1838,6 +1898,8 @@ export const zhCN: TranslationResources = { notNow: "暂不", directConnectionHint: "不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。", + tailscaleDocs: "设置 Tailscale", + tailscaleDocsAccessibility: "了解如何通过 Tailscale 连接", updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。", unavailable: "配对 offer 不可用。", hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。", @@ -2299,6 +2361,11 @@ export const zhCN: TranslationResources = { general: { title: "通用", sending: "发送", + setup: { + label: "首次设置", + description: "重新设置外观、提供方、项目和手机配对", + action: "再次显示设置", + }, browserData: { title: "浏览器数据", siteData: "Cookie 和网站数据", diff --git a/packages/app/src/onboarding/index.ts b/packages/app/src/onboarding/index.ts new file mode 100644 index 0000000000..68c80d8f0d --- /dev/null +++ b/packages/app/src/onboarding/index.ts @@ -0,0 +1,9 @@ +/** + * 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. + */ +export { OnboardingHost } from "./onboarding-host"; +export { useOpenOnboarding } from "./use-open-onboarding"; diff --git a/packages/app/src/onboarding/internal/completion-storage.ts b/packages/app/src/onboarding/internal/completion-storage.ts new file mode 100644 index 0000000000..f50ef7cacf --- /dev/null +++ b/packages/app/src/onboarding/internal/completion-storage.ts @@ -0,0 +1,25 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import { readValidatedJson } from "@/storage/validated-storage"; +import type { OnboardingCompletionStorage } from "./model"; + +export const ONBOARDING_STORAGE_KEY = "@paseo:onboarding"; + +const OnboardingRecordSchema = z.object({ completedAt: z.string() }); + +export const asyncStorageOnboardingCompletion: OnboardingCompletionStorage = { + async isCompleted() { + const record = await readValidatedJson( + AsyncStorage, + ONBOARDING_STORAGE_KEY, + OnboardingRecordSchema, + ); + return record !== null; + }, + async markCompleted() { + await AsyncStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ completedAt: new Date().toISOString() }), + ); + }, +}; diff --git a/packages/app/src/onboarding/internal/model.test.ts b/packages/app/src/onboarding/internal/model.test.ts new file mode 100644 index 0000000000..445c165892 --- /dev/null +++ b/packages/app/src/onboarding/internal/model.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + createOnboardingModel, + type OnboardingCompletionStorage, + type OnboardingSetupHost, +} from "./model"; + +class MemoryCompletionStorage implements OnboardingCompletionStorage { + completed: boolean; + + constructor(completed = false) { + this.completed = completed; + } + + async isCompleted(): Promise { + return this.completed; + } + + async markCompleted(): Promise { + this.completed = true; + } +} + +const FRESH_HOST: OnboardingSetupHost = { + serverId: "local", + isConnected: true, + hasHydratedWorkspaces: true, + hasProjects: false, +}; + +async function openModel(storage = new MemoryCompletionStorage()) { + const model = createOnboardingModel({ storage }); + await model.ready; + return model; +} + +describe("onboarding model", () => { + it("opens on the appearance step for a connected host with no projects", async () => { + const model = await openModel(); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toEqual({ + status: "open", + serverId: "local", + steps: ["appearance", "providers", "projects", "orchestration", "phone"], + stepIndex: 0, + }); + }); + + it("waits until the host is connected and its workspaces are hydrated", async () => { + const model = await openModel(); + + model.applySetupHost(null, { includePhoneStep: true }); + model.applySetupHost({ ...FRESH_HOST, isConnected: false }, { includePhoneStep: true }); + model.applySetupHost( + { ...FRESH_HOST, hasHydratedWorkspaces: false }, + { includePhoneStep: true }, + ); + expect(model.getState()).toEqual({ status: "deciding" }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(model.getState().status).toBe("open"); + }); + + it("stays hidden for a host that already has projects, even after they are removed", async () => { + const model = await openModel(); + + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("stays hidden once setup was completed on this client", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("decides only after the completion record has loaded", async () => { + const model = createOnboardingModel({ storage: new MemoryCompletionStorage(true) }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(model.getState()).toEqual({ status: "deciding" }); + + await model.ready; + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("keeps the modal open when the host gains its first project during setup", async () => { + const model = await openModel(); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + model.next(); + model.next(); + + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + + expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 }); + }); + + it("omits the phone step when the client is a phone", async () => { + const model = await openModel(); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: false }); + + expect(model.getState()).toMatchObject({ + steps: ["appearance", "providers", "projects", "orchestration"], + }); + }); + + it("moves between steps without leaving the step list", async () => { + const model = await openModel(); + model.applySetupHost(FRESH_HOST, { includePhoneStep: false }); + + model.back(); + expect(model.getState()).toMatchObject({ stepIndex: 0 }); + model.next(); + model.next(); + model.next(); + model.next(); + expect(model.getState()).toMatchObject({ stepIndex: 3 }); + model.goTo("providers"); + expect(model.getState()).toMatchObject({ stepIndex: 1 }); + model.back(); + expect(model.getState()).toMatchObject({ stepIndex: 0 }); + }); + + it("records completion when setup closes, so it does not open again", async () => { + const storage = new MemoryCompletionStorage(); + const model = await openModel(storage); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + await model.complete(); + + expect(model.getState()).toEqual({ status: "hidden" }); + expect(storage.completed).toBe(true); + const relaunched = await openModel(storage); + relaunched.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(relaunched.getState()).toEqual({ status: "hidden" }); + }); + + it("reopens on the first step for an existing user", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + + model.reopen("local", { includePhoneStep: true }); + + expect(model.getState()).toMatchObject({ status: "open", serverId: "local", stepIndex: 0 }); + }); + + it("notifies subscribers on every state change", async () => { + const model = await openModel(); + const seen: string[] = []; + model.subscribe(() => { + const state = model.getState(); + seen.push(state.status === "open" ? `open:${state.stepIndex}` : state.status); + }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + model.next(); + await model.complete(); + + expect(seen).toEqual(["open:0", "open:1", "hidden"]); + }); +}); diff --git a/packages/app/src/onboarding/internal/model.ts b/packages/app/src/onboarding/internal/model.ts new file mode 100644 index 0000000000..4b2fb9584d --- /dev/null +++ b/packages/app/src/onboarding/internal/model.ts @@ -0,0 +1,155 @@ +export type OnboardingStepId = "appearance" | "providers" | "projects" | "orchestration" | "phone"; + +const STEPS_WITHOUT_PHONE: readonly OnboardingStepId[] = [ + "appearance", + "providers", + "projects", + "orchestration", +]; +const STEPS_WITH_PHONE: readonly OnboardingStepId[] = [...STEPS_WITHOUT_PHONE, "phone"]; + +export interface OnboardingCompletionStorage { + isCompleted(): Promise; + markCompleted(): Promise; +} + +/** The host setup runs against: the desktop's managed daemon, otherwise the first saved host. */ +export interface OnboardingSetupHost { + serverId: string; + isConnected: boolean; + hasHydratedWorkspaces: boolean; + hasProjects: boolean; +} + +export interface OnboardingStepOptions { + /** False when the client is itself a phone, where pairing a phone makes no sense. */ + includePhoneStep: boolean; +} + +export type OnboardingState = + | { status: "deciding" } + | { status: "hidden" } + | { + status: "open"; + serverId: string; + steps: readonly OnboardingStepId[]; + stepIndex: number; + }; + +export interface OnboardingModel { + /** Resolves once the completion record has loaded. */ + ready: Promise; + getState(): OnboardingState; + subscribe(listener: () => void): () => void; + applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void; + reopen(serverId: string, options: OnboardingStepOptions): void; + next(): void; + back(): void; + goTo(step: OnboardingStepId): void; + /** Closes setup, whether the user finished or skipped it, and remembers that on this client. */ + complete(): Promise; +} + +type Completion = "loading" | "completed" | "pending"; + +interface PendingHost { + host: OnboardingSetupHost | null; + options: OnboardingStepOptions; +} + +function isReadyForSetup(host: OnboardingSetupHost | null): host is OnboardingSetupHost { + return host !== null && host.isConnected && host.hasHydratedWorkspaces; +} + +function openState(serverId: string, options: OnboardingStepOptions): OnboardingState { + return { + status: "open", + serverId, + steps: options.includePhoneStep ? STEPS_WITH_PHONE : STEPS_WITHOUT_PHONE, + stepIndex: 0, + }; +} + +export function createOnboardingModel(input: { + storage: OnboardingCompletionStorage; +}): OnboardingModel { + const listeners = new Set<() => void>(); + let state: OnboardingState = { status: "deciding" }; + let completion: Completion = "loading"; + let pendingHost: PendingHost | null = null; + + function setState(next: OnboardingState) { + state = next; + for (const listener of listeners) { + listener(); + } + } + + // The gate is decided once per launch: a host that later loses its projects, or a + // project added during setup, never flips the decision. + function decide() { + if (state.status !== "deciding" || completion === "loading") return; + if (completion === "completed") { + setState({ status: "hidden" }); + return; + } + const host = pendingHost?.host ?? null; + if (!pendingHost || !isReadyForSetup(host)) return; + setState( + host.hasProjects ? { status: "hidden" } : openState(host.serverId, pendingHost.options), + ); + } + + function moveTo(stepIndex: number) { + if (state.status !== "open") return; + const clamped = Math.max(0, Math.min(stepIndex, state.steps.length - 1)); + if (clamped === state.stepIndex) return; + setState({ ...state, stepIndex: clamped }); + } + + async function loadCompletion(): Promise { + try { + completion = (await input.storage.isCompleted()) ? "completed" : "pending"; + } catch { + // An unreadable record must not trap an existing user in setup. + completion = "completed"; + } + decide(); + } + + const ready = loadCompletion(); + + return { + ready, + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + applySetupHost(host, options) { + pendingHost = { host, options }; + decide(); + }, + reopen(serverId, options) { + setState(openState(serverId, options)); + }, + next() { + if (state.status === "open") moveTo(state.stepIndex + 1); + }, + back() { + if (state.status === "open") moveTo(state.stepIndex - 1); + }, + goTo(step) { + if (state.status !== "open") return; + const stepIndex = state.steps.indexOf(step); + if (stepIndex >= 0) moveTo(stepIndex); + }, + async complete() { + completion = "completed"; + setState({ status: "hidden" }); + await input.storage.markCompleted(); + }, + }; +} diff --git a/packages/app/src/onboarding/internal/onboarding-instance.ts b/packages/app/src/onboarding/internal/onboarding-instance.ts new file mode 100644 index 0000000000..d6f73456fb --- /dev/null +++ b/packages/app/src/onboarding/internal/onboarding-instance.ts @@ -0,0 +1,10 @@ +import { asyncStorageOnboardingCompletion } from "./completion-storage"; +import { createOnboardingModel, type OnboardingModel } from "./model"; + +let instance: OnboardingModel | null = null; + +/** The app's one setup model, created on first use so storage is read once per launch. */ +export function getOnboardingModel(): OnboardingModel { + instance ??= createOnboardingModel({ storage: asyncStorageOnboardingCompletion }); + return instance; +} diff --git a/packages/app/src/onboarding/internal/onboarding-modal.tsx b/packages/app/src/onboarding/internal/onboarding-modal.tsx new file mode 100644 index 0000000000..a061bfd2bb --- /dev/null +++ b/packages/app/src/onboarding/internal/onboarding-modal.tsx @@ -0,0 +1,292 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; +import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { Button } from "@/components/ui/button"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { ScrollView } from "@/components/ui/scroll-view"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model"; +import { OnboardingStepContent } from "./steps"; + +type OpenState = Extract; + +const ThemedCheck = withUnistyles(Check); +const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); + +export function OnboardingModal({ model, state }: { model: OnboardingModel; state: OpenState }) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const step = state.steps[state.stepIndex]; + const isFirst = state.stepIndex === 0; + const isLast = state.stepIndex === state.steps.length - 1; + + const handleComplete = useCallback(() => { + void model.complete(); + }, [model]); + const handleBack = useCallback(() => model.back(), [model]); + const handleNext = useCallback(() => model.next(), [model]); + + const header = useMemo( + () => ({ + title: t("onboarding.setup.title"), + leading: , + subtitle: isCompact ? ( + + {t("onboarding.setup.stepCounter", { + current: state.stepIndex + 1, + total: state.steps.length, + })} + + ) : undefined, + }), + [isCompact, state.stepIndex, state.steps.length, t], + ); + + const footer = ( + <> + + {isLast ? null : ( + + )} + + + {isFirst ? null : ( + + )} + + + + ); + + return ( + + + {isCompact ? null : } + + {/* Padding lives on a wrapper: the ScrollView's contentContainerStyle skips Unistyles. */} + + + + {t(`onboarding.setup.steps.${step}.title`)} + + + {t(`onboarding.setup.steps.${step}.description`)} + + + + + + + + ); +} + +function StepRail({ + state, + onSelect, +}: { + state: OpenState; + onSelect: (step: OnboardingStepId) => void; +}) { + const { t } = useTranslation(); + return ( + + + {state.steps.map((step, index) => ( + + ))} + + {t("onboarding.setup.footnote")} + + ); +} + +function StepRailItem({ + step, + number, + current, + done, + onSelect, +}: { + step: OnboardingStepId; + number: number; + current: boolean; + done: boolean; + onSelect: (step: OnboardingStepId) => void; +}) { + const { t } = useTranslation(); + const label = t(`onboarding.setup.steps.${step}.label`); + const handlePress = useCallback(() => onSelect(step), [onSelect, step]); + const accessibilityState = useMemo(() => ({ selected: current }), [current]); + const itemStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.railItem, + hovered && !current && styles.railItemHovered, + current && styles.railItemCurrent, + ], + [current], + ); + return ( + + + {done ? ( + + ) : ( + {number} + )} + + + {label} + + + ); +} + +const BADGE_SIZE = 20; + +const styles = StyleSheet.create((theme) => ({ + content: { + padding: 0, + gap: 0, + }, + body: { + flex: 1, + minHeight: 0, + flexDirection: "row", + }, + rail: { + width: 200, + padding: theme.spacing[3], + justifyContent: "space-between", + borderRightWidth: theme.borderWidth[1], + borderRightColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, + }, + railSteps: { + gap: theme.spacing[1], + }, + railItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + railItemHovered: { + backgroundColor: theme.colors.surface2, + }, + railItemCurrent: { + backgroundColor: theme.colors.surface2, + }, + railBadge: { + width: BADGE_SIZE, + height: BADGE_SIZE, + borderRadius: BADGE_SIZE / 2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + railBadgeActive: { + width: BADGE_SIZE, + height: BADGE_SIZE, + borderRadius: BADGE_SIZE / 2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + railBadgeText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + railBadgeTextActive: { + color: theme.colors.accentForeground, + fontSize: theme.fontSize.sm, + }, + railLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + railLabelCurrent: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + railFootnote: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[2], + }, + pane: { + flex: 1, + minHeight: 0, + }, + paneContent: { + padding: theme.spacing[6], + gap: theme.spacing[6], + }, + stepHeader: { + gap: theme.spacing[2], + }, + stepTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.medium, + }, + stepDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.5, + }, + stepCounter: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + footerActions: { + flexDirection: "row", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/appearance-step.tsx b/packages/app/src/onboarding/internal/steps/appearance-step.tsx new file mode 100644 index 0000000000..54ee902a62 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/appearance-step.tsx @@ -0,0 +1,5 @@ +import { ThemePicker } from "@/appearance/theme-picker"; + +export function AppearanceStep() { + return ; +} diff --git a/packages/app/src/onboarding/internal/steps/index.tsx b/packages/app/src/onboarding/internal/steps/index.tsx new file mode 100644 index 0000000000..aa6a869a3b --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/index.tsx @@ -0,0 +1,29 @@ +import type { OnboardingStepId } from "../model"; +import { AppearanceStep } from "./appearance-step"; +import { OrchestrationStep } from "./orchestration-step"; +import { PhoneStep } from "./phone-step"; +import { ProjectsStep } from "./projects-step"; +import { ProvidersStep } from "./providers-step"; + +export function OnboardingStepContent({ + step, + serverId, + onFinish, +}: { + step: OnboardingStepId; + serverId: string; + onFinish: () => void; +}) { + switch (step) { + case "appearance": + return ; + case "providers": + return ; + case "projects": + return ; + case "orchestration": + return ; + case "phone": + return ; + } +} diff --git a/packages/app/src/onboarding/internal/steps/orchestration-step.tsx b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx new file mode 100644 index 0000000000..013bee28a8 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { CalendarClock, GitBranch, SquareTerminal, Users } from "lucide-react-native"; +import { PaseoToolsCard } from "@/screens/settings/paseo-tools-card"; +import type { Theme } from "@/styles/theme"; + +const ThemedUsers = withUnistyles(Users); +type ThemedIcon = typeof ThemedUsers; + +const CAPABILITIES: ReadonlyArray<{ + key: "agents" | "workspaces" | "schedules" | "terminals"; + icon: ThemedIcon; +}> = [ + { key: "agents", icon: ThemedUsers }, + { key: "workspaces", icon: withUnistyles(GitBranch) }, + { key: "schedules", icon: withUnistyles(CalendarClock) }, + { key: "terminals", icon: withUnistyles(SquareTerminal) }, +]; + +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export function OrchestrationStep({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + return ( + + + {CAPABILITIES.map(({ key, icon }) => ( + + ))} + + + + ); +} + +function Capability({ + icon: Icon, + title, + description, +}: { + icon: ThemedIcon; + title: string; + description: string; +}) { + return ( + + + + {title} + {description} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[6], + }, + capabilities: { + gap: theme.spacing[4], + }, + capability: { + flexDirection: "row", + gap: theme.spacing[3], + }, + capabilityText: { + flex: 1, + gap: theme.spacing[1], + }, + capabilityTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + capabilityDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.5, + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/phone-step.tsx b/packages/app/src/onboarding/internal/steps/phone-step.tsx new file mode 100644 index 0000000000..6ceafc95a7 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/phone-step.tsx @@ -0,0 +1,45 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { PairDeviceSection } from "@/desktop/components/pair-device-section"; +import { openExternalUrl } from "@/utils/open-external-url"; + +const APP_STORE_URL = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"; +const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=sh.paseo"; + +export function PhoneStep({ serverId, onFinish }: { serverId: string; onFinish: () => void }) { + const { t } = useTranslation(); + const openAppStore = useCallback(() => { + void openExternalUrl(APP_STORE_URL).catch(console.error); + }, []); + const openPlayStore = useCallback(() => { + void openExternalUrl(PLAY_STORE_URL).catch(console.error); + }, []); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[6], + }, + storeLinks: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/projects-step.tsx b/packages/app/src/onboarding/internal/steps/projects-step.tsx new file mode 100644 index 0000000000..beda492cd3 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/projects-step.tsx @@ -0,0 +1,78 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, FolderPlus } from "lucide-react-native"; +import { Button } from "@/components/ui/button"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useProjectRootPaths } from "@/stores/session-store-hooks"; +import { settingsStyles } from "@/styles/settings"; +import type { Theme } from "@/styles/theme"; + +const ThemedCheck = withUnistyles(Check); +const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[400] }); + +function basename(path: string): string { + const segments = path.split(/[\\/]/).filter((segment) => segment.length > 0); + return segments.at(-1) ?? path; +} + +export function ProjectsStep({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const openAddProject = useOpenAddProject(); + const projectRootPaths = useProjectRootPaths(serverId); + const handleAddProject = useCallback(() => openAddProject(serverId), [openAddProject, serverId]); + + return ( + + {projectRootPaths.length > 0 ? ( + + {projectRootPaths.map((rootPath, index) => ( + 0 && settingsStyles.rowBorder]} + > + + + {basename(rootPath)} + + + {rootPath} + + + + + {t("onboarding.setup.steps.projects.added")} + + + ))} + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[4], + }, + actions: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + addedBadge: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + addedLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/providers-step.tsx b/packages/app/src/onboarding/internal/steps/providers-step.tsx new file mode 100644 index 0000000000..84235fe921 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/providers-step.tsx @@ -0,0 +1,10 @@ +import { View } from "react-native"; +import { ProvidersSection } from "@/screens/settings/providers-section"; + +export function ProvidersStep({ serverId }: { serverId: string }) { + return ( + + + + ); +} diff --git a/packages/app/src/onboarding/internal/use-setup-host.ts b/packages/app/src/onboarding/internal/use-setup-host.ts new file mode 100644 index 0000000000..0680e8a466 --- /dev/null +++ b/packages/app/src/onboarding/internal/use-setup-host.ts @@ -0,0 +1,14 @@ +import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon"; +import { useHosts } from "@/runtime/host-runtime"; + +/** + * The host setup configures: the desktop's built-in daemon, otherwise the first saved host. + * Null while the desktop daemon is still reporting its id, or when there is no host at all. + */ +export function useSetupHostServerId(): string | null { + const localDaemon = useLocalDaemonServerIdState(); + const hosts = useHosts(); + if (localDaemon.status === "loading") return null; + if (localDaemon.status === "resolved" && localDaemon.serverId) return localDaemon.serverId; + return hosts[0]?.serverId ?? null; +} diff --git a/packages/app/src/onboarding/onboarding-host.tsx b/packages/app/src/onboarding/onboarding-host.tsx new file mode 100644 index 0000000000..28280c7c70 --- /dev/null +++ b/packages/app/src/onboarding/onboarding-host.tsx @@ -0,0 +1,40 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useDirectoryDemand } from "@/hooks/use-directory-demand"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHasHydratedWorkspaces, useProjectRootPaths } from "@/stores/session-store-hooks"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { OnboardingModal } from "./internal/onboarding-modal"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** Mounted once by the app shell. Decides whether setup opens, then owns the modal. */ +export function OnboardingHost() { + // Native phones are set up from a desktop; setup never opens there. + if (isNative) return null; + return ; +} + +function OnboardingGate() { + const model = getOnboardingModel(); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const serverId = useSetupHostServerId(); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const projectRootPaths = useProjectRootPaths(serverId); + const isCompact = useIsCompactFormFactor(); + const hasProjects = projectRootPaths.length > 0; + + // Without demand nothing may ask a fresh host for its workspaces, and the gate would wait forever. + useDirectoryDemand(state.status === "hidden" ? null : serverId); + + useEffect(() => { + model.applySetupHost( + serverId ? { serverId, isConnected, hasHydratedWorkspaces, hasProjects } : null, + { includePhoneStep: !isCompact }, + ); + }, [model, serverId, isConnected, hasHydratedWorkspaces, hasProjects, isCompact]); + + if (state.status !== "open") return null; + return ; +} diff --git a/packages/app/src/onboarding/use-open-onboarding.ts b/packages/app/src/onboarding/use-open-onboarding.ts new file mode 100644 index 0000000000..1f4a93328e --- /dev/null +++ b/packages/app/src/onboarding/use-open-onboarding.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** Reopens setup on its first step. Null when there is no host to set up. */ +export function useOpenOnboarding(): (() => void) | null { + const serverId = useSetupHostServerId(); + const isCompact = useIsCompactFormFactor(); + const open = useCallback(() => { + if (!serverId) return; + getOnboardingModel().reopen(serverId, { includePhoneStep: !isCompact }); + }, [isCompact, serverId]); + return serverId ? open : null; +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 78425bf20d..bd92f43810 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -145,7 +145,8 @@ import { useSettingsSidebarScroll, } from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; +import { useOpenOnboarding } from "@/onboarding"; // --------------------------------------------------------------------------- // View model @@ -389,11 +390,33 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) + {isNative ? null : } ); } +function ShowSetupAgainRow() { + const { t } = useTranslation(); + const openOnboarding = useOpenOnboarding(); + return ( + + + {t("settings.general.setup.label")} + {t("settings.general.setup.description")} + + + + ); +} + interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 2dfea09fd0..24db79857b 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -1,23 +1,17 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Monitor, Moon, Sun } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; import { SYNTAX_THEME_OPTIONS, type SyntaxThemeId, type SyntaxThemeOption, } from "@getpaseo/highlight"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { SettingsSection } from "@/components/settings/headings/settings-section"; -import { useContributedThemes } from "@/appearance/provider"; +import { ThemePicker } from "@/appearance/theme-picker"; import { Button } from "@/components/ui/button"; import { EditingTextInput as TextInput, @@ -36,40 +30,12 @@ import { sanitizeFontFamily, useAppSettings, type AppSettings, - DEFAULT_THEME_PREFERENCE, } from "@/hooks/use-settings"; -import { - DEFAULT_MONO_FONT_STACK, - DEFAULT_UI_FONT_STACK, - ICON_SIZE, - PLUGIN_THEME_PREFERENCE, - THEME_OPTIONS, - THEME_SWATCHES, - type Theme, -} from "@/styles/theme"; +import { DEFAULT_MONO_FONT_STACK, DEFAULT_UI_FONT_STACK } from "@/styles/theme"; import { isNative } from "@/constants/platform"; -import type { PluginThemeOption } from "@/plugins/themes"; import { settingsStyles } from "@/styles/settings"; import { AppearancePreview } from "./appearance-preview"; -// --------------------------------------------------------------------------- -// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no -// useUnistyles). Icon sizes read the static ICON_SIZE token; the appearance -// feature does not scale icons. -// --------------------------------------------------------------------------- - -const ThemedSun = withUnistyles(Sun); -const ThemedMoon = withUnistyles(Moon); -const ThemedMonitor = withUnistyles(Monitor); - -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -type BuiltInThemePreference = Exclude; - -function getThemeLabel(t: TFunction, value: BuiltInThemePreference): string { - return t(`settings.appearance.theme.options.${value}`); -} - // Platform default stacks can be the bare native tokens ("normal"/"monospace"); // those read as a bug, so show a human label in the placeholder instead. const BARE_DEFAULT_STACKS: ReadonlySet = new Set(["normal", "monospace"]); @@ -86,148 +52,6 @@ function sizeDraftToOverride(value: string): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -// --------------------------------------------------------------------------- -// Theme picker -// --------------------------------------------------------------------------- - -interface ThemeLeadingProps { - themeValue: BuiltInThemePreference; -} - -function ThemeLeading({ themeValue }: ThemeLeadingProps) { - switch (themeValue) { - case "light": - return ; - case "dark": - return ; - case "auto": - return ; - default: - return ; - } -} - -interface ThemeSwatchProps { - color: string; -} - -function ThemeSwatch({ color }: ThemeSwatchProps) { - const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]); - return ; -} - -interface ThemeMenuItemProps { - themeValue: BuiltInThemePreference; - selected: boolean; - onChange: (theme: BuiltInThemePreference) => void; -} - -function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) { - const { t } = useTranslation(); - const handleSelect = useCallback(() => { - onChange(themeValue); - }, [onChange, themeValue]); - const leading = useMemo(() => , [themeValue]); - return ( - - {getThemeLabel(t, themeValue)} - - ); -} - -interface PluginThemeMenuItemProps { - option: PluginThemeOption; - selected: boolean; - onSelect: (option: PluginThemeOption) => void; -} - -function PluginThemeMenuItem({ option, selected, onSelect }: PluginThemeMenuItemProps) { - const handleSelect = useCallback(() => { - onSelect(option); - }, [onSelect, option]); - const leading = useMemo(() => , [option.swatch]); - return ( - - {option.name} - - ); -} - -interface ThemeRowProps { - value: AppSettings["theme"]; - pluginThemes: PluginThemeOption[]; - selectedPluginTheme: PluginThemeOption | null; - onChange: (theme: BuiltInThemePreference) => void; - onSelectPluginTheme: (option: PluginThemeOption) => void; -} - -function ThemeRow({ - value, - pluginThemes, - selectedPluginTheme, - onChange, - onSelectPluginTheme, -}: ThemeRowProps) { - const { t } = useTranslation(); - // A selected contribution that is no longer installed shows the fallback the app renders. - const builtInValue = value === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : value; - const selectedLabel = selectedPluginTheme - ? selectedPluginTheme.name - : getThemeLabel(t, builtInValue); - const leading = useMemo( - () => - selectedPluginTheme ? ( - - ) : ( - - ), - [builtInValue, selectedPluginTheme], - ); - return ( - - - {t("settings.appearance.theme.title")} - - - - {selectedLabel} - - - {THEME_OPTIONS.map((option, index) => { - const previousOption = THEME_OPTIONS[index - 1]; - return ( - - {previousOption && previousOption.group !== option.group ? ( - - ) : null} - - - ); - })} - {pluginThemes.length > 0 ? : null} - {pluginThemes.map((option) => ( - - ))} - - - - ); -} - // --------------------------------------------------------------------------- // Fonts: family text fields + numeric size fields (commit on blur/submit) // --------------------------------------------------------------------------- @@ -478,11 +302,6 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) { export function AppearanceSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const { - options: pluginThemes, - selected: selectedPluginTheme, - select: selectPluginTheme, - } = useContributedThemes(); const showInterfaceFontFamilyRow = !isNative; const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK); const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK); @@ -504,20 +323,6 @@ export function AppearanceSection() { setCodeSizeDraft(String(settings.codeFontSize)); }, [settings.codeFontSize]); - const handleThemeChange = useCallback( - (theme: BuiltInThemePreference) => { - void updateSettings({ theme }); - }, - [updateSettings], - ); - - const handlePluginThemeChange = useCallback( - (option: PluginThemeOption) => { - selectPluginTheme(option); - }, - [selectPluginTheme], - ); - const handleContentMaxWidthChange = useCallback( (contentMaxWidth: AppSettings["contentMaxWidth"]) => { void updateSettings({ contentMaxWidth }); @@ -627,14 +432,8 @@ export function AppearanceSection() { return ( - - + + @@ -723,12 +522,8 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, }, - swatch: { - width: ICON_SIZE.md, - height: ICON_SIZE.md, - borderRadius: ICON_SIZE.md / 2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + themeCard: { + padding: theme.spacing[3], }, fontFamilyInput: { flexGrow: 1, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 879ef2fe95..0afe1e367d 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; import { useProviderIcon } from "@/components/provider-icons"; import { BrowserToolsOptInCard } from "./browser-tools-card"; +import { PaseoToolsCard } from "./paseo-tools-card"; import { RestartRecoveryCard } from "./restart-recovery-card"; import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card"; import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; @@ -290,7 +291,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { {isConnected ? ( - + @@ -910,45 +911,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { ); } -function InjectPaseoToolsCard({ serverId }: { serverId: string }) { - const { t } = useTranslation(); - const isConnected = useHostRuntimeIsConnected(serverId); - const { config, patchConfig } = useDaemonConfig(serverId); - - const handleValueChange = useCallback( - (next: boolean) => { - void patchConfig({ - mcp: { - injectIntoAgents: next, - }, - }); - }, - [patchConfig], - ); - - if (!isConnected) return null; - - return ( - - - - - {t("settings.host.orchestration.enableTools.title")} - - - {t("settings.host.orchestration.enableTools.hint")} - - - - - - ); -} - function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) { const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); diff --git a/packages/app/src/screens/settings/paseo-tools-card.tsx b/packages/app/src/screens/settings/paseo-tools-card.tsx new file mode 100644 index 0000000000..2d6aace91f --- /dev/null +++ b/packages/app/src/screens/settings/paseo-tools-card.tsx @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; + +/** The host's "give agents Paseo tools" switch; Settings and first-run setup share it. */ +export function PaseoToolsCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + const handleValueChange = useCallback( + (next: boolean) => { + void patchConfig({ + mcp: { + injectIntoAgents: next, + }, + }); + }, + [patchConfig], + ); + + if (!isConnected) return null; + + return ( + + + + + {t("settings.host.orchestration.enableTools.title")} + + + {t("settings.host.orchestration.enableTools.hint")} + + + + + + ); +} diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 72234b7602..69d8367dd9 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -26,6 +26,7 @@ import { import { ProviderCatalogList } from "@/components/provider-catalog-list"; import { useProviderIcon } from "@/components/provider-icons"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { DropdownMenu, @@ -169,6 +170,36 @@ function ProviderActionsMenu({ ); } +const SKELETON_ROW_KEYS = [0, 1, 2, 3].map((index) => `provider-skeleton-row-${index}`); + +function ProviderRowsSkeleton() { + const { t } = useTranslation(); + const pulse = useSkeletonPulse(); + return ( + + {SKELETON_ROW_KEYS.map((key, index) => ( + 0 && settingsStyles.rowBorder, styles.row]} + > + + + + + + + + + + ))} + + ); +} + function ProviderRow({ serverId, def, @@ -439,11 +470,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { {t("settings.providers.unavailable")} ) : null} - {hasServer && isConnected && isLoading ? ( - - {t("settings.providers.loading")} - - ) : null} + {hasServer && isConnected && isLoading ? : null} {hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? ( {providerDefinitions.map((def, index) => { @@ -513,6 +540,25 @@ const styles = StyleSheet.create((theme) => ({ rowHovered: { backgroundColor: theme.colors.surface2, }, + skeletonIcon: { + width: 20, + height: 20, + marginLeft: theme.spacing[6], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + skeletonTitle: { + width: 160, + height: 12, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, + skeletonSwitch: { + width: 36, + height: 20, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + }, rowPressed: { backgroundColor: theme.colors.surface3, }, diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 3dba13bc80..d6d4a188c1 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -10,6 +10,7 @@ import { selectWorkspaceDirectoryServerIds, selectHasWorkspaces, selectProjectOrder, + selectProjectRootPaths, selectRecommendedProjectPaths, selectWorkspace, selectWorkspaceDirectory, @@ -147,6 +148,14 @@ export function useRecommendedProjectPaths(serverId: string | null): string[] { ); } +export function useProjectRootPaths(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectProjectRootPaths(state, serverId), + workspaceEqualityFns.deep, + ); +} + export function useHasWorkspaces(serverId: string | null): boolean { return useStoreWithEqualityFn( useSessionStore, diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index c131fa7a3a..85169b6f4a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -307,6 +307,14 @@ export function selectRecommendedProjectPaths( .filter((path) => path.length > 0); } +export function selectProjectRootPaths(state: SessionsSnapshot, serverId: string | null): string[] { + const projects = serverId ? state.sessions[serverId]?.projects : undefined; + if (!projects || projects.size === 0) { + return EMPTY_WORKSPACE_KEYS; + } + return Array.from(projects.values(), (project) => project.projectRootPath); +} + export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean { if (!serverId) { return false; diff --git a/packages/desktop/e2e/appearance-font-size.electron.mjs b/packages/desktop/e2e/appearance-font-size.electron.mjs index 3b92c6f8d1..af21ca1891 100644 --- a/packages/desktop/e2e/appearance-font-size.electron.mjs +++ b/packages/desktop/e2e/appearance-font-size.electron.mjs @@ -12,8 +12,7 @@ export async function runAppearanceFontSizeRegression(page) { await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Appearance", exact: true }).click(); - await page.getByLabel("Theme: System", { exact: true }).click(); - await page.getByText("Pure black", { exact: true }).click(); + await page.getByRole("radio", { name: "Pure black", exact: true }).click(); const interfaceSizeInput = page.getByRole("textbox", { name: "Interface font size" }); const contentSizeInput = page.getByRole("textbox", { name: "Content font size" }); diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs index a98a158511..bb19c947ef 100644 --- a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -72,6 +72,11 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port page = await desktop.firstWindow(); page.on("pageerror", (error) => console.log("Renderer error:", error.message)); await page.route(/:(6767|6768)\b/, (route) => route.abort()); + // The attached daemon has no projects, so first-run setup would open over Settings. + await page.evaluate(() => + localStorage.setItem("@paseo:onboarding", JSON.stringify({ completedAt: "1970-01-01" })), + ); + await page.reload(); await page.getByRole("button", { name: "Settings", exact: true }).click({ timeout: 90_000 }); await page.getByRole("button", { name: "Enable built-in daemon", exact: true }).click(); await expect(page.getByText("Attached to an existing daemon", { exact: true })).toBeVisible(); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index f1310e287f..1d0cfeb4d8 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -804,6 +804,11 @@ async function openSmokeWorkspace({ appPath, env, page, daemonHome }) { ], label: "Create packaged smoke workspace", }); + // The fresh daemon had no projects at launch, so first-run setup is open over the sidebar. + await page.evaluate(() => + localStorage.setItem("@paseo:onboarding", JSON.stringify({ completedAt: "1970-01-01" })), + ); + await page.reload(); await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click(); await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`)); await page.getByRole("button", { name: "Agent", exact: true }).click();