mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e635d9b46 | ||
|
|
3e859625ed | ||
|
|
83b1a3cbe2 | ||
|
|
0132f9f3e3 |
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
-35
@@ -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) } }
|
||||
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+21
-17
@@ -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] {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 とサイトデータ",
|
||||
|
||||
@@ -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: "쿠키 및 사이트 데이터",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 и данные сайтов",
|
||||
|
||||
@@ -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 和网站数据",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user