Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bd1b491dcb fix(app): keep layout sized to the window after rotation
A style update racing a window resize could recommit old root constraints. Rebuild the style update from the current root on each commit attempt.
2026-10-31 12:59:15 +01:00
64 changed files with 443 additions and 4013 deletions
@@ -9,11 +9,9 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
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();
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
await themeTrigger.click();
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("appearance-theme-picker.png"),
fullPage: true,
-231
View File
@@ -1,231 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, metroTest as test } from "../support/fixtures";
import {
addProjectFromSetup,
continueSetup,
expectSetupStep,
finishSetup,
goToSetupStep,
openAppAsNewUser,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
skipSetup,
startSetupHost,
} 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 { createTempDirectory, 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)";
test("a new user is walked through setup once", async ({ page }, testInfo) => {
const host = await startSetupHost("fresh");
const repo = await createTempGitRepo("onboarding-folder-");
try {
await openAppAsNewUser(page, host);
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("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
await continueSetup(page);
await expectSetupStep(page, "Add your projects");
await expect(
setupModal(page).getByText("No recent projects found", { exact: true }),
).toBeVisible({ timeout: 60_000 });
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 host.close();
await repo.cleanup();
}
});
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
const repo = await createTempGitRepo("onboarding-history-");
const repoName = path.basename(repo.path);
const subdirectory = path.join(repo.path, "packages", "web");
await mkdir(subdirectory, { recursive: true });
const worktree = `${repo.path}-fix`;
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
const scratch = await createTempDirectory("onboarding-scratch-");
const scratchName = path.basename(scratch.path);
const host = await startSetupHost("history", {
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
withBrokenProvider: true,
});
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await goToSetupStep(page, "Projects");
await expectSetupStep(page, "Add your projects");
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
await expect(projectSuggestion(page, repoName)).toContainText("3 sessions");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(setupModal(page).getByRole("checkbox")).toHaveCount(2);
});
await test.step("a provider that cannot read its history offers Retry", async () => {
await expect(
setupModal(page).getByText("Couldn't read Broken ACP history", { exact: true }),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page).getByRole("button", { name: "Retry" })).toBeVisible();
});
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
await expect(
setupModal(page).getByRole("button", { name: "Add another project…", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
await projectSuggestion(page, repoName).click();
await projectSuggestion(page, scratchName).click();
});
await test.step("a folder that cannot be added keeps the user on the step", async () => {
await scratch.cleanup();
await setupPrimaryButton(page, "Add 2 projects").click();
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
await expectSetupStep(page, "Add your projects");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(projectSuggestion(page, scratchName)).toContainText("Directory not found");
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
});
await test.step("with nothing checked, Continue moves on without adding", async () => {
await projectSuggestion(page, scratchName).click();
await setupPrimaryButton(page, "Continue").click();
await expectSetupStep(page, "Let agents use Paseo");
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
).toBeVisible({ timeout: 60_000 });
await expect(
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
).toHaveCount(0);
});
} finally {
await host.close();
await rm(worktree, { recursive: true, force: true });
await repo.cleanup();
await scratch.cleanup();
}
});
test("an existing user only sees setup when they ask for it", async ({ page }) => {
const host = await startSetupHost("existing");
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
try {
await openAppAsNewUser(page, host);
await expect(
page.getByTestId(`sidebar-workspace-row-${host.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 host.close();
}
});
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
page,
}, testInfo) => {
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
try {
await openAppAsNewUser(page, host);
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, host.serverId, "agents");
await expect(paseoToolsSwitch(page)).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
});
} finally {
await host.close();
}
});
+24 -13
View File
@@ -2,6 +2,7 @@ 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";
@@ -68,7 +69,8 @@ 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();
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
@@ -77,14 +79,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 () => {
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
await latteItem.click();
await expect(latteItem).toBeChecked();
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(mochaItem).toBeChecked();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
@@ -93,13 +95,15 @@ 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(mochaItem).toBeChecked({ timeout: 30_000 });
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
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.getByRole("radio", { name: "System", exact: true })).toBeChecked({
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -143,7 +147,15 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
return () => {};
}`;
test("reaches the last theme when a plugin contributes more themes than fit", async ({
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 ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
@@ -163,16 +175,15 @@ test("reaches the last theme when a plugin contributes more themes than fit", as
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
await lastTheme.scrollIntoViewIfNeeded();
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await lastTheme.click();
await expect(lastTheme).toBeChecked();
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
@@ -313,12 +313,12 @@ test.describe("Settings sidebar navigation", () => {
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Highlight theme:/).click();
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
@@ -38,9 +38,12 @@ async function expectDarkIcons(expected, context) {
assert.equal(windowState(), expected, context);
}
// Settings > Appearance shows every theme as a tile in one radio group.
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
+2 -13
View File
@@ -107,8 +107,6 @@ 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;
@@ -116,9 +114,8 @@ const test = daemonTest.extend<{
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
e2eOnboardingCompleted: [true, { option: true }],
paseoE2ESetup: [
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -160,7 +157,7 @@ const test = daemonTest.extend<{
const createAgentPreferences = buildCreateAgentPreferences();
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
// `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.
@@ -188,20 +185,12 @@ 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,
},
);
@@ -25,8 +25,6 @@ export interface IsolatedHostDaemon {
}
export interface IsolatedHostDaemonOptions {
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
environment?: NodeJS.ProcessEnv;
mutableRelay?: {
enabled: boolean;
@@ -95,28 +93,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
function withMutableRelay(
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
): NonNullable<IsolatedHostDaemonOptions["config"]> {
const endpoint =
relay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
return {
...config,
daemon: {
...config.daemon,
relay: {
enabled: relay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
};
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -162,13 +138,24 @@ export async function startIsolatedHostDaemon(
throw error;
}
}
const config = options.mutableRelay
? withMutableRelay(options.config ?? {}, options.mutableRelay)
: options.config;
if (config) {
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ version: 1, ...config })}\n`,
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
);
}
const serverDir = publishedPackageRoot
@@ -1,192 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
/** 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<void> {
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<void> {
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
});
}
export async function pickTheme(page: Page, name: string): Promise<void> {
const tile = setupModal(page).getByRole("radio", { name, exact: true });
await tile.click();
await expect(tile).toBeChecked();
}
export async function continueSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
}
export async function skipSetup(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
await page
.context()
.route("https://paseo.sh/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
);
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;
}
export interface SetupHost {
serverId: string;
port: number;
close(): Promise<void>;
}
/**
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
* never depend on the machine running the test. `withBrokenProvider` adds a provider that cannot
* start.
*/
export async function startSetupHost(
label: string,
options: {
claudeSessionsIn?: readonly string[];
withBrokenProvider?: boolean;
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
} = {},
): Promise<SetupHost> {
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
const daemon = await startIsolatedHostDaemon(serverId, {
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
environment: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfigDir },
mutableRelay: options.mutableRelay,
});
return {
serverId,
port: daemon.port,
close: async () => {
await daemon.close();
await rm(claudeConfigDir, { recursive: true, force: true });
},
};
}
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
const newest = Date.now() - 60_000;
for (const [index, cwd] of cwds.entries()) {
const sessionId = `onboarding-session-${index + 1}`;
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
await mkdir(projectDir, { recursive: true });
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
const record = {
type: "user",
uuid: `${sessionId}-user`,
message: { role: "user", content: `Work on session ${index + 1}` },
cwd,
sessionId,
};
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return configDir;
}
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
const brokenProvider = {
"broken-acp": {
extends: "acp",
label: "Broken ACP",
command: ["missing-agent-command", "acp"],
},
};
return {
agents: {
providers: {
codex: { enabled: false },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
...(options.withBrokenProvider ? brokenProvider : {}),
},
},
};
}
export function projectSuggestion(page: Page, name: string): Locator {
return setupModal(page).getByRole("checkbox", { name, exact: true });
}
/** The footer's main button, which adds the checked suggestions when there are any. */
export function setupPrimaryButton(page: Page, label: string): Locator {
return setupModal(page).getByRole("button", { name: label, exact: true });
}
-2
View File
@@ -35,7 +35,6 @@ 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";
@@ -495,7 +494,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -1,280 +0,0 @@
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<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
// 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 (
<View
style={styles.grid}
accessibilityRole="radiogroup"
accessibilityLabel={t("settings.appearance.theme.title")}
>
{PICKER_THEME_OPTIONS.map((option) => (
<BuiltInThemeTile
key={option.name}
value={option.name}
selected={selectedPluginTheme === null && builtInValue === option.name}
onSelect={selectBuiltIn}
/>
))}
{pluginThemes.map((option) => (
<PluginThemeTile
key={option.id}
option={option}
selected={selectedPluginTheme?.id === option.id}
onSelect={select}
/>
))}
</View>
);
}
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 (
<ThemeTile
label={t(`settings.appearance.theme.options.${value}`)}
selected={selected}
onPress={handlePress}
previewTheme={option && "theme" in option ? option.theme : "system"}
/>
);
}
function PluginThemeTile({
option,
selected,
onSelect,
}: {
option: PluginThemeOption;
selected: boolean;
onSelect: (option: PluginThemeOption) => void;
}) {
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
return (
<ThemeTile
label={option.name}
selected={selected}
onPress={handlePress}
previewTheme={option.theme}
/>
);
}
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 (
<PressHighlight
style={tileStyle}
onPress={onPress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={styles.previewFrame}>
{previewTheme === "system" ? <SystemThemePreview /> : <ThemePreview theme={previewTheme} />}
</View>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
/** 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 (
<View style={previewStyles.root}>
<View style={previewStyles.sidebar}>
<View style={[styles.previewLine, styles.sidebarLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineNarrow, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.sidebarLineMedium, previewStyles.mutedLine]} />
</View>
<View style={styles.previewMain}>
<View style={[styles.previewTitle, previewStyles.strongLine]} />
<View style={[styles.previewLine, styles.mainLineWide, previewStyles.mutedLine]} />
<View style={[styles.previewLine, styles.mainLineNarrow, previewStyles.mutedLine]} />
<View style={previewStyles.bubble} />
</View>
</View>
);
}
/** "System" follows the OS: light on the left half, dark on the right. */
function SystemThemePreview() {
return (
<View style={styles.preview}>
<ThemePreview theme={lightTheme} />
<View style={styles.systemDarkHalf}>
<View style={styles.systemDarkOffset}>
<ThemePreview theme={darkTheme} />
</View>
</View>
</View>
);
}
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,
},
}));
@@ -1,8 +1,7 @@
import { useMemo } from "react";
import { Animated, View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } 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;
@@ -12,6 +11,17 @@ 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<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
function SkeletonSection({
pulse,
sectionOpacity,
@@ -47,7 +57,27 @@ function SkeletonSection({
}
export function SidebarAgentListSkeleton() {
const pulse = useSkeletonPulse();
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 (
<View style={styles.container}>
@@ -1,34 +0,0 @@
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<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
@@ -22,7 +22,6 @@ 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);
@@ -220,14 +219,7 @@ function RelayConsent(props: PairDeviceBodyProps) {
</View>
<View style={styles.directRow}>
<ThemedNetwork size={14} style={styles.directIcon} />
<View style={styles.directContent}>
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
<ExternalLink
href={TAILSCALE_DOCS_URL}
label={t("pairing.device.tailscaleDocs")}
accessibilityLabel={t("pairing.device.tailscaleDocsAccessibility")}
/>
</View>
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
</View>
</View>
);
@@ -346,12 +338,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
marginTop: 1, // optical: seats the glyph on the hint's first text line
},
directContent: {
flex: 1,
alignItems: "flex-start",
gap: theme.spacing[2],
},
directHint: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
@@ -1,9 +1,39 @@
import { View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } 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<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
export function PrActivitySkeleton() {
const pulse = useSkeletonPulse();
@@ -2,8 +2,7 @@ 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 { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { PrActivitySkeleton } from "./activity-skeleton";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
-83
View File
@@ -1641,82 +1641,6 @@ 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: "إضافة مشروع",
addAnotherProject: "إضافة مشروع آخر…",
added: "تمت الإضافة",
suggestions: {
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
stillReading: "ما زلنا نقرأ سجل {{providers}}…",
readFailed: "تعذّرت قراءة سجل {{provider}}",
empty: "لم يتم العثور على مشاريع حديثة",
sessions_one: "جلسة واحدة ({{count}})",
sessions_other: "{{count}} جلسات",
addSelected_one: "إضافة مشروع واحد ({{count}})",
addSelected_other: "إضافة {{count}} مشاريع",
adding: "جارٍ الإضافة…",
addFailed: "تعذّرت إضافة هذا المجلد",
},
},
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: {
@@ -1935,8 +1859,6 @@ export const ar: TranslationResources = {
notNow: "ليس الآن",
directConnectionHint:
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
tailscaleDocs: "إعداد Tailscale",
tailscaleDocsAccessibility: "اقرأ كيفية الاتصال عبر Tailscale",
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
@@ -2436,11 +2358,6 @@ export const ar: TranslationResources = {
general: {
title: "عام",
sending: "الإرسال",
setup: {
label: "الإعداد الأولي",
description: "أعد المرور على المظهر والمزوّدين والمشاريع وإقران الهاتف",
action: "إظهار الإعداد مجددًا",
},
browserData: {
title: "بيانات المتصفح",
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
-83
View File
@@ -1666,82 +1666,6 @@ 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",
addAnotherProject: "Add another project…",
added: "Added",
suggestions: {
loading: "Looking for folders you've worked in",
stillReading: "Still reading {{providers}} history…",
readFailed: "Couldn't read {{provider}} history",
empty: "No recent projects found",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
addSelected_one: "Add {{count}} project",
addSelected_other: "Add {{count}} projects",
adding: "Adding…",
addFailed: "Couldn't add this folder",
},
},
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: {
@@ -1960,8 +1884,6 @@ 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.",
@@ -2525,11 +2447,6 @@ 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",
-86
View File
@@ -1687,84 +1687,6 @@ 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",
addAnotherProject: "Añadir otro proyecto…",
added: "Añadido",
suggestions: {
loading: "Buscando carpetas en las que has trabajado",
stillReading: "Todavía leyendo el historial de {{providers}}…",
readFailed: "No se pudo leer el historial de {{provider}}",
empty: "No se encontraron proyectos recientes",
sessions_one: "{{count}} sesión",
sessions_other: "{{count}} sesiones",
addSelected_one: "Añadir {{count}} proyecto",
addSelected_other: "Añadir {{count}} proyectos",
adding: "Añadiendo…",
addFailed: "No se pudo añadir esta carpeta",
},
},
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: {
@@ -1985,8 +1907,6 @@ 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.",
@@ -2450,12 +2370,6 @@ 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",
-86
View File
@@ -1685,84 +1685,6 @@ 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",
addAnotherProject: "Ajouter un autre projet…",
added: "Ajouté",
suggestions: {
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
stillReading: "Lecture de l'historique {{providers}} en cours…",
readFailed: "Impossible de lire l'historique {{provider}}",
empty: "Aucun projet récent trouvé",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
addSelected_one: "Ajouter {{count}} projet",
addSelected_other: "Ajouter {{count}} projets",
adding: "Ajout…",
addFailed: "Impossible d'ajouter ce dossier",
},
},
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: {
@@ -1983,8 +1905,6 @@ 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.",
@@ -2450,12 +2370,6 @@ 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",
-85
View File
@@ -1658,83 +1658,6 @@ 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: "プロジェクトを追加",
addAnotherProject: "別のプロジェクトを追加…",
added: "追加済み",
suggestions: {
loading: "作業したフォルダーを探しています",
stillReading: "{{providers}} の履歴を読み込んでいます…",
readFailed: "{{provider}} の履歴を読み込めませんでした",
empty: "最近のプロジェクトは見つかりませんでした",
sessions_one: "{{count}} セッション",
sessions_other: "{{count}} セッション",
addSelected_one: "{{count}} 件のプロジェクトを追加",
addSelected_other: "{{count}} 件のプロジェクトを追加",
adding: "追加しています…",
addFailed: "このフォルダーを追加できませんでした",
},
},
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: {
@@ -1955,8 +1878,6 @@ export const ja: TranslationResources = {
notNow: "今はしない",
directConnectionHint:
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
tailscaleDocs: "Tailscale を設定",
tailscaleDocsAccessibility: "Tailscale で接続する方法を読む",
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
@@ -2422,12 +2343,6 @@ export const ja: TranslationResources = {
general: {
title: "一般",
sending: "送信",
setup: {
label: "初回セットアップ",
description:
"外観、プロバイダー、プロジェクト、スマートフォンのペアリングをもう一度設定します",
action: "セットアップを再表示",
},
browserData: {
title: "ブラウザーデータ",
siteData: "Cookie とサイトデータ",
-83
View File
@@ -1651,82 +1651,6 @@ 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: "프로젝트 추가",
addAnotherProject: "다른 프로젝트 추가…",
added: "추가됨",
suggestions: {
loading: "작업했던 폴더를 찾는 중",
stillReading: "{{providers}} 기록을 아직 읽는 중…",
readFailed: "{{provider}} 기록을 읽지 못했습니다",
empty: "최근 프로젝트를 찾지 못했습니다",
sessions_one: "세션 {{count}}개",
sessions_other: "세션 {{count}}개",
addSelected_one: "프로젝트 {{count}}개 추가",
addSelected_other: "프로젝트 {{count}}개 추가",
adding: "추가하는 중…",
addFailed: "이 폴더를 추가하지 못했습니다",
},
},
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: {
@@ -1945,8 +1869,6 @@ export const ko: TranslationResources = {
notNow: "지금은 아님",
directConnectionHint:
"릴레이 없이 TCP, Tailscale 또는 다른 VPN을 통해 직접 연결하세요. QR 코드가 생성되지 않습니다.",
tailscaleDocs: "Tailscale 설정",
tailscaleDocsAccessibility: "Tailscale로 연결하는 방법 읽기",
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
unavailable: "페어링 정보를 사용할 수 없습니다.",
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
@@ -2410,11 +2332,6 @@ export const ko: TranslationResources = {
general: {
title: "일반",
sending: "전송",
setup: {
label: "초기 설정",
description: "모양, 제공자, 프로젝트, 휴대폰 페어링을 다시 진행합니다",
action: "설정 다시 보기",
},
browserData: {
title: "브라우저 데이터",
siteData: "쿠키 및 사이트 데이터",
-84
View File
@@ -1672,83 +1672,6 @@ 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",
addAnotherProject: "Adicionar outro projeto…",
added: "Adicionado",
suggestions: {
loading: "Procurando pastas em que você trabalhou",
stillReading: "Ainda lendo o histórico de {{providers}}…",
readFailed: "Não foi possível ler o histórico de {{provider}}",
empty: "Nenhum projeto recente encontrado",
sessions_one: "{{count}} sessão",
sessions_other: "{{count}} sessões",
addSelected_one: "Adicionar {{count}} projeto",
addSelected_other: "Adicionar {{count}} projetos",
adding: "Adicionando…",
addFailed: "Não foi possível adicionar esta pasta",
},
},
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: {
@@ -1969,8 +1892,6 @@ 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.",
@@ -2434,11 +2355,6 @@ 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",
-84
View File
@@ -1670,83 +1670,6 @@ 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: "Добавить проект",
addAnotherProject: "Добавить другой проект…",
added: "Добавлено",
suggestions: {
loading: "Ищем папки, в которых вы работали",
stillReading: "Ещё читаем историю {{providers}}…",
readFailed: "Не удалось прочитать историю {{provider}}",
empty: "Недавних проектов не найдено",
sessions_one: "{{count}} сессия",
sessions_other: "Сессий: {{count}}",
addSelected_one: "Добавить {{count}} проект",
addSelected_other: "Добавить проекты: {{count}}",
adding: "Добавляем…",
addFailed: "Не удалось добавить эту папку",
},
},
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: {
@@ -1968,8 +1891,6 @@ export const ru: TranslationResources = {
notNow: "Не сейчас",
directConnectionHint:
"Без ретранслятора подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код создаваться не будет.",
tailscaleDocs: "Настроить Tailscale",
tailscaleDocsAccessibility: "Узнать, как подключиться через Tailscale",
updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.",
unavailable: "Данные для сопряжения недоступны.",
hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.",
@@ -2436,11 +2357,6 @@ export const ru: TranslationResources = {
general: {
title: "Основные",
sending: "Отправка",
setup: {
label: "Первоначальная настройка",
description: "Снова пройти оформление, провайдеры, проекты и привязку телефона",
action: "Показать настройку снова",
},
browserData: {
title: "Данные браузера",
siteData: "Файлы cookie и данные сайтов",
-80
View File
@@ -1624,79 +1624,6 @@ 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: "添加项目",
addAnotherProject: "添加其他项目…",
added: "已添加",
suggestions: {
loading: "正在查找你工作过的文件夹",
stillReading: "仍在读取 {{providers}} 历史记录…",
readFailed: "无法读取 {{provider}} 历史记录",
empty: "未找到最近的项目",
sessions_one: "{{count}} 个会话",
sessions_other: "{{count}} 个会话",
addSelected_one: "添加 {{count}} 个项目",
addSelected_other: "添加 {{count}} 个项目",
adding: "正在添加…",
addFailed: "无法添加此文件夹",
},
},
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: {
@@ -1911,8 +1838,6 @@ export const zhCN: TranslationResources = {
notNow: "暂不",
directConnectionHint:
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
tailscaleDocs: "设置 Tailscale",
tailscaleDocsAccessibility: "了解如何通过 Tailscale 连接",
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
@@ -2374,11 +2299,6 @@ export const zhCN: TranslationResources = {
general: {
title: "通用",
sending: "发送",
setup: {
label: "首次设置",
description: "重新设置外观、提供方、项目和手机配对",
action: "再次显示设置",
},
browserData: {
title: "浏览器数据",
siteData: "Cookie 和网站数据",
-9
View File
@@ -1,9 +0,0 @@
/**
* 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";
@@ -1,25 +0,0 @@
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() }),
);
},
};
@@ -1,202 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createOnboardingModel,
type OnboardingCompletionStorage,
type OnboardingModel,
type OnboardingSetupHost,
} from "./model";
class MemoryCompletionStorage implements OnboardingCompletionStorage {
completed: boolean;
constructor(completed = false) {
this.completed = completed;
}
async isCompleted(): Promise<boolean> {
return this.completed;
}
async markCompleted(): Promise<void> {
this.completed = true;
}
}
const FRESH_HOST: OnboardingSetupHost = {
serverId: "local",
isConnected: true,
hasHydratedWorkspaces: true,
hasProjects: false,
};
/** Presses Continue on whatever step is showing. */
function advance(model: OnboardingModel) {
const state = model.getState();
if (state.status !== "open") throw new Error("setup is not open");
model.next(state.visit);
}
function currentVisit(model: OnboardingModel): number {
const state = model.getState();
if (state.status !== "open") throw new Error("setup is not open");
return state.visit;
}
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()).toMatchObject({
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 });
advance(model);
advance(model);
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 });
advance(model);
advance(model);
advance(model);
advance(model);
expect(model.getState()).toMatchObject({ stepIndex: 3 });
model.goTo("providers");
expect(model.getState()).toMatchObject({ stepIndex: 1 });
model.back();
expect(model.getState()).toMatchObject({ stepIndex: 0 });
});
it("ignores a late advance from a step visit the user already left", async () => {
const model = await openModel(new MemoryCompletionStorage(true));
model.reopen("local", { includePhoneStep: true });
model.goTo("projects");
// Projects are still being added when the user leaves Projects and comes back.
const addingFrom = currentVisit(model);
model.goTo("appearance");
model.goTo("projects");
model.next(addingFrom);
expect(model.getState()).toMatchObject({ stepIndex: 2 });
// Or closes setup, opens it again and returns to Projects before the add finishes.
await model.complete();
model.reopen("local", { includePhoneStep: true });
model.goTo("projects");
model.next(addingFrom);
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
});
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 });
advance(model);
await model.complete();
expect(seen).toEqual(["open:0", "open:1", "hidden"]);
});
});
@@ -1,165 +0,0 @@
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<boolean>;
markCompleted(): Promise<void>;
}
/** 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;
/** Identifies this stay on the current step; it changes on every move, close or reopen. */
visit: number;
};
export interface OnboardingModel {
/** Resolves once the completion record has loaded. */
ready: Promise<void>;
getState(): OnboardingState;
subscribe(listener: () => void): () => void;
applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void;
reopen(serverId: string, options: OnboardingStepOptions): void;
/**
* Moves on from the step visit the caller saw. A no-op once that visit ended, so an action that
* finishes late never moves a step the user left or a setup they closed and reopened.
*/
next(visit: number): void;
back(): void;
goTo(step: OnboardingStepId): void;
/** Closes setup, whether the user finished or skipped it, and remembers that on this client. */
complete(): Promise<void>;
}
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;
}
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;
let visits = 0;
function openState(serverId: string, options: OnboardingStepOptions): OnboardingState {
visits += 1;
return {
status: "open",
serverId,
steps: options.includePhoneStep ? STEPS_WITH_PHONE : STEPS_WITHOUT_PHONE,
stepIndex: 0,
visit: visits,
};
}
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;
visits += 1;
setState({ ...state, stepIndex: clamped, visit: visits });
}
async function loadCompletion(): Promise<void> {
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(visit) {
if (state.status === "open" && state.visit === visit) 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();
},
};
}
@@ -1,10 +0,0 @@
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;
}
@@ -1,343 +0,0 @@
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 { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions";
import type { Theme } from "@/styles/theme";
import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model";
import { OnboardingStepContent } from "./steps";
type OpenState = Extract<OnboardingState, { status: "open" }>;
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;
// Held here so Continue can add the checked folders; reading history from the first step means
// the Projects step rarely waits.
const projectSuggestions = useProjectSuggestions(state.serverId);
const { addChecked } = projectSuggestions;
const handleComplete = useCallback(() => {
void model.complete();
}, [model]);
const handleBack = useCallback(() => model.back(), [model]);
const { visit } = state;
const handleNext = useCallback(() => model.next(visit), [model, visit]);
const handleAddSuggestions = useCallback(() => {
void (async () => {
if (await addChecked()) model.next(visit);
})();
}, [addChecked, model, visit]);
const primary = usePrimaryAction({
step,
isLast,
projectSuggestions,
onFinish: handleComplete,
onNext: handleNext,
onAddSuggestions: handleAddSuggestions,
});
const header = useMemo(
() => ({
title: t("onboarding.setup.title"),
leading: <PaseoLogo size={20} />,
subtitle: isCompact ? (
<Text style={styles.stepCounter}>
{t("onboarding.setup.stepCounter", {
current: state.stepIndex + 1,
total: state.steps.length,
})}
</Text>
) : undefined,
}),
[isCompact, state.stepIndex, state.steps.length, t],
);
const footer = (
<>
<View>
{isLast ? null : (
<Button variant="ghost" onPress={handleComplete}>
{t("onboarding.setup.skip")}
</Button>
)}
</View>
<View style={styles.footerActions}>
{isFirst ? null : (
<Button variant="secondary" onPress={handleBack}>
{t("common.actions.back")}
</Button>
)}
<Button variant="default" onPress={primary.onPress} loading={primary.busy}>
{primary.label}
</Button>
</View>
</>
);
return (
<AdaptiveModalSheet
visible
onClose={handleComplete}
header={header}
footer={footer}
scrollable={false}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={880}
desktopHeight={640}
contentStyle={styles.content}
testID="onboarding-modal"
>
<View style={styles.body}>
{isCompact ? null : <StepRail state={state} onSelect={model.goTo} />}
<ScrollView style={styles.pane}>
{/* Padding lives on a wrapper: the ScrollView's contentContainerStyle skips Unistyles. */}
<View style={styles.paneContent}>
<View style={styles.stepHeader}>
<Text style={styles.stepTitle} accessibilityRole="header">
{t(`onboarding.setup.steps.${step}.title`)}
</Text>
<Text style={styles.stepDescription}>
{t(`onboarding.setup.steps.${step}.description`)}
</Text>
</View>
<OnboardingStepContent
step={step}
serverId={state.serverId}
projectSuggestions={projectSuggestions}
onFinish={handleComplete}
/>
</View>
</ScrollView>
</View>
</AdaptiveModalSheet>
);
}
/**
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
* every one was added; otherwise it continues, or finishes on the last step.
*/
function usePrimaryAction(input: {
step: OnboardingStepId;
isLast: boolean;
projectSuggestions: ProjectSuggestionsController;
onFinish: () => void;
onNext: () => void;
onAddSuggestions: () => void;
}): { label: string; busy: boolean; onPress: () => void } {
const { t } = useTranslation();
const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input;
const { checkedCount, isAdding } = projectSuggestions;
if (isLast) {
return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish };
}
// Rows leave the list as they are added, so the count can reach zero while adding.
if (step === "projects" && (isAdding || checkedCount > 0)) {
return {
label: isAdding
? t("onboarding.setup.steps.projects.suggestions.adding")
: t("onboarding.setup.steps.projects.suggestions.addSelected", { count: checkedCount }),
busy: isAdding,
onPress: onAddSuggestions,
};
}
return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext };
}
function StepRail({
state,
onSelect,
}: {
state: OpenState;
onSelect: (step: OnboardingStepId) => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.rail}>
<View style={styles.railSteps}>
{state.steps.map((step, index) => (
<StepRailItem
key={step}
step={step}
number={index + 1}
current={index === state.stepIndex}
done={index < state.stepIndex}
onSelect={onSelect}
/>
))}
</View>
<Text style={styles.railFootnote}>{t("onboarding.setup.footnote")}</Text>
</View>
);
}
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 (
<PressHighlight
style={itemStyle}
onPress={handlePress}
accessibilityRole="tab"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-selected={current}
>
<View style={done || current ? styles.railBadgeActive : styles.railBadge}>
{done ? (
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
) : (
<Text style={current ? styles.railBadgeTextActive : styles.railBadgeText}>{number}</Text>
)}
</View>
<Text style={current ? styles.railLabelCurrent : styles.railLabel} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
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],
},
}));
@@ -1,5 +0,0 @@
import { ThemePicker } from "@/appearance/theme-picker";
export function AppearanceStep() {
return <ThemePicker />;
}
@@ -1,32 +0,0 @@
import type { ProjectSuggestionsController } from "@/project-suggestions";
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,
projectSuggestions,
onFinish,
}: {
step: OnboardingStepId;
serverId: string;
projectSuggestions: ProjectSuggestionsController;
onFinish: () => void;
}) {
switch (step) {
case "appearance":
return <AppearanceStep />;
case "providers":
return <ProvidersStep serverId={serverId} />;
case "projects":
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
case "orchestration":
return <OrchestrationStep serverId={serverId} />;
case "phone":
return <PhoneStep serverId={serverId} onFinish={onFinish} />;
}
}
@@ -1,86 +0,0 @@
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 (
<View style={styles.container}>
<View style={styles.capabilities}>
{CAPABILITIES.map(({ key, icon }) => (
<Capability
key={key}
icon={icon}
title={t(`onboarding.setup.steps.orchestration.capabilities.${key}.title`)}
description={t(`onboarding.setup.steps.orchestration.capabilities.${key}.description`)}
/>
))}
</View>
<PaseoToolsCard serverId={serverId} />
</View>
);
}
function Capability({
icon: Icon,
title,
description,
}: {
icon: ThemedIcon;
title: string;
description: string;
}) {
return (
<View style={styles.capability}>
<Icon size={16} uniProps={mutedMapping} />
<View style={styles.capabilityText}>
<Text style={styles.capabilityTitle}>{title}</Text>
<Text style={styles.capabilityDescription}>{description}</Text>
</View>
</View>
);
}
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,
},
}));
@@ -1,45 +0,0 @@
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 (
<View style={styles.container}>
<View style={styles.storeLinks}>
<Button variant="outline" onPress={openAppStore}>
{t("onboarding.setup.steps.phone.appStore")}
</Button>
<Button variant="outline" onPress={openPlayStore}>
{t("onboarding.setup.steps.phone.googlePlay")}
</Button>
</View>
<PairDeviceSection serverId={serverId} onClose={onFinish} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[6],
},
storeLinks: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
}));
@@ -1,89 +0,0 @@
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 { ProjectSuggestionList, type ProjectSuggestionsController } from "@/project-suggestions";
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;
}
/** Adding the checked suggestions is the setup's primary action; see the modal footer. */
export function ProjectsStep({
serverId,
suggestions,
}: {
serverId: string;
suggestions: ProjectSuggestionsController;
}) {
const { t } = useTranslation();
const openAddProject = useOpenAddProject();
const projectRootPaths = useProjectRootPaths(serverId);
const handleAddProject = useCallback(() => openAddProject(serverId), [openAddProject, serverId]);
return (
<View style={styles.container}>
<ProjectSuggestionList suggestions={suggestions} />
{projectRootPaths.length > 0 ? (
<View style={settingsStyles.card}>
{projectRootPaths.map((rootPath, index) => (
<View
key={rootPath}
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]}
>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{basename(rootPath)}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{rootPath}
</Text>
</View>
<View style={styles.addedBadge}>
<ThemedCheck size={14} uniProps={successMapping} />
<Text style={styles.addedLabel}>{t("onboarding.setup.steps.projects.added")}</Text>
</View>
</View>
))}
</View>
) : null}
<View style={styles.actions}>
<Button variant="outline" leftIcon={FolderPlus} onPress={handleAddProject}>
{suggestions.list.status === "ready" && suggestions.list.rows.length > 0
? t("onboarding.setup.steps.projects.addAnotherProject")
: t("onboarding.setup.steps.projects.addProject")}
</Button>
</View>
</View>
);
}
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,
},
}));
@@ -1,10 +0,0 @@
import { View } from "react-native";
import { ProvidersSection } from "@/screens/settings/providers-section";
export function ProvidersStep({ serverId }: { serverId: string }) {
return (
<View>
<ProvidersSection serverId={serverId} />
</View>
);
}
@@ -1,14 +0,0 @@
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;
}
@@ -1,40 +0,0 @@
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 <OnboardingGate />;
}
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 <OnboardingModal model={model} state={state} />;
}
@@ -1,15 +0,0 @@
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;
}
@@ -1,9 +0,0 @@
/**
* Project suggestions: folders the user already ran agents in, offered as projects to add.
* Requires the host's `projectSuggestions` feature; on older hosts the list renders nothing.
*/
export { ProjectSuggestionList } from "./internal/project-suggestions";
export {
useProjectSuggestions,
type ProjectSuggestionsController,
} from "./internal/use-project-suggestions";
@@ -1,267 +0,0 @@
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 { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PressHighlight } from "@/components/ui/press-highlight";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ProjectSuggestion, ProviderSuggestions } from "./suggestions";
import type {
ProjectSuggestionsController,
SuggestionRow as SuggestionRowModel,
} from "./use-project-suggestions";
const ThemedCheck = withUnistyles(Check);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const SKELETON_ROWS = [0, 1, 2];
/**
* Folders the user already ran agents in, ready to add as projects. Rows appear as each provider
* answers and a failed provider can be retried. Adding the checked rows is the owner's primary
* action, through the same controller. Renders nothing on a host that cannot suggest projects.
*/
export function ProjectSuggestionList({
suggestions,
}: {
suggestions: ProjectSuggestionsController;
}) {
const { t } = useTranslation();
const { list } = suggestions;
if (list.status === "unsupported") return null;
if (list.status === "loading") return <SuggestionsSkeleton />;
return (
<View style={styles.container}>
{list.rows.length > 0 ? (
<View style={settingsStyles.card}>
{list.rows.map((row, index) => (
<SuggestionRow
key={row.suggestion.rootPath}
row={row}
first={index === 0}
providerLabels={list.providerLabels}
disabled={list.isAdding}
onToggle={list.toggle}
/>
))}
</View>
) : null}
{list.rows.length === 0 && list.reading.length === 0 ? (
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.empty")}
</Text>
) : null}
{list.reading.length > 0 ? <StillReading providers={list.reading} /> : null}
{list.failed.map((provider) => (
<ProviderFailure key={provider.provider} provider={provider} onRetry={list.retry} />
))}
</View>
);
}
function SuggestionRow({
row,
first,
providerLabels,
disabled,
onToggle,
}: {
row: SuggestionRowModel;
first: boolean;
providerLabels: ReadonlyMap<string, string>;
disabled: boolean;
onToggle: (suggestion: ProjectSuggestion) => void;
}) {
const { t } = useTranslation();
const { suggestion, checked, addFailure } = row;
const handlePress = useCallback(() => onToggle(suggestion), [onToggle, suggestion]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const lastActivity = useTimeAgo(useMemo(() => new Date(suggestion.lastActivityAt), [suggestion]));
const providers = suggestion.providerIds.map((id) => providerLabels.get(id) ?? id).join(" · ");
const rowStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
styles.row,
!first && settingsStyles.rowBorder,
hovered && styles.rowHovered,
],
[first],
);
return (
<PressHighlight
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={suggestion.displayName}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
{checked ? (
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
) : null}
</View>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{suggestion.displayName}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{suggestion.rootPath}
</Text>
{addFailure === undefined ? null : (
<Text style={settingsStyles.rowError}>
{addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")}
</Text>
)}
</View>
<View style={styles.meta}>
<Text style={styles.metaText} numberOfLines={1}>
{providers}
</Text>
<Text style={styles.metaText} numberOfLines={1}>
{t("onboarding.setup.steps.projects.suggestions.sessions", {
count: suggestion.sessionCount,
})}
{" · "}
{lastActivity}
</Text>
</View>
</PressHighlight>
);
}
function StillReading({ providers }: { providers: readonly ProviderSuggestions[] }) {
const { t } = useTranslation();
return (
<View style={styles.statusRow}>
<ThemedLoadingSpinner uniProps={mutedMapping} />
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.stillReading", {
providers: providers.map((provider) => provider.label).join(", "),
})}
</Text>
</View>
);
}
function ProviderFailure({
provider,
onRetry,
}: {
provider: ProviderSuggestions;
onRetry: (provider: string) => void;
}) {
const { t } = useTranslation();
const handleRetry = useCallback(() => onRetry(provider.provider), [onRetry, provider.provider]);
return (
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.readFailed", { provider: provider.label })}
</Text>
<Button variant="ghost" size="sm" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
function SuggestionsSkeleton() {
const { t } = useTranslation();
const pulse = useSkeletonPulse();
return (
<View
style={settingsStyles.card}
accessibilityLabel={t("onboarding.setup.steps.projects.suggestions.loading")}
testID="project-suggestions-skeleton"
>
{SKELETON_ROWS.map((row) => (
<View
key={row}
style={[settingsStyles.row, styles.row, row > 0 && settingsStyles.rowBorder]}
>
<SkeletonPulse pulse={pulse} style={styles.skeletonCheckbox} />
<View style={styles.skeletonText}>
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
<SkeletonPulse pulse={pulse} style={styles.skeletonPath} />
</View>
</View>
))}
</View>
);
}
const CHECKBOX_SIZE = 16;
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
row: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
checkbox: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
checkboxChecked: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
meta: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
metaText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
skeletonCheckbox: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
},
skeletonText: {
flex: 1,
gap: theme.spacing[2],
},
skeletonTitle: {
width: 140,
height: 12,
borderRadius: theme.borderRadius.sm,
},
skeletonPath: {
width: 220,
height: 10,
borderRadius: theme.borderRadius.sm,
},
}));
@@ -1,149 +0,0 @@
import { describe, expect, it } from "vitest";
import {
addSuggestedProjects,
isAwaitingFirstAnswer,
isSuggestionChecked,
mergeProviderSuggestions,
type ProjectSuggestion,
type ProviderSuggestions,
} from "./suggestions";
const NOW = new Date("2026-10-31T12:00:00.000Z");
function suggestion(
rootPath: string,
overrides: Partial<ProjectSuggestion> = {},
): ProjectSuggestion {
return {
rootPath,
displayName: rootPath.split("/").at(-1) ?? rootPath,
sessionCount: 1,
providerIds: ["claude"],
lastActivityAt: "2026-10-30T12:00:00.000Z",
...overrides,
};
}
describe("mergeProviderSuggestions", () => {
it("shows one row per folder across providers that answered, newest first", () => {
const rows = mergeProviderSuggestions(
[
{
provider: "claude",
label: "Claude",
status: "ready",
suggestions: [
suggestion("/dev/app", { sessionCount: 2, lastActivityAt: "2026-10-29T00:00:00.000Z" }),
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
],
},
{
provider: "codex",
label: "Codex",
status: "failed",
message: "Timed out",
suggestions: [
suggestion("/dev/app", {
sessionCount: 3,
providerIds: ["codex"],
lastActivityAt: "2026-10-30T00:00:00.000Z",
}),
],
},
{ provider: "opencode", label: "OpenCode", status: "loading" },
],
new Set(),
);
expect(rows).toEqual([
suggestion("/dev/app", {
sessionCount: 5,
providerIds: ["claude", "codex"],
lastActivityAt: "2026-10-30T00:00:00.000Z",
}),
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
]);
});
it("drops folders that are already projects", () => {
const rows = mergeProviderSuggestions(
[
{
provider: "claude",
label: "Claude",
status: "ready",
suggestions: [suggestion("/dev/app"), suggestion("/dev/docs")],
},
],
new Set(["/dev/app"]),
);
expect(rows.map((row) => row.rootPath)).toEqual(["/dev/docs"]);
});
});
describe("isSuggestionChecked", () => {
const busyRecent = suggestion("/dev/app", { sessionCount: 3 });
const quiet = suggestion("/dev/docs", { sessionCount: 2 });
const busyOld = suggestion("/dev/legacy", {
sessionCount: 9,
lastActivityAt: "2026-09-01T00:00:00.000Z",
});
it("starts checked for folders used at least three times in the last 30 days", () => {
expect([busyRecent, quiet, busyOld].map((row) => isSuggestionChecked(row, {}, NOW))).toEqual([
true,
false,
false,
]);
});
it("keeps the user's choice once they toggle a row", () => {
const choices = { "/dev/app": false, "/dev/docs": true };
expect(isSuggestionChecked(busyRecent, choices, NOW)).toBe(false);
expect(isSuggestionChecked(quiet, choices, NOW)).toBe(true);
});
});
describe("addSuggestedProjects", () => {
it("adds every folder and reports the ones the host refused", async () => {
const added: string[] = [];
const failures = await addSuggestedProjects(
["/dev/app", "/dev/gone", "/dev/docs"],
async (path) => {
if (path === "/dev/gone") return { ok: false, error: "Directory not found" };
added.push(path);
return { ok: true };
},
);
expect(added).toEqual(["/dev/app", "/dev/docs"]);
expect(failures).toEqual(new Map([["/dev/gone", "Directory not found"]]));
});
it("keeps going when adding one folder throws", async () => {
const failures = await addSuggestedProjects(["/dev/app", "/dev/docs"], async (path) => {
if (path === "/dev/app") throw new Error("Host disconnected");
return { ok: true };
});
expect(failures).toEqual(new Map([["/dev/app", "Host disconnected"]]));
});
});
describe("isAwaitingFirstAnswer", () => {
it("stops waiting once any provider answers, so a failure shows while others still read", () => {
const reading: ProviderSuggestions = { provider: "codex", label: "Codex", status: "loading" };
const failed: ProviderSuggestions = {
provider: "opencode",
label: "OpenCode",
status: "failed",
message: "Timed out",
suggestions: [],
};
expect(isAwaitingFirstAnswer([reading, { ...reading, provider: "pi" }])).toBe(true);
expect(isAwaitingFirstAnswer([reading, failed])).toBe(false);
expect(isAwaitingFirstAnswer([])).toBe(false);
});
});
@@ -1,97 +0,0 @@
import type { ProjectSuggestion } from "@getpaseo/protocol/messages";
export type { ProjectSuggestion };
const PRESELECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const PRESELECT_MIN_SESSIONS = 3;
/** One provider's answer. Each provider is asked separately so a slow one never holds up the rest. */
export type ProviderSuggestions =
| { provider: string; label: string; status: "loading" }
| { provider: string; label: string; status: "ready"; suggestions: ProjectSuggestion[] }
| {
provider: string;
label: string;
status: "failed";
message: string;
suggestions: ProjectSuggestion[];
};
/** Combines the providers that answered into one row per folder, most recent first. */
export function mergeProviderSuggestions(
providers: readonly ProviderSuggestions[],
addedRootPaths: ReadonlySet<string>,
): ProjectSuggestion[] {
const byRoot = new Map<string, ProjectSuggestion>();
for (const provider of providers) {
if (provider.status === "loading") continue;
for (const suggestion of provider.suggestions) {
if (addedRootPaths.has(suggestion.rootPath)) continue;
const existing = byRoot.get(suggestion.rootPath);
byRoot.set(
suggestion.rootPath,
existing ? combineSuggestions(existing, suggestion) : suggestion,
);
}
}
return Array.from(byRoot.values()).sort((a, b) =>
b.lastActivityAt.localeCompare(a.lastActivityAt),
);
}
function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): ProjectSuggestion {
return {
...a,
sessionCount: a.sessionCount + b.sessionCount,
providerIds: Array.from(new Set([...a.providerIds, ...b.providerIds])).sort(),
lastActivityAt: a.lastActivityAt > b.lastActivityAt ? a.lastActivityAt : b.lastActivityAt,
};
}
/**
* True until the first provider answers. After that the list shows what it has, including a failed
* provider's Retry, while the slower providers keep reading.
*/
export function isAwaitingFirstAnswer(providers: readonly ProviderSuggestions[]): boolean {
return providers.length > 0 && providers.every((provider) => provider.status === "loading");
}
/** Folders the user worked in often and recently start checked. */
export function isPreselected(suggestion: ProjectSuggestion, now: Date): boolean {
return (
suggestion.sessionCount >= PRESELECT_MIN_SESSIONS &&
now.getTime() - Date.parse(suggestion.lastActivityAt) <= PRESELECT_WINDOW_MS
);
}
/**
* Checks start from the preselection; once the user toggles a row, their choice sticks even as
* more providers answer and the row's counts change.
*/
export function isSuggestionChecked(
suggestion: ProjectSuggestion,
choices: Readonly<Record<string, boolean>>,
now: Date,
): boolean {
return choices[suggestion.rootPath] ?? isPreselected(suggestion, now);
}
/**
* Adds each folder in turn and returns the ones that failed, with the host's message when it gave
* one. Sequential so the sidebar fills in the order the user sees.
*/
export async function addSuggestedProjects(
rootPaths: readonly string[],
addProject: (rootPath: string) => Promise<{ ok: true } | { ok: false; error: string | null }>,
): Promise<Map<string, string | null>> {
const failures = new Map<string, string | null>();
for (const rootPath of rootPaths) {
try {
const result = await addProject(rootPath);
if (!result.ok) failures.set(rootPath, result.error);
} catch (error) {
failures.set(rootPath, error instanceof Error ? error.message : null);
}
}
return failures;
}
@@ -1,112 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useOpenProject } from "@/hooks/use-open-project";
import { useProjectRootPaths } from "@/stores/session-store-hooks";
import {
addSuggestedProjects,
isAwaitingFirstAnswer,
isSuggestionChecked,
mergeProviderSuggestions,
type ProjectSuggestion,
type ProviderSuggestions,
} from "./suggestions";
import { useProviderSuggestions } from "./use-provider-suggestions";
export interface SuggestionRow {
suggestion: ProjectSuggestion;
checked: boolean;
/** Undefined when the folder was not attempted; null when the host gave no reason. */
addFailure: string | null | undefined;
}
export type SuggestionList =
| { status: "unsupported" }
/** Nothing to show yet: the provider list or the first provider's answer is pending. */
| { status: "loading" }
| {
status: "ready";
rows: SuggestionRow[];
providerLabels: ReadonlyMap<string, string>;
reading: ProviderSuggestions[];
failed: ProviderSuggestions[];
/** True while checked rows are being added; the selection is frozen until it settles. */
isAdding: boolean;
toggle: (suggestion: ProjectSuggestion) => void;
retry: (provider: string) => void;
};
export interface ProjectSuggestionsController {
list: SuggestionList;
checkedCount: number;
isAdding: boolean;
/** Adds every checked folder. Resolves true when all of them were added. */
addChecked: () => Promise<boolean>;
}
/**
* The suggestion list and the user's selection. Whoever owns the step's primary action holds
* this, so proceeding can add what is checked; calling it early also starts reading history.
*/
export function useProjectSuggestions(serverId: string): ProjectSuggestionsController {
const state = useProviderSuggestions(serverId);
const projectRootPaths = useProjectRootPaths(serverId);
const openProject = useOpenProject(serverId);
const [now] = useState(() => new Date());
const [choices, setChoices] = useState<Record<string, boolean>>({});
const [addFailures, setAddFailures] = useState<Map<string, string | null>>(new Map());
const providers = state.status === "ready" ? state.providers : null;
const suggestions = useMemo(
() => (providers ? mergeProviderSuggestions(providers, new Set(projectRootPaths)) : []),
[providers, projectRootPaths],
);
const checkedRootPaths = useMemo(
() =>
suggestions
.filter((suggestion) => isSuggestionChecked(suggestion, choices, now))
.map((suggestion) => suggestion.rootPath),
[suggestions, choices, now],
);
const toggle = useCallback(
(suggestion: ProjectSuggestion) => {
setChoices((current) => ({
...current,
[suggestion.rootPath]: !isSuggestionChecked(suggestion, current, now),
}));
},
[now],
);
const { mutateAsync, isPending } = useMutation({
mutationFn: (rootPaths: string[]) => addSuggestedProjects(rootPaths, openProject),
onSuccess: setAddFailures,
});
const addChecked = useCallback(async () => {
const failures = await mutateAsync(checkedRootPaths);
return failures.size === 0;
}, [mutateAsync, checkedRootPaths]);
const list = useMemo((): SuggestionList => {
if (state.status !== "ready") return state;
if (isAwaitingFirstAnswer(state.providers)) return { status: "loading" };
return {
status: "ready",
rows: suggestions.map((suggestion) => ({
suggestion,
checked: checkedRootPaths.includes(suggestion.rootPath),
addFailure: addFailures.get(suggestion.rootPath),
})),
providerLabels: new Map(
state.providers.map((provider) => [provider.provider, provider.label]),
),
reading: state.providers.filter((provider) => provider.status === "loading"),
failed: state.providers.filter((provider) => provider.status === "failed"),
isAdding: isPending,
toggle,
retry: state.retry,
};
}, [state, suggestions, checkedRootPaths, addFailures, isPending, toggle]);
return { list, checkedCount: checkedRootPaths.length, isAdding: isPending, addChecked };
}
@@ -1,95 +0,0 @@
import { useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useFetchQueries } from "@/data/query";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ProviderSuggestions } from "./suggestions";
type SuggestionsResponse = Awaited<ReturnType<DaemonClient["listProjectSuggestions"]>>;
// History changes slowly. Opening the step still re-asks, keeping the rows already shown.
const SUGGESTIONS_STALE_TIME_MS = 5 * 60 * 1000;
export type ProjectSuggestionsState =
| { status: "unsupported" }
/** The provider list itself has not arrived yet. */
| { status: "loading" }
| { status: "ready"; providers: ProviderSuggestions[]; retry: (provider: string) => void };
function suggestionsQueryKey(serverId: string, provider: string) {
return ["project-suggestions", serverId, provider] as const;
}
/**
* Asks the host for suggestions once per enabled provider, in parallel. The queries are shared, so
* calling this early (when setup opens) means the Projects step usually finds them answered.
*/
export function useProviderSuggestions(serverId: string): ProjectSuggestionsState {
const queryClient = useQueryClient();
const supported = useHostFeature(serverId, "projectSuggestions");
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries } = useProvidersSnapshot(serverId, { enabled: supported });
const providers = useMemo(
() =>
entries
?.filter((entry) => entry.enabled !== false)
.map((entry) => ({ id: entry.provider, label: entry.label ?? entry.provider })),
[entries],
);
const results = useFetchQueries<SuggestionsResponse>(
(supported && providers ? providers : []).map((provider) => ({
queryKey: suggestionsQueryKey(serverId, provider.id),
dataShape: "value",
enabled: Boolean(client) && isConnected,
// A provider that cannot answer stays failed until the user presses Retry.
retry: false,
staleTimeMs: SUGGESTIONS_STALE_TIME_MS,
queryFn: () => {
if (!client) throw new Error("Host disconnected");
return client.listProjectSuggestions({ providers: [provider.id] });
},
})),
);
const retry = useCallback(
(provider: string) => {
void queryClient.refetchQueries({ queryKey: suggestionsQueryKey(serverId, provider) });
},
[queryClient, serverId],
);
return useMemo((): ProjectSuggestionsState => {
if (!supported) return { status: "unsupported" };
if (!providers) return { status: "loading" };
return {
status: "ready",
providers: providers.map((provider, index) =>
toProviderSuggestions(provider, results[index]),
),
retry,
};
}, [supported, providers, results, retry]);
}
function toProviderSuggestions(
provider: { id: string; label: string },
result: { data?: SuggestionsResponse; error: Error | null; isFetching: boolean } | undefined,
): ProviderSuggestions {
const base = { provider: provider.id, label: provider.label };
const data = result?.data;
const providerError = data?.providerErrors.find((error) => error.provider === provider.id);
const failure = result?.error?.message ?? providerError?.message ?? null;
// A retry shows as reading again; a background refresh of good results keeps the rows.
if (!result || (result.isFetching && (failure !== null || !data))) {
return { ...base, status: "loading" };
}
if (failure !== null) {
return { ...base, status: "failed", message: failure, suggestions: data?.suggestions ?? [] };
}
if (!data) return { ...base, status: "loading" };
return { ...base, status: "ready", suggestions: data.suggestions };
}
+1 -24
View File
@@ -145,8 +145,7 @@ import {
useSettingsSidebarScroll,
} from "@/navigation/settings-presentation";
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
import { isNative, isWeb } from "@/constants/platform";
import { useOpenOnboarding } from "@/onboarding";
import { isWeb } from "@/constants/platform";
// ---------------------------------------------------------------------------
// View model
@@ -390,33 +389,11 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps)
</DropdownMenuContent>
</DropdownMenu>
</View>
{isNative ? null : <ShowSetupAgainRow />}
</View>
</SettingsSection>
);
}
function ShowSetupAgainRow() {
const { t } = useTranslation();
const openOnboarding = useOpenOnboarding();
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.setup.label")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.general.setup.description")}</Text>
</View>
<Button
variant="secondary"
size="sm"
onPress={openOnboarding ?? undefined}
disabled={!openOnboarding}
>
{t("settings.general.setup.action")}
</Button>
</View>
);
}
interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
@@ -1,17 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, 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 } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Monitor, Moon, Sun } from "lucide-react-native";
import {
SYNTAX_THEME_OPTIONS,
type SyntaxThemeId,
type SyntaxThemeOption,
} from "@getpaseo/highlight";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { ThemePicker } from "@/appearance/theme-picker";
import { useContributedThemes } from "@/appearance/provider";
import { Button } from "@/components/ui/button";
import {
EditingTextInput as TextInput,
@@ -30,12 +36,40 @@ import {
sanitizeFontFamily,
useAppSettings,
type AppSettings,
DEFAULT_THEME_PREFERENCE,
} from "@/hooks/use-settings";
import { DEFAULT_MONO_FONT_STACK, DEFAULT_UI_FONT_STACK } from "@/styles/theme";
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
ICON_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
THEME_SWATCHES,
type Theme,
} 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<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
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<string> = new Set(["normal", "monospace"]);
@@ -52,6 +86,148 @@ 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 <ThemedSun size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "dark":
return <ThemedMoon size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "auto":
return <ThemedMonitor size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[themeValue]} />;
}
}
interface ThemeSwatchProps {
color: string;
}
function ThemeSwatch({ color }: ThemeSwatchProps) {
const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]);
return <View style={swatchStyle} />;
}
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(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{getThemeLabel(t, themeValue)}
</DropdownMenuItem>
);
}
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(() => <ThemeSwatch color={option.swatch} />, [option.swatch]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{option.name}
</DropdownMenuItem>
);
}
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 ? (
<ThemeSwatch color={selectedPluginTheme.swatch} />
) : (
<ThemeLeading themeValue={builtInValue} />
),
[builtInValue, selectedPluginTheme],
);
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
</View>
<DropdownMenu>
<DropdownTrigger
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
value: selectedLabel,
})}
leading={leading}
>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
return (
<Fragment key={option.name}>
{previousOption && previousOption.group !== option.group ? (
<DropdownMenuSeparator />
) : null}
<ThemeMenuItem
themeValue={option.name}
selected={selectedPluginTheme === null && builtInValue === option.name}
onChange={onChange}
/>
</Fragment>
);
})}
{pluginThemes.length > 0 ? <DropdownMenuSeparator /> : null}
{pluginThemes.map((option) => (
<PluginThemeMenuItem
key={option.id}
option={option}
selected={selectedPluginTheme?.id === option.id}
onSelect={onSelectPluginTheme}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// Fonts: family text fields + numeric size fields (commit on blur/submit)
// ---------------------------------------------------------------------------
@@ -302,6 +478,11 @@ 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);
@@ -323,6 +504,20 @@ 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 });
@@ -432,8 +627,14 @@ export function AppearanceSection() {
return (
<View>
<SettingsSection title={t("settings.appearance.theme.title")}>
<View style={[settingsStyles.card, styles.themeCard]}>
<ThemePicker />
<View style={settingsStyles.card}>
<ThemeRow
value={settings.theme}
pluginThemes={pluginThemes}
selectedPluginTheme={selectedPluginTheme}
onChange={handleThemeChange}
onSelectPluginTheme={handlePluginThemeChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.fonts.title")}>
@@ -522,8 +723,12 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
themeCard: {
padding: theme.spacing[3],
swatch: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
borderRadius: ICON_SIZE.md / 2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
fontFamilyInput: {
flexGrow: 1,
@@ -65,7 +65,6 @@ 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";
@@ -291,7 +290,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<View>
{isConnected ? (
<SettingsSection title={t("settings.hostSections.agents")}>
<PaseoToolsCard serverId={serverId} />
<InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
<RestartRecoveryCard serverId={serverId} />
@@ -911,6 +910,45 @@ 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 (
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.orchestration.enableTools.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.host.orchestration.enableTools.hint")}
</Text>
</View>
<Switch
value={config?.mcp.injectIntoAgents !== false}
onValueChange={handleValueChange}
accessibilityLabel={t("settings.host.orchestration.enableTools.accessibilityLabel")}
/>
</View>
</View>
);
}
function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
@@ -1,47 +0,0 @@
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 (
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.orchestration.enableTools.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.host.orchestration.enableTools.hint")}
</Text>
</View>
<Switch
value={config?.mcp.injectIntoAgents !== false}
onValueChange={handleValueChange}
accessibilityLabel={t("settings.host.orchestration.enableTools.accessibilityLabel")}
/>
</View>
</View>
);
}
@@ -26,7 +26,6 @@ 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,
@@ -170,36 +169,6 @@ 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 (
<View
style={settingsStyles.card}
accessibilityLabel={t("settings.providers.loading")}
testID="providers-skeleton"
>
{SKELETON_ROW_KEYS.map((key, index) => (
<View
key={key}
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder, styles.row]}
>
<View style={styles.rowContent}>
<SkeletonPulse pulse={pulse} style={styles.skeletonIcon} />
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
</View>
<View style={styles.trailingControls}>
<SkeletonPulse pulse={pulse} style={styles.skeletonSwitch} />
<View style={styles.menuSlot} />
</View>
</View>
))}
</View>
);
}
function ProviderRow({
serverId,
def,
@@ -470,7 +439,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
<Text style={styles.emptyText}>{t("settings.providers.unavailable")}</Text>
</View>
) : null}
{hasServer && isConnected && isLoading ? <ProviderRowsSkeleton /> : null}
{hasServer && isConnected && isLoading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>{t("settings.providers.loading")}</Text>
</View>
) : null}
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
<View style={settingsStyles.card}>
{providerDefinitions.map((def, index) => {
@@ -540,25 +513,6 @@ 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,
},
@@ -10,7 +10,6 @@ import {
selectWorkspaceDirectoryServerIds,
selectHasWorkspaces,
selectProjectOrder,
selectProjectRootPaths,
selectRecommendedProjectPaths,
selectWorkspace,
selectWorkspaceDirectory,
@@ -148,14 +147,6 @@ 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,
@@ -307,14 +307,6 @@ 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;
-21
View File
@@ -869,14 +869,6 @@ export type WorkspaceLabelDeleteInspectPayload = Extract<
SessionOutboundMessage,
{ type: "workspace.label.delete.inspect.response" }
>["payload"];
export type ProjectSuggestionsPayload = Extract<
SessionOutboundMessage,
{ type: "project.suggestions.response" }
>["payload"];
export type ProjectSuggestionsOptions = Omit<
Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
"type" | "requestId"
> & { requestId?: string };
export type ProjectListPayload = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
@@ -2984,19 +2976,6 @@ export class DaemonClient {
});
}
/** Repositories the user ran agent sessions in recently, grouped by main checkout. */
listProjectSuggestions(options?: ProjectSuggestionsOptions): Promise<ProjectSuggestionsPayload> {
return this.sendNamespacedCorrelatedSessionRequest<"project.suggestions.response">({
requestId: options?.requestId,
message: {
type: "project.suggestions.request",
...(options?.providers ? { providers: options.providers } : {}),
...(options?.since ? { since: options.since } : {}),
...(options?.limit ? { limit: options.limit } : {}),
},
});
}
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -12,7 +12,8 @@ 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.getByRole("radio", { name: "Pure black", exact: true }).click();
await page.getByLabel("Theme: System", { exact: true }).click();
await page.getByText("Pure black", { exact: true }).click();
const interfaceSizeInput = page.getByRole("textbox", { name: "Interface font size" });
const contentSizeInput = page.getByRole("textbox", { name: "Content font size" });
@@ -804,11 +804,6 @@ 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();
-34
View File
@@ -1370,15 +1370,6 @@ export const ProjectListRequestMessageSchema = z.object({
sync: DirectorySyncRequestSchema.optional(),
});
export const ProjectSuggestionsRequestSchema = z.object({
type: z.literal("project.suggestions.request"),
requestId: z.string(),
providers: z.array(z.string()).optional(),
// ISO timestamp. Sessions older than this are ignored. Defaults to 30 days ago.
since: z.string().optional(),
limit: z.number().int().positive().max(100).optional(),
});
export const FetchAgentHistoryRequestMessageSchema = z.object({
type: z.literal("fetch_agent_history_request"),
requestId: z.string(),
@@ -3305,7 +3296,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FetchRecentProviderSessionsRequestMessageSchema,
FetchWorkspacesRequestMessageSchema,
ProjectListRequestMessageSchema,
ProjectSuggestionsRequestSchema,
FetchAgentRequestMessageSchema,
DeleteAgentRequestMessageSchema,
ArchiveAgentRequestMessageSchema,
@@ -3823,8 +3813,6 @@ export const ServerInfoStatusPayloadSchema = z
projectCreateDirectory: z.boolean().optional(),
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: z.boolean().optional(),
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
projectSuggestions: z.boolean().optional(),
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: z.boolean().optional(),
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
@@ -4510,24 +4498,6 @@ export const ProjectListResponseMessageSchema = z.object({
}),
});
export const ProjectSuggestionSchema = z.object({
// The main repository root for git checkouts and their worktrees; the session cwd otherwise.
rootPath: z.string(),
displayName: z.string(),
sessionCount: z.number().int().nonnegative(),
providerIds: z.array(z.string()),
lastActivityAt: z.string(),
});
export const ProjectSuggestionsResponseSchema = z.object({
type: z.literal("project.suggestions.response"),
payload: z.object({
requestId: z.string(),
suggestions: z.array(ProjectSuggestionSchema),
providerErrors: z.array(z.object({ provider: z.string(), message: z.string() })),
}),
});
export const ScriptStatusUpdateMessageSchema = z.object({
type: z.literal("script_status_update"),
payload: z.object({
@@ -7094,7 +7064,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceLabelDeleteInspectResponseSchema,
ProjectUpdateMessageSchema,
ProjectListResponseMessageSchema,
ProjectSuggestionsResponseSchema,
ScriptStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
@@ -7459,9 +7428,6 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
>;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
export type ProjectSuggestionsRequest = z.infer<typeof ProjectSuggestionsRequestSchema>;
export type ProjectSuggestion = z.infer<typeof ProjectSuggestionSchema>;
export type ProjectSuggestionsResponse = z.infer<typeof ProjectSuggestionsResponseSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
@@ -380,7 +380,7 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
export function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
return (
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
);
@@ -153,7 +153,6 @@ const INBOUND_PERMISSION = {
"project.icon.get.request": "workspace.read",
"project.icon.set.request": "workspace.manage",
"project.list.request": "workspace.read",
"project.suggestions.request": "workspace.read",
"project.remove.request": "workspace.manage",
"project.rename.request": "workspace.manage",
project_icon_request: "workspace.read",
@@ -391,7 +390,6 @@ const OUTBOUND_PERMISSION = {
"project.icon.get.response": "workspace.read",
"project.icon.set.response": "workspace.manage",
"project.list.response": "workspace.read",
"project.suggestions.response": "workspace.read",
"project.remove.response": "workspace.manage",
"project.rename.response": "workspace.manage",
"project.update": "workspace.read",
@@ -1,188 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import type {
AgentManager,
ImportableSessionProviderError,
ManagedImportableProviderSession,
} from "./agent/agent-manager.js";
import { listProjectSuggestions } from "./project-suggestions.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { PersistedProjectRecord } from "./workspace-registry.js";
const NOW = new Date("2026-10-31T12:00:00.000Z");
function daysAgo(days: number): Date {
return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000);
}
function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, stdio: "ignore" });
}
function createRepo(root: string, name: string): string {
const repo = path.join(root, name);
mkdirSync(repo, { recursive: true });
git(repo, "init", "-b", "main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test");
writeFileSync(path.join(repo, "README.md"), "hello\n");
git(repo, "add", ".");
git(repo, "commit", "-m", "init");
return repo;
}
function session(
provider: string,
cwd: string,
lastActivityAt: Date,
): ManagedImportableProviderSession {
return {
provider,
providerHandleId: `${provider}-${cwd}-${lastActivityAt.toISOString()}`,
cwd,
title: null,
firstPromptPreview: "Fix the build",
lastPromptPreview: null,
lastActivityAt,
};
}
/** Agent history as the providers report it. */
function agentHistory(
sessions: ManagedImportableProviderSession[],
providerErrors: ImportableSessionProviderError[] = [],
): Pick<AgentManager, "listImportableSessions"> {
return {
listImportableSessions: async (options) => ({
sessions: sessions.filter(
(entry) => !options?.providerFilter || options.providerFilter.has(entry.provider),
),
providerErrors,
}),
};
}
function projects(...rootPaths: Array<{ rootPath: string; archived?: boolean }>) {
const records = rootPaths.map(
({ rootPath, archived }, index): PersistedProjectRecord => ({
projectId: `project-${index}`,
rootPath,
kind: "git",
displayName: path.basename(rootPath),
projectKey: null,
customName: null,
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
archivedAt: archived ? NOW.toISOString() : null,
}),
);
return { list: async () => records };
}
describe("listProjectSuggestions", () => {
let root: string;
let gitService: WorkspaceGitServiceImpl;
beforeEach(() => {
// The native realpath expands Windows short names (RUNNER~1), which git reports in full.
root = realpathSync.native(mkdtempSync(path.join(tmpdir(), "project-suggestions-")));
gitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: path.join(root, ".paseo"),
});
});
afterEach(async () => {
await gitService.dispose();
rmSync(root, { recursive: true, force: true });
});
test("groups sessions in a repo, its subdirectories and its worktrees under the main checkout", async () => {
const app = createRepo(root, "app");
mkdirSync(path.join(app, "packages", "web"), { recursive: true });
const worktree = path.join(root, "codex-worktrees", "app-feature");
git(app, "worktree", "add", "-b", "feature", worktree);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", app, daysAgo(1)),
session("claude", path.join(app, "packages", "web"), daysAgo(2)),
session("codex", worktree, daysAgo(3)),
]),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: app,
displayName: "app",
sessionCount: 3,
providerIds: ["claude", "codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [],
});
});
test("leaves out active projects, deleted folders, old sessions and the home directory", async () => {
const added = createRepo(root, "already-added");
const removed = createRepo(root, "removed-project");
const notes = path.join(root, "notes");
mkdirSync(notes);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", added, daysAgo(1)),
session("claude", removed, daysAgo(2)),
session("claude", notes, daysAgo(4)),
session("claude", path.join(root, "deleted"), daysAgo(1)),
session("claude", createRepo(root, "stale"), daysAgo(45)),
session("claude", homedir(), daysAgo(1)),
]),
projectRegistry: projects({ rootPath: added }, { rootPath: removed, archived: true }),
workspaceGitService: gitService,
now: NOW,
});
expect(result.suggestions.map((suggestion) => suggestion.rootPath)).toEqual([removed, notes]);
});
test("reads only the requested providers and reports the ones that failed", async () => {
const app = createRepo(root, "app");
const api = createRepo(root, "api");
const result = await listProjectSuggestions({
request: { providers: ["codex"] },
agentManager: agentHistory(
[session("claude", app, daysAgo(1)), session("codex", api, daysAgo(1))],
[{ provider: "codex", message: "Timed out listing sessions" }],
),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: api,
displayName: "api",
sessionCount: 1,
providerIds: ["codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [{ provider: "codex", message: "Timed out listing sessions" }],
});
});
});
@@ -1,177 +0,0 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, parse, resolve } from "node:path";
import pLimit from "p-limit";
import type { ProjectSuggestion, ProjectSuggestionsRequest } from "@getpaseo/protocol/messages";
import type { AgentManager, ManagedImportableProviderSession } from "./agent/agent-manager.js";
import { isMetadataGenerationSession } from "./agent/import-sessions.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectRegistry } from "./workspace-registry.js";
import { normalizePathForIdentity } from "../utils/path.js";
const DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_LIMIT = 20;
// Sessions read per provider. Enough history to find every repo used in the window.
const SESSION_SCAN_LIMIT = 500;
const CHECKOUT_READ_CONCURRENCY = 8;
export class ProjectSuggestionsRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ProjectSuggestionsRequestError";
}
}
export interface ListProjectSuggestionsInput {
request: Pick<ProjectSuggestionsRequest, "providers" | "since" | "limit">;
agentManager: Pick<AgentManager, "listImportableSessions">;
projectRegistry: Pick<ProjectRegistry, "list">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
now?: Date;
}
export interface ListProjectSuggestionsResult {
suggestions: ProjectSuggestion[];
providerErrors: Array<{ provider: string; message: string }>;
}
/**
* Turns the agent sessions a user already ran into projects they could add. Sessions are grouped
* by repository: a session in a subdirectory or in any worktree counts toward the main checkout,
* because that is the path a project is keyed by.
*/
export async function listProjectSuggestions(
input: ListProjectSuggestionsInput,
): Promise<ListProjectSuggestionsResult> {
const now = input.now ?? new Date();
const since = parseSince(input.request.since, now);
const listing = await input.agentManager.listImportableSessions({
limit: SESSION_SCAN_LIMIT,
scanLimit: SESSION_SCAN_LIMIT,
providerFilter: input.request.providers ? new Set(input.request.providers) : undefined,
});
const sessions = listing.sessions.filter(
(session) => session.lastActivityAt.getTime() >= since && !isMetadataGenerationSession(session),
);
const rootByCwd = await resolveProjectRoots(
new Set(sessions.map((session) => resolve(session.cwd))),
input.workspaceGitService,
);
const excludedRoots = await collectExcludedRoots(input.projectRegistry);
const groups = new Map<string, SuggestionGroup>();
for (const session of sessions) {
const rootPath = rootByCwd.get(resolve(session.cwd));
if (!rootPath || excludedRoots.has(normalizePathForIdentity(rootPath))) {
continue;
}
addSession(groups, rootPath, session);
}
const suggestions = Array.from(groups.values())
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
.slice(0, input.request.limit ?? DEFAULT_LIMIT)
.map(toSuggestion);
return { suggestions, providerErrors: listing.providerErrors };
}
interface SuggestionGroup {
rootPath: string;
sessionCount: number;
providerIds: Set<string>;
lastActivityAt: number;
}
function parseSince(since: string | undefined, now: Date): number {
if (!since) {
return now.getTime() - DEFAULT_WINDOW_MS;
}
const timestamp = Date.parse(since);
if (Number.isNaN(timestamp)) {
throw new ProjectSuggestionsRequestError("invalid_since", `Invalid since timestamp: ${since}`);
}
return timestamp;
}
/** Maps each session cwd to the folder a project would be added at, or null when it is gone. */
async function resolveProjectRoots(
cwds: Set<string>,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<Map<string, string | null>> {
const limit = pLimit(CHECKOUT_READ_CONCURRENCY);
const entries = await Promise.all(
Array.from(cwds, (cwd) =>
limit(async () => [cwd, await resolveProjectRoot(cwd, workspaceGitService)] as const),
),
);
return new Map(entries);
}
async function resolveProjectRoot(
cwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<string | null> {
if (!(await isDirectory(cwd))) {
return null;
}
const checkout = await workspaceGitService.getCheckout(cwd);
// Git reports Windows roots with forward slashes; resolve to the form project.add keys by.
const rootPath = resolve(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? cwd);
if (rootPath !== cwd && !(await isDirectory(rootPath))) {
return null;
}
return rootPath;
}
/** Active projects are already added; the home directory and filesystem root are not projects. */
async function collectExcludedRoots(
projectRegistry: Pick<ProjectRegistry, "list">,
): Promise<Set<string>> {
const home = homedir();
const excluded = new Set([home, parse(home).root].map(normalizePathForIdentity));
for (const project of await projectRegistry.list()) {
if (!project.archivedAt) {
excluded.add(normalizePathForIdentity(project.rootPath));
}
}
return excluded;
}
function addSession(
groups: Map<string, SuggestionGroup>,
rootPath: string,
session: ManagedImportableProviderSession,
): void {
const key = normalizePathForIdentity(rootPath);
const group = groups.get(key) ?? {
rootPath,
sessionCount: 0,
providerIds: new Set<string>(),
lastActivityAt: 0,
};
group.sessionCount += 1;
group.providerIds.add(session.provider);
group.lastActivityAt = Math.max(group.lastActivityAt, session.lastActivityAt.getTime());
groups.set(key, group);
}
function toSuggestion(group: SuggestionGroup): ProjectSuggestion {
return {
rootPath: group.rootPath,
displayName: basename(group.rootPath) || group.rootPath,
sessionCount: group.sessionCount,
providerIds: Array.from(group.providerIds).sort(),
lastActivityAt: new Date(group.lastActivityAt).toISOString(),
};
}
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
-34
View File
@@ -144,7 +144,6 @@ import {
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import { listProjectSuggestions, ProjectSuggestionsRequestError } from "./project-suggestions.js";
import {
checkoutLiteFromGitSnapshot,
checkoutFromPersistedWorkspacePlacement,
@@ -3063,8 +3062,6 @@ export class Session {
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg);
case "project.suggestions.request":
return this.handleProjectSuggestionsRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -6553,37 +6550,6 @@ export class Session {
}
}
private async handleProjectSuggestionsRequest(
request: Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
): Promise<void> {
try {
const result = await listProjectSuggestions({
request,
agentManager: this.agentManager,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
});
this.emit({
type: "project.suggestions.response",
payload: { requestId: request.requestId, ...result },
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.suggestions.request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: error instanceof Error ? error.message : "Failed to list project suggestions",
code:
error instanceof ProjectSuggestionsRequestError
? error.code
: "project_suggestions_failed",
},
});
}
}
private requireWorkspaceLabels(): WorkspaceLabelService {
if (!this.workspaceLabelService) {
throw new SessionRequestError("workspace_labels_unavailable", "Workspace labels unavailable");
@@ -2094,8 +2094,6 @@ export class VoiceAssistantWebSocketServer {
projectAdd: true,
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: true,
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
projectSuggestions: true,
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
@@ -705,3 +705,59 @@ index a9c1804..f5a3c20 100644
return Insets(
this._insets.top / density,
diff --git a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
index 01026dc..3786ec4 100644
--- a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
+++ b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
@@ -17,13 +17,16 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
}
#if REACT_NATIVE_VERSION_MINOR >= 81
- std::unordered_map<Tag, folly::dynamic> tagToProps;
+ // Paseo backport of https://github.com/jpudysz/react-native-unistyles/pull/1357.
+ // UIManager::updateShadowTree commits a root cloned before the commit, so when a
+ // window-size commit (rotation, keyboard) lands in between, the retried Unistyles
+ // commit puts the previous layout constraints back and the app stays laid out for
+ // the old size. Commit here instead and clone from the root the commit hands us.
+ std::unordered_set<SurfaceId> affectedSurfaces;
for (const auto& [family, props] : updates) {
auto safeProps = props.isObject() ? props : folly::dynamic::object();
- tagToProps.insert({family->getTag(), safeProps});
-
auto* mutableFamily = const_cast<ShadowNodeFamily*>(family);
if (mutableFamily->nativeProps_DEPRECATED && mutableFamily->nativeProps_DEPRECATED->isObject()) {
@@ -31,9 +34,30 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
} else {
mutableFamily->nativeProps_DEPRECATED = std::make_unique<folly::dynamic>(safeProps);
}
+
+ affectedSurfaces.insert(family->getSurfaceId());
}
- UIManagerBinding::getBinding(rt)->getUIManager().updateShadowTree(std::move(tagToProps));
+ const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
+
+ shadowTreeRegistry.enumerate([&updates, &affectedSurfaces](const ShadowTree& shadowTree, bool&){
+ if (!affectedSurfaces.contains(shadowTree.getSurfaceId())) {
+ return;
+ }
+
+ auto transaction = [&updates](const RootShadowNode& oldRootShadowNode) -> RootShadowNode::Unshared {
+ auto affectedNodes = shadow::ShadowTreeManager::findAffectedNodes(oldRootShadowNode, updates);
+
+ return std::static_pointer_cast<RootShadowNode>(shadow::ShadowTreeManager::cloneShadowTree(
+ oldRootShadowNode,
+ updates,
+ affectedNodes
+ ));
+ };
+
+ // mountSynchronously: this is an update from C++, not React
+ shadowTree.commit(transaction, {false, true});
+ });
#else
const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
+3
View File
@@ -28,6 +28,9 @@ const patchedPackages = [
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
// Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3.
// https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3
// Layout constraints: backport of https://github.com/jpudysz/react-native-unistyles/pull/1357
// (commit from the root the commit hands us, so a concurrent window-size commit is kept).
// Remove the ShadowTreeManager hunk when the installed release includes that PR.
{
nodeModulesPath: "node_modules/react-native-unistyles",
patchPrefix: "react-native-unistyles+",