Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] caf22464a5 test(usage): isolate the legacy login locator from inherited credentials 2026-10-31 12:04:32 +00:00
paseo-bot[bot] 705cf93681 Merge remote-tracking branch 'origin/main' into bot/diagnose-8309-7be72288 2026-10-31 11:57:29 +00:00
paseo-bot[bot]andpeanut_hzm 0e60432de9 fix(usage): narrow credential discovery repair to the verified GLM path
Keep the independently reproduced GLM discovery fix and isolate its file test from inherited XDG_CONFIG_HOME. Leave MiniMax quota routing for separate verification before changing existing API-key requests.

Co-authored-by: peanut_hzm <peanut_hzm@163.com>
2026-10-31 11:56:21 +00:00
paseo-bot[bot]andOuyang Xingyuan 0e5bcbd9f5 fix(app): open relative chat links inside nested repositories (#8361)
* fix(app): open a nested-repo file when the workspace-root path is missing

A workspace that contains another git repo often gets paths written
relative to that nested repo. `schema/x.md` is really
`repo-a/schema/x.md`. A markdown link was joined onto the workspace
root and opened a missing file. Inline code already asked the daemon
for a suffix match.

Resolved workspace-relative paths that contain a slash now use that
same lookup before opening. An exact hit stays on the original path.
A suffix hit replaces it and keeps the line range. A failed lookup
still opens the original path. Bare names, absolute paths, and `~/`
paths are unchanged.

Example:
- Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md`
- Before: open `/repo/schema/x.md` (missing)
- After: open `/repo/repo-a/schema/x.md`

* fix(app): preserve explicit destinations when finding nested files

Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* fix(app): keep network share links on their absolute destination

Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths.

* fix(app): keep nested-file lookup inside ordered path opening

Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection.

* test(app): load UI modules before test lifecycle hooks

Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 11:05:06 +00:00
a7d9bb34fc fix(plugins): keep rewound conversations on their retained branch (#8368)
Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged.

Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch.

The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening.

Closes #4667
Supersedes #4668 and the rewind-history proposal in #4754.

Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
2026-10-31 17:48:42 +07:00
peanut_hzm 32a420db1b Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 15:47:12 +08:00
peanut_hzm f56f277606 Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 10:38:40 +08:00
peanut_hzm 6b325bdbcd Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 10:20:41 +08:00
peanut_hzm 7ba608fcb2 fix(usage): follow the CLIs' current auth locations for MiniMax and Z.ai
The usage sources look for credentials where the CLIs used to keep them, so
their plan quota never appears in Settings → Usage.

MiniMax Code (`mcode`) moved its OAuth login to
`~/.minimax/auth/<buildEnv>/<region>/<clientId>/auth.json`, with the token
inside a `records` map and the region in a sibling `auth-state.json`. The
source still looked in `~/.mmx/` for the older `access_token` shape. It also
called `/v1/token_plan/remains`, which the agent backend answers with
`status_code 1004` ("Please carry the API secret key in the 'Authorization'
field"); the coding-plan endpoint the CLI itself uses is
`/v1/api/openplatform/coding_plan/remains`.

Z.ai's `glm-acp-agent` reads `Z_AI_API_KEY` and persists it to
`~/.config/glm-acp-agent/credentials.json` as `z_ai_api_key`. The source
looked for `ZAI_API_KEY` and `GLM_API_KEY` only, and never read the
credentials file.

Both keep their existing lookups working, so a configured environment
variable or the older `~/.mmx/` layout still resolves.
2026-10-31 00:41:24 +08:00
78 changed files with 1589 additions and 4084 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,
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
await agent.cleanup();
}
});
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
await page.getByRole("link", { name: linkName, exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
content,
);
}
async function returnToNestedLinks(page: Page): Promise<void> {
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
}
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "nested-file-link-",
title: "Nested file links",
featureValues: {
mockStreamingAssistantResponse:
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
await writeFile(
path.join(agent.cwd, "repo-a/schema/x.md"),
"# Nested file content\n\nDetails\n",
);
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
await readLinkedGuide(page, "nested guide", "Nested file content");
await returnToNestedLinks(page);
await readLinkedGuide(page, "exact guide", "Exact file content");
await returnToNestedLinks(page);
await page.getByRole("link", { name: "missing guide", exact: true }).click();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -81,6 +81,8 @@ for (const folder of [
"docs/guides",
"docs/guides/",
"docs/指南",
"examples/next.js/",
"examples/next.js",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
@@ -92,6 +94,9 @@ for (const folder of [
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
await mkdir(path.dirname(competingFile), { recursive: true });
await writeFile(competingFile, "This is a file, not the requested folder.\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
-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 });
}
+1
View File
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() =>
createInlinePathOpener({
workspaceRoot,
resolveRelativeFiles: true,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
-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,
},
}));
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
opened.push(location.path);
},
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => {
opened.push(location.path);
},
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
const { createInlinePathOpener } = await import("./path-open");
const lookup = Promise.withResolvers<{
entries: Array<{ path: string; kind: "file" }>;
error: null;
}>();
const queries: string[] = [];
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
resolveRelativeFiles: true,
client: {
listDirectory: async () => {
throw new Error("Not a directory");
},
getDirectorySuggestions: async ({ query }: { query: string }) => {
queries.push(query);
return lookup.promise;
},
},
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const earlier = opener.open(
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
"side",
);
await Promise.resolve();
expect(queries).toEqual(["schema/x.md"]);
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
await earlier;
expect(opened).toEqual(["guide.md"]);
});
@@ -5,6 +5,7 @@ import {
type OpenFileDisposition,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
interface PathOpenInput {
file?: string;
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
client: {
listDirectory(root: string, path: string): Promise<unknown>;
getDirectorySuggestions: GetDirectorySuggestions;
} | null;
resolveRelativeFiles?: boolean;
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const resolvedTarget =
input.resolveRelativeFiles && input.client
? await resolveMissingNestedRelativeFile({
target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
})
: target;
if (ownGeneration !== generation) return;
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
if (!resolved?.file) return;
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
path: resolved.file,
lineStart: resolvedTarget.lineStart,
lineEnd: resolvedTarget.lineEnd,
});
if (location) input.openFile(location, disposition);
}
@@ -7,6 +7,7 @@ import {
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
resolveMissingNestedRelativeFile,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
});
});
describe("resolveMissingNestedRelativeFile", () => {
const target = {
raw: "schema/x.md:12-14",
path: "/Users/test/project/schema/x.md",
lineStart: 12,
lineEnd: 14,
};
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "/Users/test/project",
matchMode: "suffix",
limit: 1,
},
]);
expect(result).toEqual({
...target,
path: "/Users/test/project/repo-a/schema/x.md",
});
});
it("keeps the exact workspace path when suggestions return that relative path", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(result).toEqual(target);
});
it("keeps the original target when suggestions are empty", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(target);
});
it("keeps the original target when the daemon throws", async () => {
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions: unavailableSuggestions,
}),
).resolves.toEqual(target);
});
it.each([
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
["a home-relative path", { ...target, path: "~/schema/x.md" }],
])("does not search %s", async (_label, candidate) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: candidate,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(candidate);
});
it("does not search when the workspace root is blank", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: " ",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(target);
});
it.each([
["/Users/test/project/schema/x.md", "/Users/test/project"],
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
["C:/repo/schema/x.md", "C:/repo"],
["\\\\server\\share\\schema\\x.md", "//server/share"],
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
await expect(
resolveMissingNestedRelativeFile({
target: absoluteTarget,
workspaceRoot,
getDirectorySuggestions,
}),
).resolves.toEqual(absoluteTarget);
expect(searches).toEqual([]);
});
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
"keeps folder href %s direct",
async (raw) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
});
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
await expect(
resolveMissingNestedRelativeFile({
target: folderTarget,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(folderTarget);
expect(searches).toEqual([]);
},
);
it("normalizes a Windows workspace root before the suffix lookup", async () => {
const windowsTarget = {
raw: "schema/x.md",
path: "C:\\repo\\schema\\x.md",
lineStart: undefined,
lineEnd: undefined,
};
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: windowsTarget,
workspaceRoot: "C:\\repo\\",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "C:\\repo\\",
matchMode: "suffix",
limit: 1,
},
]);
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
});
});
describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
@@ -5,6 +5,7 @@ import {
type InlinePathTarget,
} from "./parse";
import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export interface AssistantFileLinkSource {
href: string;
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
};
}
/** Only file-looking paths nested inside the workspace need suffix search. */
function nestedRelativeLookupQuery(
targetPath: string,
workspaceRoot: string | undefined,
): string | null {
const root = workspaceRoot?.trim();
if (!root) {
return null;
}
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = targetPath.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const relative = normalizedPath.slice(prefix.length);
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
return null;
}
return relative;
}
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
* missing, ask the same suffix lookup bare names already use. An exact hit
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
* Lookup failure keeps the original target.
*/
export async function resolveMissingNestedRelativeFile(input: {
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}): Promise<InlinePathTarget> {
if (
isAbsoluteInlineCodeToken(input.target.raw) ||
input.target.raw.startsWith("~/") ||
input.target.raw.endsWith("/")
) {
return input.target;
}
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
if (!query) {
return input.target;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: query,
token: input.target.raw,
target: input.target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.getDirectorySuggestions,
});
} catch {
return input.target;
}
}
export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
@@ -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} />;
}
@@ -2,7 +2,7 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
@@ -126,17 +126,13 @@ afterEach(async () => {
});
});
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
({ useAgentInputDraft } = await import("./input-draft"));
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
const { useAgentInputDraft } = await import("./input-draft");
describe("useAgentInputDraft live contract", () => {
beforeEach(() => {
@@ -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}`);
@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
@@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
}
).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
});
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
await import("./use-agent-history");
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
-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>;
@@ -3505,6 +3505,10 @@ export class AgentManager {
"agent.rewind.complete",
);
} catch (error) {
// Native rewind can commit a fork before history refresh fails. Recovery
// must resume that latest handle rather than the abandoned conversation.
this.refreshSessionPersistence(agent);
await this.persistSnapshot(agent);
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
@@ -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
);
@@ -53,6 +53,7 @@ const CAPABILITIES = [
interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
closeAcknowledgementOnly?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
}
if (input.type === "session.close") {
emit({ type: "session.closed", sessionId: input.sessionId });
if (options.closeAcknowledgementOnly) return;
}
if ("requestId" in input) {
emit({ type: "request.completed", requestId: input.requestId });
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type);
}
function createRewindProviderHarness(
options: {
reuseAnswerId?: boolean;
forkOnRevert?: boolean;
handleInput?: ProviderHarnessOptions["handleInput"];
} = {},
) {
const nativeMessages: string[] = [];
let originalMessages: string[] | null = null;
const harness = createProviderHarness({
closeAcknowledgementOnly: true,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
async handleInput(input, emit) {
if (await options.handleInput?.(input, emit)) return true;
if (input.type === "session.open" && input.history === "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
restoration: "core",
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
const messages =
options.forkOnRevert &&
(input.persistence?.data as { token?: string })?.token === "root" &&
originalMessages
? originalMessages
: nativeMessages;
for (const [index, text] of messages.entries()) {
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
}
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (options.forkOnRevert && input.scope !== "files") {
originalMessages = [...nativeMessages];
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
}
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
const harness = createRewindProviderHarness(options);
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: true });
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
session.subscribe((event) => live.push(event));
await session.revertConversation!({ messageId: "user-B" });
live.length = 0;
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
async function withPluginRewindSession(
options: Parameters<typeof createRewindProviderHarness>[0],
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
try {
await session.run("A");
await session.run("B");
await session.run("C");
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
const events = await Array.fromAsync(session.streamHistory());
return events.flatMap((event) =>
event.type === "timeline" &&
(event.item.type === "user_message" || event.item.type === "assistant_message")
? [event.item.text]
: [],
);
}
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
await session.revertConversation!({ messageId: "user-A" });
expect(await pluginMessages(session)).toEqual([]);
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type === "session.revert")
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
return false;
},
},
async (session, harness) => {
await session.revertConversation!({ messageId: "user-B" });
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
persistence: { version: 1, data: { token: "fork" } },
history: "replay",
});
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
},
);
});
test("failed native plugin rewind preserves history and does not reopen", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "native rewind failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"native rewind failed",
);
expect(await pluginMessages(session)).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
]);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
},
);
});
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
let failReplay = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"replay failed",
);
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
await session.revertConversation!({ messageId: "user-B" });
await session.run("D");
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
handleInput: async (input, emit) => {
if (input.type === "session.close") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("closing during plugin rewind replay closes each native binding once", async () => {
let opened!: () => void;
let release!: () => void;
const replayStarted = new Promise<void>((resolve) => {
opened = resolve;
});
const replayAllowed = new Promise<void>((resolve) => {
release = resolve;
});
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type === "session.open" && input.history === "replay") {
opened();
await replayAllowed;
}
return false;
},
},
async (session, harness) => {
const rewind = session.revertConversation!({ messageId: "user-B" });
await replayStarted;
const closing = session.close();
release();
await Promise.all([rewind, closing]);
const closes = harness.inputs.flatMap((input) =>
input.type === "session.close" ? [input.sessionId] : [],
);
expect(closes).toHaveLength(2);
expect(new Set(closes).size).toBe(2);
},
);
});
test("plugin rewind retries a failed close before reopening the native writer", async () => {
let failClose = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.close" || !failClose) return false;
failClose = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "close failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"close failed",
);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("plugin rewind retries permission settlement before retiring its session", async () => {
let failPermission = true;
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type !== "session.permission" || !failPermission) return false;
failPermission = false;
throw new Error("permission delivery failed");
},
},
async (session, harness) => {
const opened = harness.inputs.find((input) => input.type === "session.open")!;
if (opened.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: opened.sessionId,
request: { id: "pending", name: "tool", kind: "tool" },
});
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"permission delivery failed",
);
expect(session.getPendingPermissions()).toHaveLength(1);
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
},
);
});
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
await withPluginRewindSession({}, async (session, harness) => {
const root = harness.inputs.find((input) => input.type === "session.open")!;
if (root.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: root.sessionId,
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.revertConversation!({ messageId: "user-B" });
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.permission",
permissionId: "old-permission",
response: { behavior: "deny", message: "Session replaced after rewind" },
}),
);
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
});
const history = await Array.fromAsync(session.streamHistory());
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
harness.emit({
type: "session.opened",
sessionId: "child-1",
parentSessionId: reopened.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
});
const next = await Array.fromAsync(session.streamHistory());
expect(next).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ text: "New child reply" }),
}),
}),
);
});
});
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
restoration: "core",
cwd: input.config.cwd,
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
expect(session.capabilities.supportsRewindConversation).toBe(false);
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"requires session persistence",
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
await session.revertFiles!({ messageId: "user-B" });
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
},
);
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [
["a", rootSessionId],
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
>();
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
}
this.closePromise ??= this.closeNativeSession().catch((error) => {
this.closePromise = null;
throw error;
});
await this.closePromise;
}
private async closeNativeSession(): Promise<void> {
try {
await this.runtime.complete({
type: "session.close",
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
// A failed close still needs its routing for a subsequent session.closed ack.
this.runtime.removeSession(this.id, this.providerSessionId);
}
beginOpen(requestId: string): Promise<void> {
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
persistence: input.persistence,
history: input.history,
});
const session = new PluginAgentSession(this.provider, bridge, () => {
this.rootsBySession.delete(bridge.id);
});
this.rootsBySession.set(bridge.id, session);
this.attachPendingChildren();
const forget = () => {
for (const [id, owner] of this.rootsBySession) {
if (owner === session) this.rootsBySession.delete(id);
}
};
const bind = (next: ProviderRuntimeSession) => {
this.rootsBySession.set(next.id, session);
this.attachPendingChildren();
};
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
const session = new PluginAgentSession(
this.provider,
bridge,
forget,
async (persistence, state) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
...config,
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
},
persistence,
history: "replay",
});
},
bind,
);
bind(bridge);
return session;
}
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly children = new Map<
string,
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
private replay: {
persistence: ProviderPersistence;
config: ProviderConfigState;
messageId: string;
scope: "conversation" | "both";
closed: boolean;
} | null = null;
private replaying: Promise<void> | null = null;
constructor(
readonly provider: string,
private readonly bridge: ProviderRuntimeSession,
private bridge: ProviderRuntimeSession,
private readonly onClose: () => void,
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
) {
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
}
get capabilities(): AgentCapabilityFlags {
return agentCapabilities(this.bridge.negotiatedCapabilities);
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
const resumable = this.canReplayHistory;
return {
...capabilities,
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
};
}
get features(): AgentFeature[] {
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: AgentRunOptions = {},
): Promise<{ turnId: string }> {
this.assertReplayComplete();
const clientMessageId = options.clientMessageId ?? randomUUID();
const result = await this.bridge.prompt({
clientMessageId,
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
this.assertReplayComplete();
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" };
}
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureReplay();
for (const event of this.history.read(this.provider)) yield event;
}
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ mode: modeId });
}
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.unsubscribe?.();
this.unsubscribe = null;
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear();
await this.replaying?.catch(() => undefined);
await this.clearBinding();
this.replay = null;
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession {
}
async setModel(modelId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
@@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession {
const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set(
child.id,
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
);
this.children.set(child.id, {
session: child,
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
});
}
private accept(event: ProviderEvent, live: boolean): void {
@@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession {
}
}
private async clearBinding(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
for (const child of this.children.values()) {
child.unsubscribe();
await child.session.close();
}
this.children.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.currentTurnId = null;
}
private assertReplayComplete(): void {
if (this.replay) {
throw new Error(
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
);
}
}
// A native rewind has already committed. Until replay succeeds, neither history
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
private async ensureReplay(): Promise<void> {
if (!this.replay) return;
if (this.closed) throw new StaleProviderSessionError(this.id);
if (!this.replaying) {
this.replaying = this.replayBinding().finally(() => {
this.replaying = null;
});
}
await this.replaying;
}
private async replayBinding(): Promise<void> {
const replay = this.replay!;
if (!replay.closed) {
for (const request of this.pendingPermissions.values()) {
await this.respondToPermission(request.id, {
behavior: "deny",
message: "Session replaced after rewind",
});
this.pendingPermissions.delete(request.id);
this.permissionResponses.delete(request.id);
}
await this.clearBinding();
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
this.onBind(bridge);
this.replay = null;
}
private get canReplayHistory(): boolean {
return (
this.bridge.restoration === "core" &&
this.bridge.persistence !== null &&
this.bridge.negotiatedCapabilities.includes("session.persistence")
);
}
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
const pending = this.replay;
await this.ensureReplay();
if (pending?.messageId === messageId && pending.scope === scope) return;
if (scope !== "files" && !this.canReplayHistory) {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
await this.bridge.revert({
@@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession {
token,
scope,
});
if (scope !== "files") {
const persistence = this.bridge.persistence;
if (!persistence) throw new Error("Plugin rewind removed session persistence");
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
await this.ensureReplay();
}
}
private publish(event: AgentStreamEvent): void {
@@ -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.
+58 -9
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -32,11 +32,13 @@ describe("zai usage source", () => {
process.env["USERPROFILE"] = homeDir;
for (const key of [
"APPDATA",
"XDG_CONFIG_HOME",
"COPILOT_TOKEN",
"GITHUB_TOKEN",
"GITHUB_PAT",
"CURSOR_ACCESS_TOKEN",
"CURSOR_TOKEN",
"Z_AI_API_KEY",
"ZAI_API_KEY",
"GLM_API_KEY",
"GROK_API_KEY",
@@ -116,11 +118,61 @@ describe("zai usage source", () => {
details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]),
});
});
});
it("discovery returns a locator when fetch finds zai credentials", async () => {
const previous = process.env["ZAI_API_KEY"];
try {
it("fetches Z.ai usage from Z_AI_API_KEY, the name the glm-acp-agent CLI uses", async () => {
process.env["Z_AI_API_KEY"] = "zai_cli_token";
let authorization: string | null = null;
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
}) as unknown as typeof fetch;
const zai = findProvider(await service().listUsage(), "zai");
expect(authorization).toBe("Bearer zai_cli_token");
expect(zai).toMatchObject({ status: "available", planLabel: "GLM Coding Max" });
});
it("reads Z.ai usage from the credentials file the glm-acp-agent CLI writes", async () => {
const configDir = join(homeDir, ".config", "glm-acp-agent");
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, "credentials.json"),
JSON.stringify({ z_ai_api_key: "zai_file_token" }),
);
let authorization: string | null = null;
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
}) as unknown as typeof fetch;
const zai = findProvider(await service().listUsage(), "zai");
expect(authorization).toBe("Bearer zai_file_token");
expect(zai).toMatchObject({ status: "available" });
});
it("honours XDG_CONFIG_HOME for the credentials file", async () => {
const configDir = join(homeDir, "xdg", "glm-acp-agent");
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, "credentials.json"),
JSON.stringify({ z_ai_api_key: "zai_xdg_token" }),
);
process.env["XDG_CONFIG_HOME"] = join(homeDir, "xdg");
let authorization: string | null = null;
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
}) as unknown as typeof fetch;
const zai = findProvider(await service().listUsage(), "zai");
expect(authorization).toBe("Bearer zai_xdg_token");
expect(zai).toMatchObject({ status: "available" });
});
it("discovery returns a locator when fetch finds zai credentials", async () => {
process.env["ZAI_API_KEY"] = "fixture-token";
let requested = false;
await fetchFirst(async () => {
@@ -131,10 +183,7 @@ it("discovery returns a locator when fetch finds zai credentials", async () => {
expect(await discover()).toEqual([
{ key: "default", input: { store: "env", locator: "ZAI_API_KEY" } },
]);
} finally {
if (previous === undefined) delete process.env["ZAI_API_KEY"];
else process.env["ZAI_API_KEY"] = previous;
}
});
});
describe("account discovery", () => {
+37 -2
View File
@@ -1,4 +1,7 @@
import type { UsageInput } from "../shared/input.js";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
unavailable,
@@ -25,11 +28,16 @@ const ZaiUsageResponseSchema = z.object({
.optional(),
});
/** The credentials file the `glm-acp-agent` CLI writes. */
const ZaiCredentialsSchema = z.object({
z_ai_api_key: z.string().optional(),
});
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
const token = process.env[input.locator];
const token = await readToken(input);
if (!token) throw new Error("Z.ai login store no longer exists");
const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", {
@@ -64,9 +72,36 @@ export async function fetchUsage(
};
}
/**
* The Z.AI API key comes from the environment or from the credentials file the
* `glm-acp-agent` CLI writes. The CLI names the variable `Z_AI_API_KEY`; the
* older `ZAI_API_KEY` spelling is still accepted.
*/
async function readToken(input: UsageInput): Promise<string | undefined> {
if (input.store === "env") {
return process.env[input.locator];
}
try {
const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8"));
return ZaiCredentialsSchema.parse(raw).z_ai_api_key;
} catch {
return undefined;
}
}
export async function discover(): Promise<UsageAccount[]> {
for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) {
for (const locator of ["Z_AI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY"]) {
if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }];
}
const credentialsPath = join(
process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
"glm-acp-agent",
"credentials.json",
);
if (await readToken({ store: "file", locator: credentialsPath })) {
return [{ key: "default", input: { store: "file", locator: credentialsPath } }];
}
return [];
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
export const inputSchema = z
.object({
store: z.literal("env"),
store: z.enum(["env", "file"]),
locator: z.string().min(1),
})
.strict();
+5
View File
@@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
servers during `session.open`. There is no separate reload operation.
Conversation and combined rewind require a core-restored session with persistence. After a
successful rewind, Paseo closes the session and reopens its latest persistence value with
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
children. File-only rewind keeps the current session and conversation history.
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
recreate it while restoring the parent.