Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 9e635d9b46 test(app): pin the silent dictation reproduction conditions 2026-10-31 07:48:02 +00:00
paseo-bot[bot] 3e859625ed test(app): verify dictation retries through the real daemon
Observe actual dictation frames instead of constructing protocol success
inside Playwright. Silence is finalized by the daemon's existing filter,
so the test needs no transcription response replacement.

Keep the completion decision and recording ownership behind a typed port
for deterministic empty, successful, and partial-result unit coverage.
2026-10-31 07:42:30 +00:00
paseo-bot[bot]andNightMachinery 83b1a3cbe2 fix(app): keep empty dictation feedback factual and readable
An empty transcript does not identify whether silence filtering or speech
recognition produced it. Preserve the recording and offer Retry without
mirroring the daemon's independently configured silence threshold.

Show the finished result in place of the recording meter so its explanation
fits in the composer. Verify exact PCM replay across repeated retries,
discard on a narrow screen, and partial-text fallback through browser flows.

Co-authored-by: NightMachinery <feraidoonmehri@gmail.com>
2026-10-31 07:20:43 +00:00
NightMachinery 0132f9f3e3 fix(app): explain empty dictation results and retain recordings
Keep empty transcription results visible and preserve captured audio for retry.
2026-10-31 07:20:31 +00:00
90 changed files with 1568 additions and 5052 deletions
-12
View File
@@ -61,18 +61,6 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
@@ -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,
@@ -0,0 +1,80 @@
import { expect, test } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
discardDictation,
expectEmptyDictation,
installSyntheticMicrophone,
observeDictation,
recordDictation,
retryDictation,
} from "../support/helpers/dictation";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Silence is finalized by the real daemon without calling a transcription service.
// A closed local endpoint makes an unexpected transcription request fail the test.
test.use({
e2eDaemonEnvironment: {
PASEO_DICTATION_ENABLED: "1",
PASEO_DICTATION_SILENCE_PEAK_THRESHOLD: "300",
OPENAI_API_KEY: "unused-for-silent-recordings",
OPENAI_BASE_URL: "http://127.0.0.1:9/v1",
},
});
test.describe("Dictation empty result", () => {
test("the daemon accepts the same recording through repeated empty-result retries", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-empty-",
title: "Empty dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
expect(observed.recordings).toHaveLength(3);
expect(observed.recordings[0].audio.length).toBeGreaterThan(0);
expect(observed.recordings[0].finalSeq).toBe(observed.recordings[0].audio.length - 1);
expect(observed.recordings[0].text).toBe("");
expect(observed.recordings[1]).toEqual(observed.recordings[0]);
expect(observed.recordings[2]).toEqual(observed.recordings[0]);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
test.describe("narrow composer", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("explains and discards an empty result", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-silent-",
title: "Silent dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
});
});
-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,
},
);
@@ -0,0 +1,106 @@
import { expect, type Page } from "../fixtures";
/**
* Replaces the browser microphone with an oscillator. `silent` keeps the stream
* running at zero gain, which is what a muted or wrongly routed input delivers.
*/
export async function installSyntheticMicrophone(
page: Page,
options: { silent?: boolean } = {},
): Promise<void> {
await page.addInitScript((silent: boolean) => {
const mediaDevices = navigator.mediaDevices;
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
gain.gain.value = silent ? 0 : 1;
const destination = context.createMediaStreamDestination();
oscillator.connect(gain);
gain.connect(destination);
oscillator.start();
return destination.stream;
},
});
}, options.silent ?? false);
}
/** Observe real browser/daemon traffic without intercepting or answering it. */
export function observeDictation(page: Page) {
const streams = new Map<string, { audio: Map<number, string>; finalSeq: number }>();
const recordings: Array<{ audio: string[]; finalSeq: number; text: string }> = [];
let resolveAudio!: () => void;
const audio = new Promise<void>((resolve) => {
resolveAudio = resolve;
});
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = sessionMessage(payload);
if (!request || typeof request.dictationId !== "string") return;
const id = request.dictationId;
if (request.type === "dictation_stream_start") {
streams.set(id, { audio: new Map(), finalSeq: -1 });
}
if (request.type === "dictation_stream_chunk") {
streams.get(id)!.audio.set(request.seq as number, request.audio as string);
resolveAudio();
}
if (request.type === "dictation_stream_finish") {
streams.get(id)!.finalSeq = request.finalSeq as number;
}
});
socket.on("framereceived", ({ payload }) => {
const response = sessionMessage(payload);
if (response?.type !== "dictation_stream_final") return;
const final = response.payload as { dictationId: string; text: string };
const stream = streams.get(final.dictationId)!;
recordings.push({
audio: [...stream.audio.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk),
finalSeq: stream.finalSeq,
text: final.text,
});
});
});
return { waitForAudio: () => audio, recordings };
}
function sessionMessage(payload: string | Buffer): Record<string, unknown> | null {
try {
const envelope = JSON.parse(typeof payload === "string" ? payload : payload.toString("utf8"));
return envelope.type === "session" ? envelope.message : null;
} catch {
return null;
}
}
export async function recordDictation(page: Page, observed: { waitForAudio(): Promise<void> }) {
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
await observed.waitForAudio();
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
}
export async function expectEmptyDictation(page: Page) {
const message = page.getByText("No transcription was returned. Tap retry to try again.", {
exact: true,
});
await expect(message).toBeVisible();
const bounds = await message.boundingBox();
const cancel = await page
.getByRole("button", { name: "Cancel dictation", exact: true })
.boundingBox();
expect(bounds).not.toBeNull();
expect(cancel).not.toBeNull();
expect(bounds!.y).toBeGreaterThanOrEqual(cancel!.y);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(cancel!.y + cancel!.height);
}
export async function retryDictation(page: Page) {
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
}
export async function discardDictation(page: Page) {
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
}
@@ -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
@@ -195,8 +182,8 @@ export async function startIsolatedHostDaemon(
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
env: {
...withDisabledE2ESpeechEnv(process.env),
...options.environment,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
@@ -205,7 +192,7 @@ export async function startIsolatedHostDaemon(
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
},
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
@@ -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 });
}
@@ -10,7 +10,6 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -56,40 +55,6 @@ class TerminalRowLayoutTest {
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,6 +63,7 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate()
}
@@ -27,6 +27,7 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded)
}
/** Draws backgrounds, the selection overlay, then text with its original colors. */
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
val background =
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
if (background ushr 24 == 0) continue
fill.color = background
canvas.drawRect(
cellCol[cell] * cellWidth,
0f,
(cellCol[cell] + cellSpan[cell]) * cellWidth,
cellHeight,
fill,
)
}
val baseline = font.baselinePx.toFloat()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = cellForeground[first]
val color = foreground(row, selection, first, paints)
var last = first
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = cellForeground[cell]
val foreground = foreground(row, selection, cell, paints)
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,6 +291,9 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
@@ -298,5 +301,6 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0x472684FF
var selectionBackground: Int = 0xFFFFFF00.toInt()
var selectionForeground: Int = 0xFF000000.toInt()
}
@@ -43,6 +43,7 @@ export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
selectionForeground: number;
}
export interface PaseoTerminalCellMetrics {
@@ -24,7 +24,8 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0x4726_84ff)))
selectionBackground: TerminalColor(Double(0xffff_ff00)),
selectionForeground: TerminalColor(Double(0xff00_0000)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
@@ -43,7 +44,8 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground))
selectionBackground: TerminalColor(record.selectionBackground),
selectionForeground: TerminalColor(record.selectionForeground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
@@ -25,6 +25,7 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@@ -27,6 +27,7 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
@@ -266,33 +267,31 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth
let cellHeight = font.cellHeight
for cell in 0..<cellCount {
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
let background = cellBackground[cell]
if background.isPainted {
context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
guard background.isPainted else { continue }
context.setFillColor(background.cgColor())
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
}
let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color is drawn once per color, clipped to those
// A group whose cells differ in color or selection is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups {
var first = group.firstCell
while first <= group.lastCell {
let color = cellForeground[first]
let color = foreground(first, row: row, selection: selection, paint: paint)
var last = first
while last < group.lastCell && cellForeground[last + 1] == color {
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
last += 1
}
context.saveGState()
@@ -316,7 +315,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = cellForeground[cell]
let color = foreground(cell, row: row, selection: selection, paint: paint)
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
-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,
},
}));
@@ -4,9 +4,11 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
import type { DictationEmptyReason } from "@/dictation/result";
interface DictationControlsProps {
volume: number;
@@ -24,6 +26,30 @@ interface DictationControlsProps {
disabled?: boolean;
}
const EMPTY_REASON_MESSAGE_KEYS = {
"no-audio": "message.dictation.empty.noAudio",
"no-transcript": "message.dictation.empty.noTranscript",
} as const satisfies Record<DictationEmptyReason, string>;
// The line under the meter once an attempt has finished without text.
function resolveResultText(input: {
t: TFunction;
status: DictationStatus;
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}): string | null {
const { t, status, errorText, emptyReason } = input;
if (status === "empty") {
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
}
if (status !== "failed") {
return null;
}
return errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry");
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -147,18 +173,26 @@ export function DictationOverlay({
isProcessing,
status,
errorText,
emptyReason,
onCancel,
onAccept,
onAcceptAndSend,
onRetry,
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & {
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
const isEmpty = status === "empty";
// An empty result is a finished attempt the user has to acknowledge, like a failure.
const isFailed = status === "failed" || isEmpty;
const canRetry = !isEmpty || emptyReason !== "no-audio";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const resultText = resolveResultText({ t, status, errorText, emptyReason });
const containerStyle = useMemo(
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
@@ -202,26 +236,26 @@ export function DictationOverlay({
</Pressable>
<View style={overlayStyles.centerContainer}>
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{!resultText ? (
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
) : null}
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
{resultText ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
{resultText}
</Text>
) : null}
</View>
@@ -232,7 +266,7 @@ export function DictationOverlay({
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
{!actionsDisabled && isFailed && canRetry ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
@@ -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,13 +1,14 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import {
Keyboard,
type LayoutChangeEvent,
PanResponder,
type PanResponderGestureState,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -46,10 +47,13 @@ import {
type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
@@ -83,6 +87,38 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6",
cursor: "#e6e6e6",
};
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
@@ -127,14 +163,19 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionControllerRef = useRef<ReturnType<
typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(),
@@ -204,7 +245,6 @@ function NativeTerminalEmulator({
}, []);
const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range);
}, [commitSelectionRange]);
@@ -215,14 +255,20 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range);
},
[commitSelectionRange],
);
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return (
resolveMeasuredNativeTerminalSize({
@@ -477,7 +523,8 @@ function NativeTerminalEmulator({
emitMeasuredSize({ source: "measure" });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
selectionControllerRef.current?.cancel();
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null;
@@ -487,7 +534,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [emitMeasuredSize, resetTerminal, streamKey]);
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => {
if (focusRequestToken <= 0) return;
@@ -544,7 +591,6 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" });
},
[emitMeasuredSize],
@@ -617,6 +663,14 @@ function NativeTerminalEmulator({
if (!coordinate) {
return;
}
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({
@@ -631,61 +685,222 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate],
);
const selectionController = useMemo(
() =>
createTerminalSelectionController({
getGeometry: () => {
const viewport = resolveSelectionViewport();
const metrics = metricsRef.current;
return viewport && metrics
? { range: selectionRangeRef.current, viewport, metrics }
: null;
},
onBeginHandle: (endpoint, cols) =>
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
const terminal = terminalRef.current;
if (!terminal) return;
commitSelectionRange(
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
.range,
);
},
onLongPress: beginSelectionAtPoint,
onTap: focusTerminalFromTap,
onScroll: applyScroll,
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
cancel: (timer) => clearTimeout(timer),
}),
[
applyScroll,
beginSelectionAtPoint,
commitSelectionRange,
focusTerminalFromTap,
resolveSelectionViewport,
],
const updateSelectionAtPoint = useCallback(
(point: { x: number; y: number }) => {
const terminal = terminalRef.current;
if (!terminal || selectionPressRef.current?.status !== "selecting") {
return;
}
const coordinate = resolveSelectionCoordinate(point);
if (!coordinate) {
return;
}
const snapshot = selectionModelRef.current.update({
coordinate,
bounds: terminal.getBufferBounds(),
});
commitSelectionRange(snapshot.range);
},
[commitSelectionRange, resolveSelectionCoordinate],
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
);
selectionControllerRef.current = selectionController;
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
gestureStartRef.current = point;
selectionController.press(point);
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
},
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}),
[selectionController],
[
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -696,11 +911,6 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? (
<TerminalGridView
state={terminalState}
@@ -717,41 +927,6 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
@@ -794,8 +969,6 @@ const styles = StyleSheet.create({
nativeGrid: {
flex: 1,
},
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: {
position: "absolute",
right: 12,
@@ -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} />;
}
+15 -3
View File
@@ -562,6 +562,7 @@ function MessageInputOverlay({
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
onCancelRecording,
onAcceptRecording,
onAcceptAndSendRecording,
@@ -585,6 +586,7 @@ function MessageInputOverlay({
isDictationProcessing: boolean;
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
dictationError: string | null;
dictationEmptyReason: React.ComponentProps<typeof DictationOverlay>["emptyReason"];
onCancelRecording: () => Promise<void>;
onAcceptRecording: () => Promise<void>;
onAcceptAndSendRecording: () => Promise<void>;
@@ -593,6 +595,7 @@ function MessageInputOverlay({
onRealtimeVoiceStop: () => void;
}) {
if (showDictationOverlay) {
const awaitsAcknowledgement = dictationStatus === "failed" || dictationStatus === "empty";
return (
<DictationOverlay
volume={dictationVolume}
@@ -601,11 +604,12 @@ function MessageInputOverlay({
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
emptyReason={dictationEmptyReason}
onCancel={onCancelRecording}
onAccept={onAcceptRecording}
onAcceptAndSend={onAcceptAndSendRecording}
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
onRetry={awaitsAcknowledgement ? onRetryFailedRecording : undefined}
onDiscard={awaitsAcknowledgement ? onDiscardFailedRecording : undefined}
/>
);
}
@@ -1032,7 +1036,12 @@ function computeShouldShowDictationOverlay(
isDictationProcessing: boolean,
dictationStatus: string,
): boolean {
return isDictating || isDictationProcessing || dictationStatus === "failed";
return (
isDictating ||
isDictationProcessing ||
dictationStatus === "failed" ||
dictationStatus === "empty"
);
}
function computeIsDictationStartEnabled(
@@ -1533,6 +1542,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
duration: dictationDuration,
error: dictationError,
status: dictationStatus,
emptyReason: dictationEmptyReason,
startDictation,
cancelDictation,
confirmDictation,
@@ -2180,6 +2190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing={isDictationProcessing}
dictationStatus={dictationStatus}
dictationError={dictationError}
dictationEmptyReason={dictationEmptyReason}
onCancelRecording={handleCancelRecording}
onAcceptRecording={handleAcceptRecording}
onAcceptAndSendRecording={handleAcceptAndSendRecording}
@@ -2201,6 +2212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
@@ -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,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { completeDictation, type DictationRecording } from "./result";
class RecordedAudio implements DictationRecording {
constructor(private segments: string[]) {}
getSegmentCount() {
return this.segments.length;
}
clearAll() {
this.segments = [];
}
}
describe("completing dictation", () => {
it("keeps a recording through repeated empty results until a retry returns text", () => {
const recording = new RecordedAudio(["first PCM chunk", "second PCM chunk"]);
const empty = { finalText: " ", partialText: "", recording };
const result = completeDictation(empty);
expect(recording.getSegmentCount()).toBe(2);
expect(result).toEqual({ status: "empty", reason: "no-transcript" });
expect(completeDictation(empty)).toEqual({ status: "empty", reason: "no-transcript" });
expect(recording.getSegmentCount()).toBe(2);
expect(completeDictation({ ...empty, finalText: " recovered " })).toEqual({
status: "transcribed",
text: "recovered",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("keeps usable partial text when the final response is empty", () => {
const recording = new RecordedAudio(["PCM chunk"]);
expect(
completeDictation({ finalText: "", partialText: " recognized words ", recording }),
).toEqual({
status: "transcribed",
text: "recognized words",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("reports a missing recording without inventing a reason for the empty result", () => {
expect(
completeDictation({ finalText: "", partialText: "", recording: new RecordedAudio([]) }),
).toEqual({
status: "empty",
reason: "no-audio",
});
});
});
+26
View File
@@ -0,0 +1,26 @@
export type DictationEmptyReason = "no-audio" | "no-transcript";
export interface DictationRecording {
getSegmentCount(): number;
clearAll(): void;
}
export type DictationResult =
| { status: "empty"; reason: DictationEmptyReason }
| { status: "transcribed"; text: string };
export function completeDictation(input: {
finalText: string;
partialText: string;
recording: DictationRecording;
}): DictationResult {
const text = input.finalText.trim() || input.partialText.trim();
if (!text) {
return {
status: "empty",
reason: input.recording.getSegmentCount() === 0 ? "no-audio" : "no-transcript",
};
}
input.recording.clearAll();
return { status: "transcribed", text };
}
@@ -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}`);
+10 -1
View File
@@ -1,6 +1,14 @@
import type { DictationEmptyReason } from "@/dictation/result";
import { i18n } from "@/i18n/i18next";
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export type DictationStatus =
| "idle"
| "recording"
| "paused"
| "uploading"
| "failed"
// Finished without text; captured audio remains available for retry.
| "empty";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
@@ -22,6 +30,7 @@ export interface UseDictationResult {
duration: number;
error: string | null;
status: DictationStatus;
emptyReason: DictationEmptyReason | null;
startDictation: () => Promise<void>;
cancelDictation: () => Promise<void>;
confirmDictation: () => Promise<void>;
+22 -9
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
import { completeDictation, type DictationEmptyReason } from "@/dictation/result";
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
import { generateMessageId } from "@/types/stream";
import { AttemptGuard } from "@/utils/attempt-guard";
@@ -33,6 +34,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const [emptyReason, setEmptyReason] = useState<DictationEmptyReason | null>(null);
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript);
@@ -131,12 +133,17 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
[setError],
);
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
const clearTranscriptState = useCallback(() => {
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
setEmptyReason(null);
}, []);
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
clearTranscriptState();
}, [clearTranscriptState]);
const startNewStream = useCallback(async (reason: string) => {
await senderRef.current?.restartStream(reason);
}, []);
@@ -192,16 +199,20 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setDuration(0);
setStatus("idle");
const transcriptText =
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim();
clearStreamingState();
if (!transcriptText) {
const result = completeDictation({
finalText: text,
partialText: latestPartialTranscriptRef.current,
recording: senderRef.current!,
});
if (result.status === "empty") {
setStatus("empty");
setEmptyReason(result.reason);
return;
}
onTranscriptRef.current?.(transcriptText, { requestId });
clearTranscriptState();
onTranscriptRef.current?.(result.text, { requestId });
},
[clearStreamingState],
[clearTranscriptState],
);
const handleDictationFailure = useCallback(
@@ -395,6 +406,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return;
}
setError(null);
setEmptyReason(null);
setStatus("uploading");
setIsProcessing(true);
isProcessingRef.current = true;
@@ -462,6 +474,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
duration,
error,
status: status === "recording" && audio.isPaused ? "paused" : status,
emptyReason,
startDictation,
cancelDictation,
confirmDictation,
+4 -83
View File
@@ -371,6 +371,10 @@ export const ar: TranslationResources = {
insertAndSend: "أدخل النسخ وأرسل",
failed: "فشل الإملاء:{{error}}",
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
empty: {
noAudio: "لم يُسجَّل أي صوت. هل يستخدم تطبيق آخر الميكروفون؟",
noTranscript: "لم يتم إرجاع نص. اضغط على إعادة المحاولة.",
},
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
@@ -1641,82 +1645,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 +1863,6 @@ export const ar: TranslationResources = {
notNow: "ليس الآن",
directConnectionHint:
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
tailscaleDocs: "إعداد Tailscale",
tailscaleDocsAccessibility: "اقرأ كيفية الاتصال عبر Tailscale",
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
@@ -2436,11 +2362,6 @@ export const ar: TranslationResources = {
general: {
title: "عام",
sending: "الإرسال",
setup: {
label: "الإعداد الأولي",
description: "أعد المرور على المظهر والمزوّدين والمشاريع وإقران الهاتف",
action: "إظهار الإعداد مجددًا",
},
browserData: {
title: "بيانات المتصفح",
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
+4 -83
View File
@@ -368,6 +368,10 @@ export const en = {
insertAndSend: "Insert transcription and send",
failed: "Dictation failed: {{error}}",
failedRetry: "Dictation failed. Tap retry.",
empty: {
noAudio: "No audio was recorded. Is another app using the microphone?",
noTranscript: "No transcription was returned. Tap retry to try again.",
},
},
question: {
responseFailed: "Could not send your response. Try again.",
@@ -1666,82 +1670,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 +1888,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 +2451,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",
+4 -86
View File
@@ -376,6 +376,10 @@ export const es: TranslationResources = {
insertAndSend: "Insertar transcripción y enviar",
failed: "Fallo en el dictado:{{error}}",
failedRetry: "El dictado falló. Toca reintentar.",
empty: {
noAudio: "No se grabó audio. ¿Otra app está usando el micrófono?",
noTranscript: "No se devolvió ninguna transcripción. Toca reintentar.",
},
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
@@ -1687,84 +1691,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 +1911,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 +2374,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",
+4 -86
View File
@@ -376,6 +376,10 @@ export const fr: TranslationResources = {
insertAndSend: "Insérer la transcription et envoyer",
failed: "Échec de la dictée : {{error}}",
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
empty: {
noAudio: "Aucun son n'a été enregistré. Une autre app utilise-t-elle le micro ?",
noTranscript: "Aucune transcription reçue. Appuyez sur Réessayer.",
},
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
@@ -1685,84 +1689,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 +1909,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 +2374,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",
+4 -85
View File
@@ -376,6 +376,10 @@ export const ja: TranslationResources = {
insertAndSend: "文字起こしを挿入して送信",
failed: "音声入力に失敗しました: {{error}}",
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
empty: {
noAudio: "音声が録音されませんでした。別のアプリがマイクを使用していませんか?",
noTranscript: "文字起こしが返されませんでした。再試行をタップしてください。",
},
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
@@ -1658,83 +1662,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 +1882,6 @@ export const ja: TranslationResources = {
notNow: "今はしない",
directConnectionHint:
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
tailscaleDocs: "Tailscale を設定",
tailscaleDocsAccessibility: "Tailscale で接続する方法を読む",
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
@@ -2422,12 +2347,6 @@ export const ja: TranslationResources = {
general: {
title: "一般",
sending: "送信",
setup: {
label: "初回セットアップ",
description:
"外観、プロバイダー、プロジェクト、スマートフォンのペアリングをもう一度設定します",
action: "セットアップを再表示",
},
browserData: {
title: "ブラウザーデータ",
siteData: "Cookie とサイトデータ",
+4 -83
View File
@@ -372,6 +372,10 @@ export const ko: TranslationResources = {
insertAndSend: "전사 삽입 후 보내기",
failed: "받아쓰기 실패: {{error}}",
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
empty: {
noAudio: "녹음된 오디오가 없습니다. 다른 앱이 마이크를 사용 중인가요?",
noTranscript: "전사 결과가 없습니다. 다시 시도하려면 누르세요.",
},
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
@@ -1651,82 +1655,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 +1873,6 @@ export const ko: TranslationResources = {
notNow: "지금은 아님",
directConnectionHint:
"릴레이 없이 TCP, Tailscale 또는 다른 VPN을 통해 직접 연결하세요. QR 코드가 생성되지 않습니다.",
tailscaleDocs: "Tailscale 설정",
tailscaleDocsAccessibility: "Tailscale로 연결하는 방법 읽기",
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
unavailable: "페어링 정보를 사용할 수 없습니다.",
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
@@ -2410,11 +2336,6 @@ export const ko: TranslationResources = {
general: {
title: "일반",
sending: "전송",
setup: {
label: "초기 설정",
description: "모양, 제공자, 프로젝트, 휴대폰 페어링을 다시 진행합니다",
action: "설정 다시 보기",
},
browserData: {
title: "브라우저 데이터",
siteData: "쿠키 및 사이트 데이터",
+4 -84
View File
@@ -375,6 +375,10 @@ export const ptBR: TranslationResources = {
insertAndSend: "Inserir transcrição e enviar",
failed: "Falha no ditado: {{error}}",
failedRetry: "Falha no ditado. Toque para tentar novamente.",
empty: {
noAudio: "Nenhum áudio foi gravado. Outro app está usando o microfone?",
noTranscript: "Nenhuma transcrição foi retornada. Toque para tentar novamente.",
},
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
@@ -1672,83 +1676,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 +1896,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 +2359,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",
+4 -84
View File
@@ -375,6 +375,10 @@ export const ru: TranslationResources = {
insertAndSend: "Вставить транскрипцию и отправить",
failed: "Диктовка не удалась: {{error}}",
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
empty: {
noAudio: "Звук не записан. Возможно, микрофон занят другим приложением?",
noTranscript: "Текст не получен. Нажмите «Повторить».",
},
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
@@ -1670,83 +1674,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 +1895,6 @@ export const ru: TranslationResources = {
notNow: "Не сейчас",
directConnectionHint:
"Без ретранслятора подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код создаваться не будет.",
tailscaleDocs: "Настроить Tailscale",
tailscaleDocsAccessibility: "Узнать, как подключиться через Tailscale",
updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.",
unavailable: "Данные для сопряжения недоступны.",
hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.",
@@ -2436,11 +2361,6 @@ export const ru: TranslationResources = {
general: {
title: "Основные",
sending: "Отправка",
setup: {
label: "Первоначальная настройка",
description: "Снова пройти оформление, провайдеры, проекты и привязку телефона",
action: "Показать настройку снова",
},
browserData: {
title: "Данные браузера",
siteData: "Файлы cookie и данные сайтов",
+4 -80
View File
@@ -371,6 +371,10 @@ export const zhCN: TranslationResources = {
insertAndSend: "插入转写并发送",
failed: "听写失败:{{error}}",
failedRetry: "听写失败。点按重试。",
empty: {
noAudio: "未录到音频。是否有其他应用正在使用麦克风?",
noTranscript: "未返回转写文本。点按重试。",
},
},
question: {
responseFailed: "未能发送你的回复,请重试。",
@@ -1624,79 +1628,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 +1842,6 @@ export const zhCN: TranslationResources = {
notNow: "暂不",
directConnectionHint:
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
tailscaleDocs: "设置 Tailscale",
tailscaleDocsAccessibility: "了解如何通过 Tailscale 连接",
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
@@ -2374,11 +2303,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;
@@ -48,7 +48,8 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: 0x472684ff,
background: 0xffffff00,
foreground: 0xff000000,
} as const;
function build256Palette(theme: ITheme): number[] {
@@ -77,6 +77,7 @@ export function TerminalGridView({
background: resolver.backgroundColor,
cursor: resolver.cursorColor,
selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background,
selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}),
[resolver],
);
@@ -1,411 +0,0 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
createTerminalSelectionModel,
extractTerminalSelectedText,
resolveTerminalSelectionRects,
} from "./terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
} from "./terminal-selection-controller";
describe("selection handle controller", () => {
it("preserves the grab offset while moving a handle", () => {
const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 };
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 23, y: 15 });
controller.move({ x: 63, y: 15 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 5 }]);
});
});
it("selects on hold, preserves a handle tap, and cancels an active drag", () => {
const actions: string[] = [];
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint) => actions.push(endpoint),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: () => actions.push("scroll"),
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 90, y: 80 });
(timer as (() => void) | null)?.();
controller.release();
controller.press({ x: 20, y: 25 });
controller.release();
controller.press({ x: 20, y: 25 });
controller.cancel();
controller.move({ x: 50, y: 25 });
expect(actions).toEqual(["long", "start", "start"]);
});
it("scrolls selection into history while holding a handle at the edge", () => {
let firstRow = 10;
let timer: (() => void) | null = null;
const coordinates: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (point) => coordinates.push(point),
onLongPress: () => {},
onTap: () => {},
onScroll: (direction) => {
if (direction === "up") firstRow--;
},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 20, y: 25 });
controller.move({ x: 20, y: 1 });
(timer as (() => void) | null)?.();
(timer as (() => void) | null)?.();
controller.release();
expect(coordinates).toEqual([
{ row: 10, col: 2 },
{ row: 9, col: 2 },
{ row: 8, col: 2 },
]);
expect(timer).toBeNull();
});
it("scrolls outside handles without clearing selection and clears on an outside tap", () => {
const actions: string[] = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => actions.push("handle"),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: (direction, rows) => actions.push(`${direction}:${rows}`),
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 95, y: 70 });
controller.move({ x: 95, y: 90 });
controller.release();
controller.press({ x: 95, y: 70 });
controller.release();
expect(actions).toEqual(["up:2", "tap"]);
});
it("extends the selection after a long press and drag", () => {
let timer: (() => void) | null = null;
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: null,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 25, y: 25 });
(timer as (() => void) | null)?.();
controller.move({ x: 65, y: 35 });
controller.release();
expect(moves).toEqual([{ row: 3, col: 6 }]);
});
it.each([
{
endpoint: "start" as const,
startX: 20,
crossedX: 80,
crossedText: "gh",
crossedStart: 6,
crossedEnd: 7,
backX: 10,
backText: "bcdef",
backStart: 1,
backEnd: 5,
},
{
endpoint: "end" as const,
startX: 60,
crossedX: 10,
crossedText: "b",
crossedStart: 1,
crossedEnd: 1,
backX: 80,
backText: "cdefgh",
backStart: 2,
backEnd: 7,
},
])("tracks the $endpoint handle through crossing and back", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
const check = (start: number, end: number, text: string) => {
const range = model.getSnapshot().range;
expect(range).toEqual({
start: { row: 0, col: start },
end: { row: 0, col: end },
coordinateEpoch: bounds.coordinateEpoch,
});
expect(terminalSelectionHandles(geometry())).toMatchObject([
{ endpoint: "start", x: start * 10, y: 5 },
{ endpoint: "end", x: (end + 1) * 10, y: 5 },
]);
expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([
{
key: `0:${start}:${end}`,
x: start * 10,
y: 0,
width: (end - start + 1) * 10,
height: 10,
},
]);
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text);
};
controller.press({ x: scenario.startX, y: 5 });
controller.move({ x: scenario.crossedX, y: 5 });
check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText);
controller.move({ x: scenario.backX, y: 5 });
check(scenario.backStart, scenario.backEnd, scenario.backText);
controller.release();
} finally {
terminal.dispose();
}
});
it.each([
{
endpoint: "end" as const,
initialRow: 0,
targetRow: 1,
targetX: 0,
expectedRow: 0,
expectedCol: 9,
text: "cdefghij",
},
{
endpoint: "start" as const,
initialRow: 1,
targetRow: 0,
targetX: 100,
expectedRow: 1,
expectedCol: 0,
text: "KLMNOP",
},
])("preserves a $endpoint boundary at a row edge", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij\r\nKLMNOPQRST");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds });
model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 });
controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 });
const range = model.getSnapshot().range;
expect(range?.[scenario.endpoint]).toEqual({
row: scenario.expectedRow,
col: scenario.expectedCol,
});
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text);
expect(
terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint),
).toMatchObject({
x: scenario.endpoint === "end" ? 100 : 0,
y: scenario.expectedRow * 10 + 5,
});
controller.release();
} finally {
terminal.dispose();
}
});
it("cancels a drag when buffer coordinates are invalidated", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
model.begin({ coordinate: { row: 2, col: 2 }, bounds });
model.update({ coordinate: { row: 2, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const controller = createTerminalSelectionController({
getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 20, y: 25 });
const previous = model.getSnapshot().range;
const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } });
if (previous && !snapshot.range) controller.cancel();
controller.move({ x: 80, y: 25 });
controller.release();
expect(model.getSnapshot().range).toBeNull();
});
it("preserves a newly selected word through small movement at the viewport edge", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: model.getSnapshot().range,
viewport: { firstRow: 0, rows: 10, cols: 20 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 6 }, bounds });
},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 45, y: 5 });
const fire = timer as (() => void) | null;
timer = null;
fire?.();
controller.move({ x: 44, y: 4 });
controller.release();
expect(model.getSnapshot().range).toEqual({
start: { row: 0, col: 2 },
end: { row: 0, col: 6 },
coordinateEpoch: 0,
});
expect(timer).toBeNull();
});
it("places mirrored teardrop grips below the selected row", () => {
const handles = terminalSelectionHandles({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
});
expect(handles).toMatchObject([
{ endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" },
{ endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" },
]);
});
it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => {
const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 };
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const handles = terminalSelectionHandles({ range, viewport, metrics });
expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([
{ gripX: 9, gripY: 19 },
{ gripX: 91, gripY: 81 },
]);
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({ range, viewport, metrics }),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 9, y: 19 });
controller.move({ x: 19, y: 29 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 1 }]);
});
@@ -1,208 +0,0 @@
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
import type {
TerminalBufferCoordinate,
TerminalSelectionPoint,
TerminalSelectionRange,
TerminalSelectionViewport,
} from "./terminal-selection";
export const TERMINAL_SELECTION_GRIP_SIZE = 18;
const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
type Endpoint = "start" | "end";
interface Geometry {
range: TerminalSelectionRange | null;
viewport: TerminalSelectionViewport;
metrics: TerminalGridCellMetrics;
}
interface Ports {
getGeometry(): Geometry | null;
onBeginHandle(endpoint: Endpoint, cols: number): void;
onMoveHandle(coordinate: TerminalBufferCoordinate): void;
onLongPress(point: TerminalSelectionPoint): void;
onTap(): void;
onScroll(direction: "up" | "down", rows: number): void;
schedule(callback: () => void, delay: number): number;
cancel(timer: number): void;
}
export function terminalSelectionHandles(geometry: Geometry): Array<{
endpoint: Endpoint;
x: number;
y: number;
gripX: number;
gripY: number;
gripDirection: "left" | "right";
gripAbove: boolean;
stemTop: number;
stemHeight: number;
}> {
const { range, viewport, metrics } = geometry;
if (!range) return [];
return (["start", "end"] as const).flatMap((endpoint) => {
const cell = range[endpoint];
if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return [];
const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth;
const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight;
const size = TERMINAL_SELECTION_GRIP_SIZE;
const width = viewport.cols * metrics.cellWidth;
const height = viewport.rows * metrics.cellHeight;
const wantsLeft = endpoint === "start";
const gripDirection =
(wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right";
const gripAbove = stemTop + metrics.cellHeight + size > height;
const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight;
return [
{
endpoint,
x,
y: stemTop + metrics.cellHeight / 2,
gripX: x + (gripDirection === "left" ? -size / 2 : size / 2),
gripY: gripTop + size / 2,
gripDirection,
gripAbove,
stemTop,
stemHeight: metrics.cellHeight,
},
];
});
}
export function createTerminalSelectionController(ports: Ports) {
let press: {
start: TerminalSelectionPoint;
point: TerminalSelectionPoint;
endpoint: Endpoint | null;
offset: TerminalSelectionPoint;
fixed: TerminalBufferCoordinate | null;
moved: boolean;
long: boolean;
scrolling: boolean;
remainder: number;
} | null = null;
let timer: number | null = null;
function stopTimer() {
if (timer !== null) ports.cancel(timer);
timer = null;
}
function updateHandle() {
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const { viewport, metrics } = geometry;
const x = press.point.x - press.offset.x;
const y = press.point.y - press.offset.y;
let row =
viewport.firstRow +
Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight)));
let col = Math.floor(x / metrics.cellWidth);
if (press.endpoint && press.fixed) {
const boundary = Math.round(x / metrics.cellWidth);
const isEnd =
row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col);
col = boundary - (isEnd ? 1 : 0);
if (col < 0 && isEnd) {
row -= 1;
col = viewport.cols - 1;
} else if (col >= viewport.cols && !isEnd) {
row += 1;
col = 0;
}
}
col = Math.max(0, Math.min(viewport.cols - 1, col));
ports.onMoveHandle({ row, col });
}
function edgeTick() {
timer = null;
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const y = press.point.y - press.offset.y;
const height = geometry.viewport.rows * geometry.metrics.cellHeight;
let direction: "up" | "down" | null = null;
if (y < geometry.metrics.cellHeight) direction = "up";
else if (y > height - geometry.metrics.cellHeight) direction = "down";
if (!direction) return;
ports.onScroll(direction, 1);
if (!press) return;
updateHandle();
if (press) timer = ports.schedule(edgeTick, 100);
}
return {
press(point: TerminalSelectionPoint) {
stopTimer();
const geometry = ports.getGeometry();
const handles = geometry ? terminalSelectionHandles(geometry) : [];
const handle = handles
.map((item) => ({
endpoint: item.endpoint,
x: item.x,
y: item.y,
distance: Math.hypot(point.x - item.gripX, point.y - item.gripY),
}))
.filter((item) => item.distance <= 28)
.sort((a, b) => a.distance - b.distance)[0];
press = {
start: point,
point,
endpoint: handle?.endpoint ?? null,
offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 },
fixed:
handle && geometry?.range
? geometry.range[handle.endpoint === "start" ? "end" : "start"]
: null,
moved: false,
long: false,
scrolling: false,
remainder: 0,
};
if (handle && geometry) {
ports.onBeginHandle(handle.endpoint, geometry.viewport.cols);
return;
}
timer = ports.schedule(() => {
timer = null;
if (press && !press.moved) {
press.long = true;
ports.onLongPress(press.start);
}
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
move(point: TerminalSelectionPoint) {
if (!press) return;
const previous = press.point;
press.point = point;
const dx = point.x - press.start.x,
dy = point.y - press.start.y;
if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
press.moved = true;
if (!press.endpoint) stopTimer();
}
if (press.endpoint || press.long) {
if (press.endpoint || press.moved) {
updateHandle();
stopTimer();
if (press) timer = ports.schedule(edgeTick, 100);
}
} else if (!press.long) {
if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
press.scrolling = true;
press.remainder = dy;
} else if (press.scrolling) press.remainder += point.y - previous.y;
const height = ports.getGeometry()?.metrics.cellHeight ?? 0;
if (press.scrolling && height > 0) {
const rows = Math.trunc(press.remainder / height);
press.remainder -= rows * height;
if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows));
}
}
},
release() {
stopTimer();
if (press && !press.endpoint && !press.long && !press.moved) ports.onTap();
press = null;
},
cancel() {
stopTimer();
press = null;
},
};
}
@@ -0,0 +1,354 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const rowDeltas: number[] = [];
for (const currentDy of [13, 15, 18, 22, 28]) {
const update = advanceTerminalScrollGesture({
state,
currentDy,
cellHeight: 14,
});
state = update.state;
rowDeltas.push(update.rowDelta);
}
expect({ rowDeltas, state }).toEqual({
rowDeltas: [0, 1, 0, 0, 1],
state: { lastDy: 28, rowRemainder: 0, didScroll: true },
});
});
});
describe("classifyTerminalGestureIntent", () => {
it("treats a motionless press as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 0,
dy: 0,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("treats a tiny jitter within tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("leaves horizontal movement available to terminal selection", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("pending");
});
it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 1,
vx: 2,
vy: 0,
elapsedMs: 20,
}),
).toEqual("tap");
});
it("keeps horizontal movement below navigation distance pending", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10,
dy: 2,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("treats a clearly vertical drag as scroll", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 4,
dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5,
vx: 0,
vy: 0.2,
}),
).toEqual("scroll");
});
it("does not navigate on a fast rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a slow rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a fast leftward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate while movement stays inside tap tolerance", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 2,
vx: 2,
vy: 0,
}),
).toEqual("tap");
});
it("keeps selection intent once selection is active", () => {
expect(
classifyTerminalGestureIntent({
status: "selecting",
dx: 100,
dy: 50,
vx: 2,
vy: 1,
}),
).toEqual("select");
});
});
describe("resolveTerminalGestureReleaseAction", () => {
it("focuses the terminal after a normal tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("focuses the terminal after a normal tap while scrolled", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("focus");
});
it("starts selection after a long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("starts selection after a 500ms hold", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 500,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("keeps a 400ms press as a tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 400,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("does nothing after a scroll gesture", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: true,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("none");
});
it("does nothing after a navigation swipe", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: true,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing while actively selecting", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("clears an existing selection after a short tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: true,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("clear");
});
it("preserves a word selection created by the current long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does not focus after an undecided fast drag", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing from idle", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "idle",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
});
});
@@ -0,0 +1,108 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
export interface TerminalGestureMoveInput {
status: TerminalGestureStatus;
dx: number;
dy: number;
vx: number;
vy: number;
elapsedMs?: number;
}
export interface TerminalGestureReleaseInput {
status: TerminalGestureStatus;
startedWithSelection?: boolean;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export interface TerminalScrollGestureState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
}
export interface TerminalScrollGestureUpdate {
state: TerminalScrollGestureState;
rowDelta: number;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export function classifyTerminalGestureIntent(
input: TerminalGestureMoveInput,
): TerminalGestureIntent {
if (input.status === "selecting") {
return "select";
}
const absDx = Math.abs(input.dx);
const absDy = Math.abs(input.dy);
if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) {
return "scroll";
}
if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
return "pending";
}
return "tap";
}
export function advanceTerminalScrollGesture(input: {
state: TerminalScrollGestureState;
currentDy: number;
cellHeight: number;
}): TerminalScrollGestureUpdate {
const deltaY = input.currentDy - input.state.lastDy;
const accumulatedRows = input.state.rowRemainder + deltaY;
const rowDelta = Math.trunc(accumulatedRows / input.cellHeight);
return {
state: {
lastDy: input.currentDy,
rowRemainder: accumulatedRows - rowDelta * input.cellHeight,
didScroll: input.state.didScroll || rowDelta !== 0,
},
rowDelta,
};
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.didScroll || input.didNavigate) {
return "none";
}
if (input.status === "selecting") {
if (!input.startedWithSelection) {
return "none";
}
return input.movedBeyondTapTolerance ? "none" : "clear";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}
@@ -483,51 +483,3 @@ describe("native terminal selection", () => {
expect(copied).toEqual([]);
});
});
describe("endpoint dragging", () => {
it.each([
{
endpoint: "start" as const,
fixed: { row: 2, col: 9 },
target: { row: 3, col: 2 },
expected: { row: 3, col: 0 },
},
{
endpoint: "end" as const,
fixed: { row: 2, col: 0 },
target: { row: 1, col: 7 },
expected: { row: 1, col: 9 },
},
])(
"preserves the fixed boundary across a row edge for $endpoint",
({ endpoint, fixed, target, expected }) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: fixed, bounds });
selection.update({ coordinate: fixed, bounds });
selection.beginEndpointDrag(endpoint, 10);
selection.update({ coordinate: target, bounds });
expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual(
expected,
);
},
);
it.each(["start", "end"] as const)(
"keeps the opposite endpoint fixed as %s crosses it",
(endpoint) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: { row: 2, col: 2 }, bounds });
selection.update({ coordinate: { row: 3, col: 4 }, bounds });
selection.beginEndpointDrag(endpoint, 10);
const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 };
selection.update({ coordinate, bounds });
expect(selection.getSnapshot().range).toEqual(
endpoint === "start"
? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 }
: { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 },
);
},
);
});
@@ -25,7 +25,6 @@ export interface TerminalSelectionBounds {
export interface TerminalSelectionModel {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
beginEndpointDrag(endpoint: "start" | "end", cols: number): void;
clear(): TerminalSelectionSnapshot;
sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot;
getSnapshot(): TerminalSelectionSnapshot;
@@ -253,7 +252,6 @@ export function resolveTerminalSelectionRects(
export function createTerminalSelectionModel(): TerminalSelectionModel {
let state: TerminalSelectionState = { anchor: null, range: null };
let drag: { endpoint: "start" | "end"; cols: number } | null = null;
function snapshot(): TerminalSelectionSnapshot {
return { range: state.range };
@@ -261,31 +259,18 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
const coordinate = clampCoordinate(input.coordinate, input.bounds);
const fixed = state.anchor ?? coordinate;
let anchor = fixed;
if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) {
anchor =
fixed.col + 1 < drag.cols
? { row: fixed.row, col: fixed.col + 1 }
: { row: fixed.row + 1, col: 0 };
} else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) {
anchor =
fixed.col > 0
? { row: fixed.row, col: fixed.col - 1 }
: { row: fixed.row - 1, col: drag.cols - 1 };
}
const anchor = state.anchor ?? coordinate;
const range = normalizeTerminalSelection({
anchor,
focus: coordinate,
coordinateEpoch: input.bounds.coordinateEpoch,
});
state = { anchor: fixed, range };
state = { anchor, range };
return snapshot();
}
return {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
drag = null;
const coordinate = clampCoordinate(input.coordinate, input.bounds);
state = {
anchor: coordinate,
@@ -300,14 +285,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
return setSelection(input);
},
beginEndpointDrag(endpoint, cols) {
if (state.range) {
drag = { endpoint, cols };
state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] };
}
},
clear(): TerminalSelectionSnapshot {
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
@@ -315,7 +293,6 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
if (!state.range || rangeIsSafe(state.range, input.bounds)) {
return snapshot();
}
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
-21
View File
@@ -869,14 +869,6 @@ export type WorkspaceLabelDeleteInspectPayload = Extract<
SessionOutboundMessage,
{ type: "workspace.label.delete.inspect.response" }
>["payload"];
export type ProjectSuggestionsPayload = Extract<
SessionOutboundMessage,
{ type: "project.suggestions.response" }
>["payload"];
export type ProjectSuggestionsOptions = Omit<
Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
"type" | "requestId"
> & { requestId?: string };
export type ProjectListPayload = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
@@ -2984,19 +2976,6 @@ export class DaemonClient {
});
}
/** Repositories the user ran agent sessions in recently, grouped by main checkout. */
listProjectSuggestions(options?: ProjectSuggestionsOptions): Promise<ProjectSuggestionsPayload> {
return this.sendNamespacedCorrelatedSessionRequest<"project.suggestions.response">({
requestId: options?.requestId,
message: {
type: "project.suggestions.request",
...(options?.providers ? { providers: options.providers } : {}),
...(options?.since ? { since: options.since } : {}),
...(options?.limit ? { limit: options.limit } : {}),
},
});
}
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -12,7 +12,8 @@ export async function runAppearanceFontSizeRegression(page) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Appearance", exact: true }).click();
await page.getByRole("radio", { name: "Pure black", exact: true }).click();
await page.getByLabel("Theme: System", { exact: true }).click();
await page.getByText("Pure black", { exact: true }).click();
const interfaceSizeInput = page.getByRole("textbox", { name: "Interface font size" });
const contentSizeInput = page.getByRole("textbox", { name: "Content font size" });
@@ -804,11 +804,6 @@ async function openSmokeWorkspace({ appPath, env, page, daemonHome }) {
],
label: "Create packaged smoke workspace",
});
// The fresh daemon had no projects at launch, so first-run setup is open over the sidebar.
await page.evaluate(() =>
localStorage.setItem("@paseo:onboarding", JSON.stringify({ completedAt: "1970-01-01" })),
);
await page.reload();
await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click();
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`));
await page.getByRole("button", { name: "Agent", exact: true }).click();
-34
View File
@@ -1370,15 +1370,6 @@ export const ProjectListRequestMessageSchema = z.object({
sync: DirectorySyncRequestSchema.optional(),
});
export const ProjectSuggestionsRequestSchema = z.object({
type: z.literal("project.suggestions.request"),
requestId: z.string(),
providers: z.array(z.string()).optional(),
// ISO timestamp. Sessions older than this are ignored. Defaults to 30 days ago.
since: z.string().optional(),
limit: z.number().int().positive().max(100).optional(),
});
export const FetchAgentHistoryRequestMessageSchema = z.object({
type: z.literal("fetch_agent_history_request"),
requestId: z.string(),
@@ -3305,7 +3296,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FetchRecentProviderSessionsRequestMessageSchema,
FetchWorkspacesRequestMessageSchema,
ProjectListRequestMessageSchema,
ProjectSuggestionsRequestSchema,
FetchAgentRequestMessageSchema,
DeleteAgentRequestMessageSchema,
ArchiveAgentRequestMessageSchema,
@@ -3823,8 +3813,6 @@ export const ServerInfoStatusPayloadSchema = z
projectCreateDirectory: z.boolean().optional(),
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: z.boolean().optional(),
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
projectSuggestions: z.boolean().optional(),
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: z.boolean().optional(),
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
@@ -4510,24 +4498,6 @@ export const ProjectListResponseMessageSchema = z.object({
}),
});
export const ProjectSuggestionSchema = z.object({
// The main repository root for git checkouts and their worktrees; the session cwd otherwise.
rootPath: z.string(),
displayName: z.string(),
sessionCount: z.number().int().nonnegative(),
providerIds: z.array(z.string()),
lastActivityAt: z.string(),
});
export const ProjectSuggestionsResponseSchema = z.object({
type: z.literal("project.suggestions.response"),
payload: z.object({
requestId: z.string(),
suggestions: z.array(ProjectSuggestionSchema),
providerErrors: z.array(z.object({ provider: z.string(), message: z.string() })),
}),
});
export const ScriptStatusUpdateMessageSchema = z.object({
type: z.literal("script_status_update"),
payload: z.object({
@@ -7094,7 +7064,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceLabelDeleteInspectResponseSchema,
ProjectUpdateMessageSchema,
ProjectListResponseMessageSchema,
ProjectSuggestionsResponseSchema,
ScriptStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
@@ -7459,9 +7428,6 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
>;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
export type ProjectSuggestionsRequest = z.infer<typeof ProjectSuggestionsRequestSchema>;
export type ProjectSuggestion = z.infer<typeof ProjectSuggestionSchema>;
export type ProjectSuggestionsResponse = z.infer<typeof ProjectSuggestionsResponseSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
@@ -380,7 +380,7 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
export function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
return (
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
);
@@ -153,7 +153,6 @@ const INBOUND_PERMISSION = {
"project.icon.get.request": "workspace.read",
"project.icon.set.request": "workspace.manage",
"project.list.request": "workspace.read",
"project.suggestions.request": "workspace.read",
"project.remove.request": "workspace.manage",
"project.rename.request": "workspace.manage",
project_icon_request: "workspace.read",
@@ -391,7 +390,6 @@ const OUTBOUND_PERMISSION = {
"project.icon.get.response": "workspace.read",
"project.icon.set.response": "workspace.manage",
"project.list.response": "workspace.read",
"project.suggestions.response": "workspace.read",
"project.remove.response": "workspace.manage",
"project.rename.response": "workspace.manage",
"project.update": "workspace.read",
@@ -1,188 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import type {
AgentManager,
ImportableSessionProviderError,
ManagedImportableProviderSession,
} from "./agent/agent-manager.js";
import { listProjectSuggestions } from "./project-suggestions.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { PersistedProjectRecord } from "./workspace-registry.js";
const NOW = new Date("2026-10-31T12:00:00.000Z");
function daysAgo(days: number): Date {
return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000);
}
function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, stdio: "ignore" });
}
function createRepo(root: string, name: string): string {
const repo = path.join(root, name);
mkdirSync(repo, { recursive: true });
git(repo, "init", "-b", "main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test");
writeFileSync(path.join(repo, "README.md"), "hello\n");
git(repo, "add", ".");
git(repo, "commit", "-m", "init");
return repo;
}
function session(
provider: string,
cwd: string,
lastActivityAt: Date,
): ManagedImportableProviderSession {
return {
provider,
providerHandleId: `${provider}-${cwd}-${lastActivityAt.toISOString()}`,
cwd,
title: null,
firstPromptPreview: "Fix the build",
lastPromptPreview: null,
lastActivityAt,
};
}
/** Agent history as the providers report it. */
function agentHistory(
sessions: ManagedImportableProviderSession[],
providerErrors: ImportableSessionProviderError[] = [],
): Pick<AgentManager, "listImportableSessions"> {
return {
listImportableSessions: async (options) => ({
sessions: sessions.filter(
(entry) => !options?.providerFilter || options.providerFilter.has(entry.provider),
),
providerErrors,
}),
};
}
function projects(...rootPaths: Array<{ rootPath: string; archived?: boolean }>) {
const records = rootPaths.map(
({ rootPath, archived }, index): PersistedProjectRecord => ({
projectId: `project-${index}`,
rootPath,
kind: "git",
displayName: path.basename(rootPath),
projectKey: null,
customName: null,
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
archivedAt: archived ? NOW.toISOString() : null,
}),
);
return { list: async () => records };
}
describe("listProjectSuggestions", () => {
let root: string;
let gitService: WorkspaceGitServiceImpl;
beforeEach(() => {
// The native realpath expands Windows short names (RUNNER~1), which git reports in full.
root = realpathSync.native(mkdtempSync(path.join(tmpdir(), "project-suggestions-")));
gitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: path.join(root, ".paseo"),
});
});
afterEach(async () => {
await gitService.dispose();
rmSync(root, { recursive: true, force: true });
});
test("groups sessions in a repo, its subdirectories and its worktrees under the main checkout", async () => {
const app = createRepo(root, "app");
mkdirSync(path.join(app, "packages", "web"), { recursive: true });
const worktree = path.join(root, "codex-worktrees", "app-feature");
git(app, "worktree", "add", "-b", "feature", worktree);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", app, daysAgo(1)),
session("claude", path.join(app, "packages", "web"), daysAgo(2)),
session("codex", worktree, daysAgo(3)),
]),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: app,
displayName: "app",
sessionCount: 3,
providerIds: ["claude", "codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [],
});
});
test("leaves out active projects, deleted folders, old sessions and the home directory", async () => {
const added = createRepo(root, "already-added");
const removed = createRepo(root, "removed-project");
const notes = path.join(root, "notes");
mkdirSync(notes);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", added, daysAgo(1)),
session("claude", removed, daysAgo(2)),
session("claude", notes, daysAgo(4)),
session("claude", path.join(root, "deleted"), daysAgo(1)),
session("claude", createRepo(root, "stale"), daysAgo(45)),
session("claude", homedir(), daysAgo(1)),
]),
projectRegistry: projects({ rootPath: added }, { rootPath: removed, archived: true }),
workspaceGitService: gitService,
now: NOW,
});
expect(result.suggestions.map((suggestion) => suggestion.rootPath)).toEqual([removed, notes]);
});
test("reads only the requested providers and reports the ones that failed", async () => {
const app = createRepo(root, "app");
const api = createRepo(root, "api");
const result = await listProjectSuggestions({
request: { providers: ["codex"] },
agentManager: agentHistory(
[session("claude", app, daysAgo(1)), session("codex", api, daysAgo(1))],
[{ provider: "codex", message: "Timed out listing sessions" }],
),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: api,
displayName: "api",
sessionCount: 1,
providerIds: ["codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [{ provider: "codex", message: "Timed out listing sessions" }],
});
});
});
@@ -1,177 +0,0 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, parse, resolve } from "node:path";
import pLimit from "p-limit";
import type { ProjectSuggestion, ProjectSuggestionsRequest } from "@getpaseo/protocol/messages";
import type { AgentManager, ManagedImportableProviderSession } from "./agent/agent-manager.js";
import { isMetadataGenerationSession } from "./agent/import-sessions.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectRegistry } from "./workspace-registry.js";
import { normalizePathForIdentity } from "../utils/path.js";
const DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_LIMIT = 20;
// Sessions read per provider. Enough history to find every repo used in the window.
const SESSION_SCAN_LIMIT = 500;
const CHECKOUT_READ_CONCURRENCY = 8;
export class ProjectSuggestionsRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ProjectSuggestionsRequestError";
}
}
export interface ListProjectSuggestionsInput {
request: Pick<ProjectSuggestionsRequest, "providers" | "since" | "limit">;
agentManager: Pick<AgentManager, "listImportableSessions">;
projectRegistry: Pick<ProjectRegistry, "list">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
now?: Date;
}
export interface ListProjectSuggestionsResult {
suggestions: ProjectSuggestion[];
providerErrors: Array<{ provider: string; message: string }>;
}
/**
* Turns the agent sessions a user already ran into projects they could add. Sessions are grouped
* by repository: a session in a subdirectory or in any worktree counts toward the main checkout,
* because that is the path a project is keyed by.
*/
export async function listProjectSuggestions(
input: ListProjectSuggestionsInput,
): Promise<ListProjectSuggestionsResult> {
const now = input.now ?? new Date();
const since = parseSince(input.request.since, now);
const listing = await input.agentManager.listImportableSessions({
limit: SESSION_SCAN_LIMIT,
scanLimit: SESSION_SCAN_LIMIT,
providerFilter: input.request.providers ? new Set(input.request.providers) : undefined,
});
const sessions = listing.sessions.filter(
(session) => session.lastActivityAt.getTime() >= since && !isMetadataGenerationSession(session),
);
const rootByCwd = await resolveProjectRoots(
new Set(sessions.map((session) => resolve(session.cwd))),
input.workspaceGitService,
);
const excludedRoots = await collectExcludedRoots(input.projectRegistry);
const groups = new Map<string, SuggestionGroup>();
for (const session of sessions) {
const rootPath = rootByCwd.get(resolve(session.cwd));
if (!rootPath || excludedRoots.has(normalizePathForIdentity(rootPath))) {
continue;
}
addSession(groups, rootPath, session);
}
const suggestions = Array.from(groups.values())
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
.slice(0, input.request.limit ?? DEFAULT_LIMIT)
.map(toSuggestion);
return { suggestions, providerErrors: listing.providerErrors };
}
interface SuggestionGroup {
rootPath: string;
sessionCount: number;
providerIds: Set<string>;
lastActivityAt: number;
}
function parseSince(since: string | undefined, now: Date): number {
if (!since) {
return now.getTime() - DEFAULT_WINDOW_MS;
}
const timestamp = Date.parse(since);
if (Number.isNaN(timestamp)) {
throw new ProjectSuggestionsRequestError("invalid_since", `Invalid since timestamp: ${since}`);
}
return timestamp;
}
/** Maps each session cwd to the folder a project would be added at, or null when it is gone. */
async function resolveProjectRoots(
cwds: Set<string>,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<Map<string, string | null>> {
const limit = pLimit(CHECKOUT_READ_CONCURRENCY);
const entries = await Promise.all(
Array.from(cwds, (cwd) =>
limit(async () => [cwd, await resolveProjectRoot(cwd, workspaceGitService)] as const),
),
);
return new Map(entries);
}
async function resolveProjectRoot(
cwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<string | null> {
if (!(await isDirectory(cwd))) {
return null;
}
const checkout = await workspaceGitService.getCheckout(cwd);
// Git reports Windows roots with forward slashes; resolve to the form project.add keys by.
const rootPath = resolve(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? cwd);
if (rootPath !== cwd && !(await isDirectory(rootPath))) {
return null;
}
return rootPath;
}
/** Active projects are already added; the home directory and filesystem root are not projects. */
async function collectExcludedRoots(
projectRegistry: Pick<ProjectRegistry, "list">,
): Promise<Set<string>> {
const home = homedir();
const excluded = new Set([home, parse(home).root].map(normalizePathForIdentity));
for (const project of await projectRegistry.list()) {
if (!project.archivedAt) {
excluded.add(normalizePathForIdentity(project.rootPath));
}
}
return excluded;
}
function addSession(
groups: Map<string, SuggestionGroup>,
rootPath: string,
session: ManagedImportableProviderSession,
): void {
const key = normalizePathForIdentity(rootPath);
const group = groups.get(key) ?? {
rootPath,
sessionCount: 0,
providerIds: new Set<string>(),
lastActivityAt: 0,
};
group.sessionCount += 1;
group.providerIds.add(session.provider);
group.lastActivityAt = Math.max(group.lastActivityAt, session.lastActivityAt.getTime());
groups.set(key, group);
}
function toSuggestion(group: SuggestionGroup): ProjectSuggestion {
return {
rootPath: group.rootPath,
displayName: basename(group.rootPath) || group.rootPath,
sessionCount: group.sessionCount,
providerIds: Array.from(group.providerIds).sort(),
lastActivityAt: new Date(group.lastActivityAt).toISOString(),
};
}
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
-34
View File
@@ -144,7 +144,6 @@ import {
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import { listProjectSuggestions, ProjectSuggestionsRequestError } from "./project-suggestions.js";
import {
checkoutLiteFromGitSnapshot,
checkoutFromPersistedWorkspacePlacement,
@@ -3063,8 +3062,6 @@ export class Session {
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg);
case "project.suggestions.request":
return this.handleProjectSuggestionsRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -6553,37 +6550,6 @@ export class Session {
}
}
private async handleProjectSuggestionsRequest(
request: Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
): Promise<void> {
try {
const result = await listProjectSuggestions({
request,
agentManager: this.agentManager,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
});
this.emit({
type: "project.suggestions.response",
payload: { requestId: request.requestId, ...result },
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.suggestions.request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: error instanceof Error ? error.message : "Failed to list project suggestions",
code:
error instanceof ProjectSuggestionsRequestError
? error.code
: "project_suggestions_failed",
},
});
}
}
private requireWorkspaceLabels(): WorkspaceLabelService {
if (!this.workspaceLabelService) {
throw new SessionRequestError("workspace_labels_unavailable", "Workspace labels unavailable");
@@ -2094,8 +2094,6 @@ export class VoiceAssistantWebSocketServer {
projectAdd: true,
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: true,
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
projectSuggestions: true,
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
-127
View File
@@ -59,130 +59,3 @@ index 0c854d6..7dc2828 100644
}
return props.transform as TransformsStyle['transform'];
}
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
--- a/node_modules/react-native-svg/android/build.gradle
+++ b/node_modules/react-native-svg/android/build.gradle
@@ -103,6 +103,7 @@
}
defaultConfig {
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
minSdkVersion safeExtGet('minSdkVersion', 16)
//noinspection OldTargetApi
targetSdkVersion safeExtGet('targetSdkVersion', 28)
@@ -136,6 +137,8 @@
}
dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
implementation 'com.facebook.react:react-native:+'
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
exclude group: 'com.facebook.soloader'
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
@@ -159,6 +159,8 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
+ clearChildCache();
this.invalidate();
}
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
new file mode 100644
--- /dev/null
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
@@ -0,0 +1,90 @@
+package com.horcrux.svg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.facebook.react.bridge.BridgeReactContext;
+import com.facebook.react.bridge.DynamicFromObject;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.react.uimanager.DisplayMetricsHolder;
+import com.facebook.soloader.SoLoader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SvgResizeTest {
+ @Test
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
+ try {
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (java.io.IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ BridgeReactContext context = new DrawingContext(app);
+ SvgView svg = new SvgView(context);
+ LinearGradientView gradient = new LinearGradientView(context);
+ gradient.setName("fade");
+ gradient.setX1(new DynamicFromObject("0%"));
+ gradient.setY1(new DynamicFromObject("0%"));
+ gradient.setX2(new DynamicFromObject("0%"));
+ gradient.setY2(new DynamicFromObject("100%"));
+ gradient.setGradientUnits(0);
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
+ svg.addView(gradient);
+ // Exercise recursive invalidation as well as the direct child case.
+ GroupView group = new GroupView(context);
+ RectView rectangle = new RectView(context);
+ rectangle.setX(new DynamicFromObject(0.0));
+ rectangle.setY(new DynamicFromObject(0.0));
+ rectangle.setWidth(new DynamicFromObject("100%"));
+ rectangle.setHeight(new DynamicFromObject("100%"));
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
+ group.addView(rectangle);
+ svg.addView(group);
+
+ assertFade(svg, 80, 60);
+ assertFade(svg, 160, 120);
+ assertFade(svg, 60, 40);
+ assertFade(svg, 160, 120);
+ });
+ }
+
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
+ private static final class DrawingContext extends BridgeReactContext
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
+ DrawingContext(android.content.Context context) { super(context); }
+
+ @Override
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
+ }
+
+ private static void assertFade(SvgView svg, int width, int height) {
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ svg.layout(0, 0, width, height);
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ svg.draw(new Canvas(image));
+ try {
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
+ assertTrue("Fade must end at the new bottom edge",
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
+ } finally {
+ image.recycle();
+ }
+ }
+}
-2
View File
@@ -59,8 +59,6 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",
},
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
// The native pixel regression is included in the patch; preserve the separate transform hunks.
{
nodeModulesPath: "packages/app/node_modules/react-native-svg",
patchPrefix: "react-native-svg+",