Add first-run setup that walks new users through Paseo (#8363)

* feat(app): add first-run setup modal

New users landed on an empty "Add a project" screen and never found
providers, Paseo tools or phone pairing, which all live in Settings. A
one-time setup modal now walks them through Appearance, Providers,
Projects, Orchestration and Phone. Every step renders the same component
Settings uses, so changing something in setup changes it in Settings too.

The modal opens only when the setup host is connected, its workspaces have
loaded and it has no projects. Existing users already have projects, so
they never see it after upgrading and no migration is needed. Finishing or
skipping writes `@paseo:onboarding` on this client. It uses its own key
because it records feature state, and e2e specs replace app settings
wholesale. Settings > General > "Show setup again" reopens the modal on
step 1. Native phones skip the modal for now.

Shared pieces moved out so setup and Settings render the same thing:
- ThemePicker is a tile grid and replaces the Settings theme dropdown.
- PaseoToolsCard is extracted from the host page.
- The skeleton pulse moves to components/ui. Provider rows show it while
  the first snapshot loads instead of "Loading…".
- The pair-device direct-connection hint links to the Tailscale docs.

The e2e fixture marks setup completed by default so existing specs keep
their starting state. The desktop smoke and lifecycle scripts, which start
from a fresh daemon, mark it before they continue.

* feat(onboarding): suggest projects from agent history

The setup's Projects step lists the repos the user already ran agents in,
with busy recent ones preselected, so a new user adds their projects in one
click instead of browsing for folders. With rows checked, the footer's main
button adds them and only moves on once every one was added; a checked row
always means "this will be added".

The daemon resolves each session folder to its project root (main checkout
for worktrees, repo root for subfolders) because only it can read the git
state; the app asks once per provider so a slow or broken provider never
holds up the others and can be retried on its own. Hosts without the
projectSuggestions feature show only the Add project button.

Onboarding e2e hosts read an empty Claude history with other providers
off, so suggestions never depend on the machine running the tests.

* fix(onboarding): keep late project adds on their step and show Retry early

- A finished add only advances setup while the Projects step is still open.
- A failed provider shows its Retry as soon as any provider answers.
- The desktop lifecycle check no longer reloads the window: setup never opens
  there because no host is connected.

* fix(onboarding): bind late project adds to their step visit and freeze rows while adding

- Each step visit has an id; a finished add only advances the visit that started it,
  so leaving and returning to Projects, or reopening setup, ignores it.
- Suggestion rows are disabled while their batch is being added.

* test(app): drive theme selection through the tile picker

Settings → Appearance no longer has a theme dropdown. The Escape test opens the
syntax theme dropdown instead, and the Android system-bars check taps theme tiles.

* fix(server): return project suggestion roots in the form project.add stores

Git reports Windows repo roots with forward slashes, so an added suggestion kept
showing because its path never matched the new project's root.

* feat(app): two control sizes, orchestration switches and mobile connect choices in setup

Controls had four sizes picked per call site, so a button in a dialog, a
settings row and a setup step came out at different heights. Controls now
have two: md (default; 32px with a pointer, 44px in the compact layout so
phone controls are full touch targets) and sm (28px, for tight chrome and
nested settings rows). The breakpoint picks md's height, not the caller.

Setup:
- Orchestration is three switches (Paseo tools, agent skills, CLI), all on,
  applied on Continue. Turning one off undoes it; the desktop CLI uninstall
  removes only the file this app installed.
- The Mobile step has store buttons and a "Choose how to connect" section:
  Relay opens the existing pairing modal, Tailscale opens the docs.

Settings section docs links are one pattern, SettingsSection's `link` prop,
and modals sit on surface0 like a screen.

The vitest unistyles stub now resolves breakpoint values by window width,
as Unistyles does at runtime; without it md controls had no height in
browser tests.

* fix(app): finish setup on its last step and confirm undoing orchestration

In the compact layout Orchestration is the last step, and Finish closed
setup without applying its switches. The last step's work now runs before
Finish, the same as Continue.

Turning off skills now asks first, as Settings does. Removing the CLI keeps
a paseo command this app didn't install and says so. A failed Paseo tools
update shows a toast. Adding suggested projects no longer advances when a
row was checked while the add ran.

Chat sender attribution and the message preview toggle go back to the sm
button: md padding is a breakpoint value, and on web its media-query rule
beat the zero right padding that aligns the sender with its bubble.

* fix(app): ignore a late Finish once the user left the last setup step

Finish called complete(), which ignored the step visit, so pressing Back
while Orchestration applied its choices still closed setup and recorded it
as done. next(visit) now finishes setup from the last step, so a late
Finish is dropped the same way a late Continue already was.

* fix(app): hold the skills switch while setup applies its choices

The Paseo tools and CLI switches were disabled while Continue or Finish
applied them, but the skills switch only locked while a skills operation
ran. With skills needing no change, it stayed editable during a pending
tools update and the new value was silently dropped.

* fix(app): read setup's skills choice as off when no skill is picked

Saving an empty selection in the skills picker left setup's skills switch
on. Continue then asked the host to install nothing, the host reported
skills as not installed, and the step stayed put with no error. Setup now
reads skills as off with nothing picked, as Settings does.
This commit is contained in:
Mohamed Boudra
2026-11-03 10:28:26 +07:00
committed by GitHub
parent 6f6712be62
commit 336e7d1b89
166 changed files with 5190 additions and 1525 deletions
+9 -9
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -36,7 +36,7 @@ Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -68,9 +68,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Both sit on `surface0`, the same base as a screen, so settings cards and inputs inside a modal read the way they do on the Settings screen. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -126,7 +126,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
@@ -197,11 +197,11 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
@@ -251,7 +251,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
## 14. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `fontWeight.medium` on row titles, body text, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
@@ -9,9 +9,11 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
await themeTrigger.click();
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
await expect(
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
).toHaveAccessibleName("System");
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("appearance-theme-picker.png"),
fullPage: true,
+311
View File
@@ -0,0 +1,311 @@
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,
hostGivesAgentsPaseoTools,
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
skipSetup,
startSetupHost,
} from "../support/helpers/onboarding";
import {
chooseCustomSkills,
openSkillSelection,
saveSkillSelection,
} from "../support/helpers/agent-skills";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
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("Let's start by picking a look", { exact: true }),
).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 starts with its switches on", async () => {
await continueSetup(page);
await expectSetupStep(page, "Let agents use Paseo");
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
await expect(
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
).toBeChecked();
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, scratchName)).toBeChecked();
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
});
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
timeout: 60_000,
});
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
});
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 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(projectSuggestionRow(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 start on in setup and Continue turns them on", async () => {
await goToSetupStep(page, "Orchestration");
await expectSetupStep(page, "Let agents use Paseo");
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
await continueSetup(page);
});
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
await expectSetupStep(page, "Take your agents with you");
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
expect(await openTailscaleDocsFromSetup(page)).toBe(
"https://paseo.sh/docs/connectivity#tailscale",
);
const pairing = await openRelayPairingFromSetup(page);
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
await enableRelayAndExpectOffer(page);
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
await closeRelayPairing(pairing);
});
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();
}
});
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
const host = await startSetupHost("no-skills");
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await goToSetupStep(page, "Orchestration");
await expectSetupStep(page, "Let agents use Paseo");
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
const skillsSwitch = setupModal(page).getByRole("switch", {
name: "Orchestration skills",
exact: true,
});
await expect(skillsSwitch).not.toBeChecked();
await continueSetup(page);
await expectSetupStep(page, "Take your agents with you");
} finally {
await host.close();
}
});
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const host = await startSetupHost("compact");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
// The compact sheet is the only surface on screen, and Orchestration is its last step.
const step = (title: string) =>
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
});
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
try {
await openAppAsNewUser(page, host);
await step("Welcome to Paseo");
await primary("Continue").click();
await step("Your agents");
await primary("Continue").click();
await step("Add your projects");
await primary("Continue").click();
// The setup host starts with Paseo tools off; the switch starts on.
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
await step("Let agents use Paseo");
await expect(paseoToolsSwitch(page)).toBeChecked();
// Skills are not installed, so leaving them off has nothing to apply.
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
await skillsSwitch.click();
await expect(skillsSwitch).not.toBeChecked();
gate.holdNextClientRequest("set_daemon_config_request");
await primary("Finish").click();
// While the choices apply, none of them can change.
await gate.waitForHeldClientRequest();
await expect(paseoToolsSwitch(page)).toBeDisabled();
await expect(skillsSwitch).toBeDisabled();
gate.releaseHeldClientRequest();
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
} finally {
await host.close();
}
});
+13 -24
View File
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -69,8 +68,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
@@ -79,14 +77,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
});
await test.step("a contributed light theme uses the light palette", async () => {
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
await latteItem.click();
await expect(latteItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(mochaItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
@@ -95,15 +93,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await test.step("the selection survives a reload", async () => {
await page.reload();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
});
await test.step("removing the plugin falls back to the default theme", async () => {
await client.removePlugin(PLUGIN_ID);
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -147,15 +143,7 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
return () => {};
}`;
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
test("reaches the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
@@ -175,15 +163,16 @@ test("scrolls to the last theme when a plugin contributes more themes than fit",
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
await lastTheme.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
await lastTheme.click();
await expect(lastTheme).toBeChecked();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
@@ -326,12 +326,12 @@ test.describe("Settings sidebar navigation", () => {
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.getByLabel(/Highlight theme:/).click();
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
@@ -38,12 +38,9 @@ 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");
+13 -2
View File
@@ -107,6 +107,8 @@ const daemonTest = metroTest.extend<
});
const test = daemonTest.extend<{
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
e2eOnboardingCompleted: boolean;
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
@@ -114,8 +116,9 @@ const test = daemonTest.extend<{
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
e2eOnboardingCompleted: [true, { option: true }],
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -157,7 +160,7 @@ const test = daemonTest.extend<{
const createAgentPreferences = buildCreateAgentPreferences();
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -185,12 +188,20 @@ const test = daemonTest.extend<{
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
// The shared daemon often has no projects, which would open first-run setup over the app.
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
localStorage.setItem(
"@paseo:onboarding",
JSON.stringify({ completedAt: new Date(0).toISOString() }),
);
}
},
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
onboardingCompleted: e2eOnboardingCompleted,
},
);
@@ -154,7 +154,7 @@ export async function chooseCustomSkills(page: Page, selected: string[]): Promis
await all.click();
const keep = new Set(selected);
for (const name of await listSkillChoices(page)) {
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
if (!keep.has(name)) await skillSwitch(page, name).click();
}
await expectSkillChoices(page, selected);
}
@@ -176,7 +176,7 @@ export async function cancelSkillSelection(page: Page): Promise<void> {
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
await skillSwitch(page, name).click();
}
export function answerNextRemovalWarning(
@@ -222,15 +222,21 @@ export async function expectSkillFile(
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
const expected = new Set(selected);
for (const name of await listSkillChoices(page)) {
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
await expect(skillSwitch(page, name)).toBeChecked({
checked: expected.has(name),
});
}
}
/** One bundled skill's switch in the picker; "All skills" sits outside the list. */
function skillSwitch(page: Page, name: string) {
return page.getByTestId("skill-selection-list-card").getByRole("switch", { name, exact: true });
}
async function listSkillChoices(page: Page): Promise<string[]> {
return page
.getByRole("checkbox")
.getByTestId("skill-selection-list-card")
.getByRole("switch")
.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("aria-label") ?? "")
@@ -1,5 +1,5 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
export interface DirectoryBootstrapCounts {
agents: number;
@@ -294,7 +294,8 @@ function recordClientRequest(
directoryStarts.total[directory] += 1;
}
export async function installDaemonWebSocketGate(page: Page) {
/** Gates the shared E2E daemon, or the daemon on `port` for a spec that runs its own. */
export async function installDaemonWebSocketGate(page: Page, options: { port?: number } = {}) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
@@ -445,7 +446,11 @@ export async function installDaemonWebSocketGate(page: Page) {
return true;
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const routePattern =
options.port === undefined
? daemonWsRoutePattern()
: wsRoutePatternForPort(String(options.port));
await page.routeWebSocket(routePattern, (ws) => {
if (!acceptingConnections) {
blockedConnectionCount += 1;
for (const resolve of blockedConnectionWaiters) resolve();
@@ -25,6 +25,8 @@ 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;
@@ -93,6 +95,28 @@ 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 = {},
@@ -138,24 +162,13 @@ export async function startIsolatedHostDaemon(
throw error;
}
}
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");
const config = options.mutableRelay
? withMutableRelay(options.config ?? {}, options.mutableRelay)
: options.config;
if (config) {
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
`${JSON.stringify({ version: 1, ...config })}\n`,
);
}
const serverDir = publishedPackageRoot
@@ -0,0 +1,230 @@
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 type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
import { connectDaemonClient } from "./daemon-client-loader";
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.getByTestId("onboarding-modal").getByRole("dialog");
}
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 });
}
/** Whether the host gives its agents Paseo tools, read from the host itself. */
export async function hostGivesAgentsPaseoTools(host: { port: number }): Promise<boolean> {
const client = await connectDaemonClient<
Pick<InternalDaemonClient, "connect" | "close" | "getDaemonConfig">
>({ clientIdPrefix: "app-e2e-onboarding", port: host.port });
try {
return (await client.getDaemonConfig()).config.mcp.injectIntoAgents !== false;
} finally {
await client.close();
}
}
export async function finishSetup(page: Page): Promise<void> {
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
await expect(setupModal(page)).toHaveCount(0);
}
/** Opens the Phone step's Relay row: the pair device modal, over setup. */
export async function openRelayPairingFromSetup(page: Page): Promise<Locator> {
await setupModal(page).getByRole("button", { name: "Relay", exact: true }).click();
const pairing = page.getByTestId("onboarding-pair-device-card");
await expect(pairing).toBeVisible();
return pairing;
}
export async function closeRelayPairing(pairing: Locator): Promise<void> {
await pairing.getByRole("button", { name: "Close", exact: true }).click();
await expect(pairing).toHaveCount(0);
}
/** Clicks the Phone step's Tailscale row and returns the docs 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: "Tailscale, Docs", exact: true }).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. Its HOME is a scratch folder, so the skills setup
* installs never land in the real ~/.agents, ~/.claude or ~/.codex. `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 home = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-home-"));
const daemon = await startIsolatedHostDaemon(serverId, {
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
mutableRelay: options.mutableRelay,
});
return {
serverId,
port: daemon.port,
close: async () => {
await daemon.close();
await rm(claudeConfigDir, { recursive: true, force: true });
await rm(home, { 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 : {}),
},
},
};
}
/** The switch that picks a suggested project for adding. */
export function projectSuggestion(page: Page, name: string): Locator {
return setupModal(page).getByRole("switch", { name, exact: true });
}
/** The whole suggestion row, with its path, last use and any add error beside the switch. */
export function projectSuggestionRow(page: Page, name: string): Locator {
return setupModal(page)
.getByTestId("project-suggestion")
.filter({ has: page.getByRole("switch", { 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 });
}
@@ -8,7 +8,7 @@ import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { identityForeground } from "@/styles/identity-colors";
@@ -153,7 +153,7 @@ export function AgentProfileAppearanceField({
icon: string;
color: string;
onChange: (next: { icon: string; color: string }) => void;
size?: FieldControlSize;
size?: ControlSize;
disabled?: boolean;
testID?: string;
triggerTestID?: string;
@@ -6,7 +6,7 @@ import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { type FieldControlSize } from "@/components/ui/control-geometry";
import { type ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
SelectField,
@@ -14,7 +14,6 @@ import {
type SelectFieldOption,
} from "@/components/ui/select-field";
import { Switch } from "@/components/ui/switch";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
import type {
@@ -133,7 +132,7 @@ function OpenAgentProfileEditModal({
onSave,
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
const { t } = useTranslation();
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const controlSize: ControlSize = "md";
const snapshot = useMemo(
() => ({ mode, ...(profile ? { profile } : {}), ...(seed ? { seed } : {}) }),
[mode, profile, seed],
@@ -410,7 +409,7 @@ function AgentProfileFeatureRow({
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: FieldControlSize;
size: ControlSize;
model: AgentProfileFormModel;
}): ReactElement {
const rowStyle = useMemo(
@@ -103,7 +103,6 @@ export function AgentProfileRow({
<View style={styles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
@@ -112,7 +111,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
@@ -121,7 +119,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={editIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
@@ -129,7 +126,6 @@ export function AgentProfileRow({
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.agentProfiles.remove")}
@@ -124,7 +124,6 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addIcon}
onPress={handleAddOpen}
disabled={!profiles}
@@ -181,7 +180,7 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
<Text style={styles.emptyText} testID="agent-profiles-empty">
{t("settings.host.agentProfiles.emptyState")}
</Text>
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
<Button leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
{t("settings.host.agentProfiles.newProfile")}
</Button>
</View>
+129 -169
View File
@@ -3,29 +3,27 @@ import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import { Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { Switch } from "@/components/ui/switch";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { SkillSelectionSheet } from "./selection-sheet";
import { useAgentSkills } from "./use-agent-skills";
import { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
export { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
const OP_KIND_ORDER: Record<AgentSkillOperation["kind"], number> = {
add: 0,
update: 1,
delete: 2,
};
const ThemedBlocks = withUnistyles(Blocks);
const ThemedCheck = withUnistyles(Check);
const ThemedSettings = withUnistyles(Settings2);
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string {
@@ -35,91 +33,50 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction):
.join("\n");
}
/** Whether the host has Paseo's skills installed in any form, current or not. */
export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean {
return status !== null && status.state !== "not-installed";
}
/** Whether the saved selection names any skill there is to install. */
export function hasSelectedSkills(status: AgentSkillsStatus | null): boolean {
if (!status) return false;
if (status.selection.mode === "all") return status.available.length > 0;
return status.selection.skills.some((name) => status.available.includes(name));
}
export function AgentSkillsSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const skills = useAgentSkills(serverId);
const refreshSkills = skills.refresh;
const skillsConnected = skills.connected;
const skillsSupported = skills.supported;
const [isChoosing, setIsChoosing] = useState(false);
const { refresh, connected, supported, reconcile, uninstall } = skills;
useFocusEffect(
useCallback(() => {
if (skillsConnected && skillsSupported) void refreshSkills();
if (connected && supported) void refresh();
return undefined;
}, [refreshSkills, skillsConnected, skillsSupported]),
}, [refresh, connected, supported]),
);
const maintenanceOps = useMemo(
() => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [],
[skills.status?.ops],
const handleValueChange = useCallback(
(next: boolean) => {
void (next ? reconcile() : uninstall());
},
[reconcile, uninstall],
);
const state =
skills.status?.state === "drift" && maintenanceOps.length === 0
? "up-to-date"
: (skills.status?.state ?? null);
const hasSelected =
(skills.status?.selection.mode === "all" && skills.status.available.length > 0) ||
(skills.status?.selection.mode === "custom" &&
skills.status.selection.skills.some((name) => skills.status?.available.includes(name)));
const handleReconcile = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.updateTitle"),
message:
maintenanceOps.length > 0
? formatUpdateMessage(maintenanceOps, t)
: t("settings.host.skills.updateFallback"),
confirmLabel: t("settings.host.skills.actions.update"),
});
if (confirmed) await skills.reconcile();
}, [maintenanceOps, skills, t]);
const handleUninstall = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.uninstallTitle"),
message: t("settings.host.skills.uninstallMessage"),
confirmLabel: t("settings.host.skills.actions.uninstall"),
destructive: true,
});
if (confirmed) await skills.uninstall();
}, [skills, t]);
const chooseIcon = useMemo(
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const handleOpen = useCallback(() => setIsChoosing(true), []);
const handleClose = useCallback(() => setIsChoosing(false), []);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(SKILLS_DOCS_URL);
}, []);
const docsIcon = useMemo(
() => <ThemedArrowUpRight size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const trailing = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.host.skills.openDocs")}
>
{t("settings.host.skills.docs")}
</Button>
),
[docsIcon, handleOpenDocs, t],
const docsLink = useMemo(
() => ({
href: SKILLS_DOCS_URL,
label: t("settings.host.skills.docs"),
accessibilityLabel: t("settings.host.skills.openDocs"),
}),
[t],
);
if (!skills.connected || !skills.supported) {
if (!connected || !supported) {
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
<View style={settingsStyles.card} testID="host-agent-skills-unavailable">
<View style={styles.emptyCard}>
<Text style={styles.mutedText}>
{skills.connected
{connected
? t("settings.host.skills.unsupported")
: t("settings.host.skills.unavailable")}
</Text>
@@ -130,109 +87,112 @@ export function AgentSkillsSection({ serverId }: { serverId: string }) {
}
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<View style={settingsStyles.card} testID="host-agent-skills-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<ThemedBlocks size={ICON_SIZE.md} uniProps={foregroundMapping} />
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{state === "drift"
? t("settings.host.skills.updateAvailable")
: t("settings.host.skills.description")}
</Text>
</View>
<View style={styles.actionsRow}>
<Button
variant="ghost"
size="sm"
leftIcon={chooseIcon}
onPress={handleOpen}
disabled={skills.status === null || skills.isWorking}
accessibilityLabel={t("settings.host.skills.choose")}
/>
{hasSelected ? (
<SkillsActions
state={state}
isWorking={skills.isWorking}
onInstall={skills.reconcile}
onReconcile={handleReconcile}
onUninstall={handleUninstall}
/>
) : null}
</View>
</View>
</View>
{skills.status ? (
<SkillSelectionSheet
visible={isChoosing}
available={skills.status.available}
selection={skills.status.selection}
isSaving={skills.isWorking}
onSave={skills.saveSelection}
onClose={handleClose}
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
<SettingsCard testID="host-agent-skills-card">
<AgentSkillsRow
skills={skills}
value={areSkillsInstalled(skills.status)}
onValueChange={handleValueChange}
/>
) : null}
</SettingsCard>
</SettingsSection>
);
}
function SkillsActions({
state,
isWorking,
onReconcile,
onInstall,
onUninstall,
/**
* The orchestration skills row: a switch for whether they are installed, a gear that opens the
* skill picker, and Update when the installed skills fall behind the bundled ones. The caller
* decides what the switch is bound to; the picker and Update always act on the host.
*/
export function AgentSkillsRow({
skills,
value,
onValueChange,
disabled = false,
}: {
state: AgentSkillsStatus["state"] | null;
isWorking: boolean;
onReconcile: () => void;
onInstall: () => void;
onUninstall: () => void;
skills: AgentSkillsController;
value: boolean;
onValueChange: (next: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<ThemedCheck size={14} uniProps={mutedMapping} />
<Text style={styles.mutedText}>{t("settings.host.skills.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
const { status, isWorking, reconcile, saveSelection } = skills;
const [isChoosing, setIsChoosing] = useState(false);
// Deleting a deselected skill is a choice made in the picker, not an update to offer here.
const maintenanceOps = useMemo(
() => status?.ops.filter((op) => op.kind !== "delete") ?? [],
[status?.ops],
);
const needsUpdate = status?.state === "drift" && maintenanceOps.length > 0;
const handleUpdate = useCallback(async () => {
if (isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.updateTitle"),
message: formatUpdateMessage(maintenanceOps, t),
confirmLabel: t("settings.host.skills.actions.update"),
});
if (confirmed) await reconcile();
}, [isWorking, maintenanceOps, reconcile, t]);
const handleOpen = useCallback(() => setIsChoosing(true), []);
const handleClose = useCallback(() => setIsChoosing(false), []);
const chooseIcon = useMemo(
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.installing")
: t("settings.host.skills.actions.install")}
</Button>
<>
<SettingsRow
label={t("settings.host.skills.title")}
hint={
needsUpdate
? t("settings.host.skills.updateAvailable")
: t("settings.host.skills.description")
}
>
<View style={styles.controls}>
{needsUpdate ? (
<Button variant="outline" onPress={handleUpdate} disabled={disabled || isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
) : null}
<Button
variant="ghost"
leftIcon={chooseIcon}
onPress={handleOpen}
disabled={disabled || status === null || isWorking}
accessibilityLabel={t("settings.host.skills.choose")}
/>
<Switch
value={value}
onValueChange={onValueChange}
// Nothing picked means nothing to install; the gear is the way forward.
disabled={
disabled || status === null || isWorking || (!value && !hasSelectedSkills(status))
}
accessibilityLabel={t("settings.host.skills.title")}
/>
</View>
</SettingsRow>
{status ? (
<SkillSelectionSheet
visible={isChoosing}
available={status.available}
selection={status.selection}
isSaving={isWorking}
onSave={saveSelection}
onClose={handleClose}
/>
) : null}
</>
);
}
const styles = StyleSheet.create((theme) => ({
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
controls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
actionsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
emptyCard: { padding: theme.spacing[4] },
}));
@@ -1,23 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import type {
AgentSkillSelection as SkillSelection,
AgentSkillsSaveResult as SkillsSaveResult,
} from "@getpaseo/protocol/messages";
const ThemedCheck = withUnistyles(Check);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
interface SkillSelectionSheetProps {
visible: boolean;
available: readonly string[];
@@ -157,12 +152,11 @@ export function SkillSelectionSheet({
footer={footer}
testID="skill-selection-sheet"
>
<View style={settingsStyles.card} testID="skill-selection-all-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.chooseAll")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseAllHint")}</Text>
</View>
<SettingsCard testID="skill-selection-all-card">
<SettingsRow
label={t("settings.host.skills.chooseAll")}
hint={t("settings.host.skills.chooseAllHint")}
>
<Switch
value={allSkills}
onValueChange={handleToggleAll}
@@ -170,30 +164,29 @@ export function SkillSelectionSheet({
accessibilityLabel={t("settings.host.skills.chooseAll")}
testID="skill-selection-all"
/>
</View>
</View>
</SettingsRow>
</SettingsCard>
{/* The sheet body already spaces its blocks apart, so the section owns
only the gap between its label and the list the label names. */}
<SettingsSection title={t("settings.host.skills.chooseList")} flush>
<View style={settingsStyles.card} testID="skill-selection-list-card">
<SettingsCard testID="skill-selection-list-card">
{available.length === 0 ? (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseEmpty")}</Text>
</View>
) : (
available.map((name, index) => (
<SkillCheckboxRow
available.map((name) => (
<SkillRow
key={name}
name={name}
checked={isSkillSelected(draft, name)}
disabled={allSkills || isSaving}
withBorder={index > 0}
onToggle={handleToggleSkill}
/>
))
)}
</View>
</SettingsCard>
</SettingsSection>
{saveError ? <Text style={settingsStyles.rowError}>{saveError}</Text> : null}
@@ -201,52 +194,27 @@ export function SkillSelectionSheet({
);
}
interface SkillCheckboxRowProps {
name: string;
checked: boolean;
disabled: boolean;
withBorder: boolean;
onToggle: (name: string) => void;
}
function SkillCheckboxRow({
function SkillRow({
name,
checked,
disabled,
withBorder,
onToggle,
}: SkillCheckboxRowProps) {
const handlePress = useCallback(() => onToggle(name), [name, onToggle]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const rowStyle = useMemo(
() => [
settingsStyles.row,
withBorder ? settingsStyles.rowBorder : null,
styles.checkboxRow,
disabled ? styles.disabledRow : null,
],
[disabled, withBorder],
);
const boxStyle = useMemo(
() => [styles.checkbox, checked ? styles.checkboxChecked : null],
[checked],
);
}: {
name: string;
checked: boolean;
disabled: boolean;
onToggle: (name: string) => void;
}) {
const handleValueChange = useCallback(() => onToggle(name), [name, onToggle]);
return (
<Pressable
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={name}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={boxStyle}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{name}</Text>
</Pressable>
<SettingsRow label={name}>
<Switch
value={checked}
onValueChange={handleValueChange}
disabled={disabled}
accessibilityLabel={name}
/>
</SettingsRow>
);
}
@@ -259,24 +227,4 @@ const styles = StyleSheet.create((theme) => ({
footerButton: {
flex: 1,
},
checkboxRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
disabledRow: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
}));
@@ -10,6 +10,9 @@ import { useToast } from "@/contexts/toast-context";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { confirmDialog } from "@/utils/confirm-dialog";
export type AgentSkillsController = ReturnType<typeof useAgentSkills>;
export function useAgentSkills(serverId: string) {
const { t } = useTranslation();
@@ -83,12 +86,30 @@ export function useAgentSkills(serverId: string) {
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const runReconcile = useCallback(async () => {
await reconcileAsync().catch(() => undefined);
}, [reconcileAsync]);
// Both resolve to whether the host ended in the asked-for state; failures are already toasted.
const runReconcile = useCallback(
() =>
reconcileAsync().then(
(status) => status.state !== "not-installed",
() => false,
),
[reconcileAsync],
);
// Uninstalling deletes whole skill folders, including anything the user added inside them, so
// it always asks first. Declining resolves false, as a failure does.
const runUninstall = useCallback(async () => {
await uninstallAsync().catch(() => undefined);
}, [uninstallAsync]);
const confirmed = await confirmDialog({
title: t("settings.host.skills.uninstallTitle"),
message: t("settings.host.skills.uninstallMessage"),
confirmLabel: t("settings.host.skills.actions.uninstall"),
destructive: true,
});
if (!confirmed) return false;
return uninstallAsync().then(
(status) => status.state === "not-installed",
() => false,
);
}, [t, uninstallAsync]);
const saveSelection = useCallback(
(selection: AgentSkillSelection, confirmedRemovals: readonly string[] = []) =>
saveAsync({ selection, confirmedRemovals }),
@@ -147,7 +147,7 @@ export function ChatFind({
{state.failure && (
<View style={styles.error}>
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
<Button size="sm" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
</View>
+2
View File
@@ -36,6 +36,7 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { OnboardingHost } from "@/onboarding";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -496,6 +497,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -0,0 +1,292 @@
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_ASPECT_RATIO = 16 / 9;
/**
* 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 (
<View style={styles.cell}>
<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>
</View>
);
}
/** 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) => ({
// Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every
// cell plus the negative margin here leaves exactly one gap between neighbors.
grid: {
flexDirection: "row",
flexWrap: "wrap",
margin: -theme.spacing[3] / 2,
},
cell: {
width: { xs: "50%", md: "25%" },
padding: theme.spacing[3] / 2,
},
tile: {
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: "100%",
aspectRatio: PREVIEW_ASPECT_RATIO,
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: "50%",
width: "50%",
overflow: "hidden",
},
// A full-width dark preview shifted left by half, so only its right half shows.
systemDarkOffset: {
width: "200%",
marginLeft: "-100%",
},
}));
@@ -118,7 +118,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
description={t("changelog.error.description")}
testID="changelog-error"
>
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
<Button variant="outline" onPress={onRetry} testID="changelog-retry">
{t("common.actions.retry")}
</Button>
</Alert>
@@ -99,7 +99,7 @@ const styles = StyleSheet.create((theme) => ({
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.surface2,
@@ -140,6 +140,12 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
},
// Without a header the close button floats over the card's top-right corner.
floatingClose: {
position: "absolute",
top: theme.spacing[4],
right: theme.spacing[4],
},
searchRow: {
flexDirection: "row",
alignItems: "center",
@@ -493,8 +499,27 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
);
}
/**
* The sheet's action row: a top border, the sheet's side inset, and children spread across it.
* The sheet draws it for `footer`; a body that splits into panes renders it at the bottom of one.
*/
export function SheetFooter({
style,
children,
}: {
style?: StyleProp<ViewStyle>;
children: ReactNode;
}) {
return <View style={[styles.footer, style]}>{children}</View>;
}
export interface AdaptiveModalSheetProps {
header: SheetHeader;
/**
* The title row with the close button. Leave it out when the body draws its own chrome, such as
* a full-height side rail: the desktop card then floats the close button over its top-right
* corner, and the compact sheet closes from its handle.
*/
header?: SheetHeader;
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
@@ -534,7 +559,7 @@ function CompactSheetBody({
contentStyle,
children,
}: {
header: SheetHeader;
header?: SheetHeader;
onClose: () => void;
testID?: string;
footer: ReactNode;
@@ -564,7 +589,7 @@ function CompactSheetBody({
);
return (
<SheetVisibleFrame footer={footerView}>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -612,7 +637,7 @@ export function AdaptiveModalSheet({
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const footerView = useMemo(
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
() => (footer ? <SheetFooter style={footerContainerStyle}>{footer}</SheetFooter> : null),
[footer, footerContainerStyle],
);
useEffect(() => {
@@ -730,7 +755,7 @@ export function AdaptiveModalSheet({
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
<SheetHeaderView header={header} onClose={onClose} />
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -746,6 +771,15 @@ export function AdaptiveModalSheet({
)}
</View>
{footerView}
{header ? null : (
<View style={styles.floatingClose}>
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
onPress={onClose}
/>
</View>
)}
</OverlayLayerProvider>
);
@@ -41,7 +41,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
@@ -27,7 +27,6 @@ export function CommunityLinks() {
<View style={styles.row}>
<Button
variant="ghost"
size="sm"
leftIcon={renderGitHubIcon}
onPress={handleOpenGitHub}
testID="community-links-github-star"
@@ -36,7 +35,6 @@ export function CommunityLinks() {
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={Heart}
onPress={handleOpenSponsor}
testID="community-links-sponsor"
@@ -45,7 +43,6 @@ export function CommunityLinks() {
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={renderDiscordIcon}
onPress={handleOpenDiscord}
testID="community-links-discord"
@@ -59,6 +56,9 @@ export function CommunityLinks() {
const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
// Shrinks and wraps rather than running off a narrow phone screen.
flexShrink: 1,
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: 0,
@@ -0,0 +1,9 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
// Same mark as the website's download buttons (packages/website/src/downloads.tsx).
const APPLE_PATH =
"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701";
export function AppleIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} path={APPLE_PATH} viewBox="0 0 24 24" />;
}
@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
/**
* The four-colour Play mark, the same one as the website's download buttons
* (packages/website/src/downloads.tsx). Brand colours are fixed, so it takes no color.
*/
export function GooglePlayIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path d="M3.27 2.17c-.2.21-.32.55-.32 1v17.66c0 .45.12.79.33 1l9.87-9.84z" fill="#00F076" />
<Path
d="m16.43 8.72-3.28 3.27 3.29 3.28 3.9-2.22c1.13-.64 1.13-1.42 0-2.06z"
fill="#FFCE00"
/>
<Path d="m13.15 11.99 3.28-3.27L4.63 2.01c-.56-.32-1.04-.28-1.36.16z" fill="#00D6FF" />
<Path d="M3.28 21.83c.32.44.8.48 1.36.16l11.8-6.72-3.29-3.28z" fill="#FF3A44" />
</Svg>
);
}
@@ -206,7 +206,7 @@ function ProviderErrorBannerRow({
</Text>
<Button
variant="ghost"
size="xs"
size="sm"
onPress={handleRetry}
testID={`import-session-retry-${row.provider}`}
>
@@ -266,7 +266,7 @@ function ScopeSubtitle({
{isScoped ? (
<Button
variant="ghost"
size="xs"
size="sm"
onPress={onShowAll}
testID="import-session-show-all"
style={styles.subtitleAction}
@@ -1235,7 +1235,7 @@ function ProviderErrorEmptyState({
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
<Button variant="default" onPress={handleRetry} disabled={isRetryingProvider}>
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
</Button>
) : null}
@@ -9,9 +9,8 @@ import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectIconView } from "@/components/project-icon-view";
import { Button } from "@/components/ui/button";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import type { ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useFilePicker } from "@/hooks/use-file-picker";
import {
@@ -50,7 +49,7 @@ export function ProjectEditSheet({
const queryClient = useQueryClient();
const toast = useToast();
const { pickFiles } = useFilePicker();
const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const size: ControlSize = "md";
const [form] = useState(() => openProjectEditForm(snapshot));
const state = useSyncExternalStore(form.subscribe, form.getState, form.getState);
@@ -96,7 +95,6 @@ export function ProjectEditSheet({
<View style={styles.footer}>
<Button
variant="secondary"
size="md"
style={styles.footerButton}
onPress={handleClose}
disabled={isSaving}
@@ -105,7 +103,6 @@ export function ProjectEditSheet({
</Button>
<Button
variant="default"
size="md"
style={styles.footerButton}
onPress={handleSubmit}
disabled={!state.canSubmit}
@@ -89,7 +89,7 @@ function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps)
</View>
</View>
<Button
size="xs"
size="sm"
variant="outline"
disabled={installing}
loading={installing}
@@ -222,10 +222,10 @@ function AddCustomModelSubSheet({
/>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
<Button variant="secondary" onPress={onClose} disabled={saving}>
{t("common.actions.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
</Button>
</View>
@@ -436,18 +436,11 @@ function renderProviderSheetFooter({
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
) : null}
<View style={actionsStyle}>
<Button
variant="secondary"
size="sm"
leftIcon={Plus}
onPress={onOpenAddSheet}
style={buttonStyle}
>
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
{t("settings.providers.models.addModel")}
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={FileText}
onPress={onOpenDiagSheet}
style={buttonStyle}
@@ -456,7 +449,6 @@ function renderProviderSheetFooter({
</Button>
<Button
variant="default"
size="sm"
leftIcon={modelsRefreshing ? undefined : RotateCw}
onPress={onRefreshModels}
disabled={modelsRefreshing}
@@ -520,7 +512,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
<View style={sheetStyles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
@@ -145,7 +145,6 @@ export function AdaptiveRenameModal({
<View style={styles.actions}>
<Button
variant="secondary"
size="sm"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
@@ -155,7 +154,6 @@ export function AdaptiveRenameModal({
</Button>
<Button
variant="default"
size="sm"
style={styles.actionButton}
onPress={handleSubmitVoid}
disabled={submitDisabled}
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useState, type ReactNode } from "reac
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import type { ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
@@ -20,7 +20,7 @@ export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
size?: ControlSize;
}
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
@@ -19,9 +19,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { ComboboxItem } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry";
import { createControlGeometry, type ControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { Switch } from "@/components/ui/switch";
import { useProviderIcon } from "@/components/provider-icons";
@@ -239,7 +238,7 @@ function OpenScheduleFormSheet({
mode,
schedule,
}: ScheduleFormSheetProps & { onDismiss: () => void }): ReactElement {
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const controlSize: ControlSize = "md";
const { projects } = useProjects();
const hostProfiles = useHosts();
const hosts = useStoreWithEqualityFn(
@@ -460,7 +459,7 @@ interface ScheduleFormFieldsProps {
state: ScheduleFormState;
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
agentTargetLabel: string | null;
controlSize: FieldControlSize;
controlSize: ControlSize;
cadenceError: string | null;
mutationServerId: string;
}
@@ -557,7 +556,7 @@ interface ScheduleTargetFieldsProps {
state: ScheduleFormState;
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
agentTargetLabel: string | null;
controlSize: FieldControlSize;
controlSize: ControlSize;
mutationServerId: string;
}
@@ -819,7 +818,7 @@ function ScheduleIsolationField({
}: {
model: ScheduleFormModel;
state: ScheduleFormState;
size: FieldControlSize;
size: ControlSize;
}): ReactElement {
const options = useMemo<SelectFieldOption<"local" | "worktree">[]>(
() => [
@@ -892,7 +891,7 @@ function ScheduleAgentTargetField({
size,
}: {
label: string | null;
size: FieldControlSize;
size: ControlSize;
}): ReactElement {
const fieldStyle = useMemo(
() => [styles.readonlyField, size === "sm" ? styles.readonlyFieldSm : styles.readonlyFieldMd],
@@ -1,12 +1,19 @@
import type { ReactNode } from "react";
import { SettingsRowHeader } from "./row-header";
interface SettingsDetailRowProps {
label: string;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
/** What the detail is set to, so the row says it without being opened. */
summary?: string;
onPress: () => void;
/** A row that cannot be opened keeps its summary and drops the chevron. */
disabled?: boolean;
/** Opens a page outside the app; an outward arrow replaces the chevron. */
external?: boolean;
testID?: string;
}
@@ -14,20 +21,6 @@ interface SettingsDetailRowProps {
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
*/
export function SettingsDetailRow({
label,
summary,
onPress,
disabled,
testID,
}: SettingsDetailRowProps) {
return (
<SettingsRowHeader
label={label}
summary={summary}
onPress={onPress}
disabled={disabled}
testID={testID}
/>
);
export function SettingsDetailRow(props: SettingsDetailRowProps) {
return <SettingsRowHeader {...props} />;
}
@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
import { SettingsInfoTip } from "./settings-info-tip";
import { settingsStyles } from "@/styles/settings";
@@ -11,6 +12,12 @@ interface SettingsSectionProps {
* paragraph between the header and the card is wrong (docs/design.md §7).
*/
info?: ReactNode;
/**
* A page outside the app, such as the docs, shown at the header's end as "Label ↗". Its glyph
* sits as far inside the card's edge as the title does on the other side.
*/
link?: Pick<ExternalLinkProps, "href" | "label" | "accessibilityLabel" | "tooltip" | "testID">;
/** An action at the header's end, such as Refresh or Add. A link to a page is `link`. */
trailing?: ReactNode;
testID?: string;
style?: StyleProp<ViewStyle>;
@@ -30,6 +37,7 @@ interface SettingsSectionProps {
export function SettingsSection({
title,
info,
link,
trailing,
testID,
style,
@@ -53,6 +61,11 @@ export function SettingsSection({
/>
) : null}
</View>
{link ? (
<View style={styles.link}>
<ExternalLink {...link} />
</View>
) : null}
{trailing}
</View>
<View style={styles.content}>{children}</View>
@@ -69,6 +82,10 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
// Mirrors the header's marginLeft, so the link's glyph and the title's sit on matching rails.
link: {
marginRight: theme.spacing[1],
},
titleRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,8 +1,8 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import type { ContainedControlSize } from "@/components/ui/control-size";
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
@@ -19,7 +19,7 @@ export interface SettingsRowLayout {
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
controlSize: ContainedControlSize;
controlSize: ControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
@@ -32,24 +32,24 @@ export function useSettingsRowLayout(): SettingsRowLayout {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "xs",
controlSize: "sm",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "sm",
controlSize: "md",
},
[nested],
);
}
const styles = StyleSheet.create((theme) => ({
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
@@ -1,7 +1,8 @@
import { ChevronRight } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLinkLabel } from "@/components/ui/external-link";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { settingsStyles } from "@/styles/settings";
@@ -15,12 +16,18 @@ type HoverState = PressableStateCallbackType & { hovered?: boolean };
export interface SettingsRowHeaderProps {
label: string;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
/** A short status shown before the chevron. */
summary?: string;
onPress: () => void;
disabled?: boolean;
/** Turns the chevron down, for a row whose children are revealed in place. */
expanded?: boolean;
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
external?: boolean;
accessibilityState?: AccessibilityState;
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
webAccessibilityProps?: Record<string, unknown> | null;
@@ -34,10 +41,13 @@ export interface SettingsRowHeaderProps {
*/
export function SettingsRowHeader({
label,
labelAccessory,
hint,
summary,
onPress,
disabled,
expanded = false,
external = false,
accessibilityState,
webAccessibilityProps,
testID,
@@ -58,7 +68,7 @@ export function SettingsRowHeader({
return (
<PressHighlight
{...webAccessibilityProps}
accessibilityRole="button"
accessibilityRole={external ? "link" : "button"}
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityState={accessibilityState}
disabled={disabled}
@@ -70,20 +80,35 @@ export function SettingsRowHeader({
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={layout.title}>{label}</Text>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={layout.title}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={layout.title}>{label}</Text>
)}
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
</View>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
{external ? (
// Drawn the same as a section header's docs link, so the two read as one thing.
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
) : (
<>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
)}
</>
)}
</>
)}
@@ -92,6 +117,7 @@ export function SettingsRowHeader({
}
const styles = StyleSheet.create((theme) => ({
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { useMemo } from "react";
import { Animated, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconRadius } from "@/components/project-icon-view";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
const PROJECT_ICON_SIZE = 16;
@@ -11,17 +12,6 @@ const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.ma
[0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`),
);
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<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,
@@ -57,27 +47,7 @@ function SkeletonSection({
}
export function SidebarAgentListSkeleton() {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulse, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
const pulse = useSkeletonPulse();
return (
<View style={styles.container}>
@@ -56,7 +56,7 @@ export function SidebarFilterEmptyState() {
>
<Button
variant="secondary"
size="xs"
size="sm"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
@@ -84,7 +84,7 @@ export function SidebarProjectEmptyState({
>
<Button
variant="secondary"
size="xs"
size="sm"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
@@ -94,7 +94,7 @@ export function SidebarProjectEmptyState({
</Button>
<Button
variant="outline"
size="xs"
size="sm"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
+4 -4
View File
@@ -3,14 +3,14 @@ import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type ButtonControlSize,
buttonIconSize,
ALERT_ICON_SIZE,
type AlertSize,
createControlGeometry,
} from "@/components/ui/control-geometry";
import { hexColorWithAlpha } from "@/utils/color";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export type AlertSize = ButtonControlSize;
export type { AlertSize };
export interface AlertProps {
title?: string;
@@ -46,7 +46,7 @@ export function Alert({
const sized = resolveSizeStyles(size);
const containerStyle = [styles.container, sized.container, { borderColor }];
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
const iconSize = buttonIconSize[size];
const iconSize = ALERT_ICON_SIZE[size];
const resolvedIcon = useMemo(() => {
if (variant === "default") return null;
+16 -27
View File
@@ -20,14 +20,13 @@ import type {
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
buttonIconSize,
controlIconSize,
createControlGeometry,
type ButtonControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = ButtonControlSize;
type LeftIcon =
| ReactElement
@@ -102,15 +101,11 @@ const styles = StyleSheet.create((theme) => {
md: {
...geometry.buttonMd,
},
xs: {
...geometry.buttonXs,
},
sm: {
...geometry.buttonSm,
},
lg: {
...geometry.buttonLg,
},
iconOnlySm: geometry.buttonIconOnlySm,
iconOnlyMd: geometry.buttonIconOnlyMd,
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
@@ -139,11 +134,11 @@ const styles = StyleSheet.create((theme) => {
},
text: {
color: theme.colors.foreground,
...geometry.buttonText,
fontWeight: theme.fontWeight.normal,
...geometry.buttonTextMd,
fontWeight: theme.fontWeight.medium,
},
textXs: {
...geometry.buttonTextXs,
textSm: {
...geometry.buttonTextSm,
},
textDefault: {
color: theme.colors.accentForeground,
@@ -178,7 +173,7 @@ export function Button({
Omit<PressableProps, "style"> & {
ref?: React.Ref<View>;
variant?: ButtonVariant;
size?: ButtonSize;
size?: ControlSize;
leftIcon?: LeftIcon;
trailing?: ReactNode;
style?: StyleProp<ViewStyle>;
@@ -205,16 +200,9 @@ export function Button({
variantStyle = styles.destructive;
}
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
} else if (size === "sm") {
sizeStyle = styles.sm;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.md;
}
const isIconOnly = children == null && leftIcon != null;
const sizeStyle = size === "sm" ? styles.sm : styles.md;
const iconOnlyStyle = size === "sm" ? styles.iconOnlySm : styles.iconOnlyMd;
const isGhostHovered = hovered && variant === "ghost";
const handleHoverIn = useCallback(() => setHovered(true), []);
@@ -224,18 +212,19 @@ export function Button({
({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
styles.base,
sizeStyle,
isIconOnly ? iconOnlyStyle : null,
variantStyle,
pressed ? styles.pressed : null,
isDisabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, isDisabled, style],
[sizeStyle, isIconOnly, iconOnlyStyle, variantStyle, isDisabled, style],
);
const resolvedTextStyle = useMemo(
() => [
styles.text,
size === "xs" ? styles.textXs : null,
size === "sm" ? styles.textSm : null,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
@@ -276,7 +265,7 @@ export function Button({
<ThemedButtonIcon
loading={loading}
leftIcon={leftIcon}
iconSize={buttonIconSize[size]}
iconSize={controlIconSize[size]}
uniProps={resolveIconMapping()}
/>
{children != null ? (
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
buttonControlHeight,
buttonIconSize,
ALERT_ICON_SIZE,
CONTROL_HEIGHTS,
controlHeightForLayout,
createControlGeometry,
switchGeometryForLayout,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -26,6 +28,7 @@ const theme = {
xs: 10,
sm: 12,
base: 14,
lg: 16,
},
opacity: {
50: 0.5,
@@ -36,8 +39,10 @@ const theme = {
1: 4,
2: 8,
3: 12,
3.5: 14,
4: 16,
6: 24,
7: 28,
8: 32,
},
} as unknown as Theme;
@@ -81,95 +86,104 @@ describe("control geometry", () => {
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextXs.fontSize).toBe(12);
expect(geometry.fieldTextXs.lineHeight).toBe(17);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextSm.fontSize).toBe(12);
expect(geometry.fieldTextSm.lineHeight).toBe(17);
expect(geometry.fieldTextMd.fontSize).toBe(14);
expect(geometry.fieldTextMd.lineHeight).toBe(20);
expect(geometry.formTextInputSm.fontSize).toBe(14);
expect(geometry.formTextInputSm.lineHeight).toBe(20);
expect(geometry.formTextInputSm.fontSize).toBe(12);
expect(geometry.formTextInputMd.fontSize).toBe(14);
expect(geometry.formTextInputMd.lineHeight).toBe(20);
});
it("derives field padding from content and border without changing the control height", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlXs.minHeight).toBe(28);
expect(
geometry.fieldTextXs.lineHeight +
geometry.fieldControlXs.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlXs.minHeight);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(geometry.fieldControlSm.minHeight).toBe(28);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
// The default keeps a 44 touch target in the compact layout and is 32 from the md
// breakpoint up.
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
for (const layout of ["xs", "md"] as const) {
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight[layout]);
}
});
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerXs.padding).toBe(0);
expect(geometry.segmentedContainerSm.padding).toBe(0);
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentXs.borderRadius).toBe(geometry.buttonXs.borderRadius);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
});
it("keeps one size contract across buttons and segmented controls", () => {
it("keeps one size contract across buttons, fields, and segmented controls", () => {
const geometry = createControlGeometry(theme);
// xs is a genuinely smaller tier, not sm with a different font.
expect(geometry.buttonXs.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toBe(32);
expect(geometry.buttonMd.minHeight).toBe(44);
expect(geometry.buttonXs.minHeight).toBe(buttonControlHeight.xs);
expect(geometry.buttonSm.minHeight).toBe(buttonControlHeight.sm);
expect(geometry.buttonMd.minHeight).toBe(buttonControlHeight.md);
expect(geometry.buttonSm.minHeight).toBe(28);
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(controlHeightForLayout("md", true)).toBe(44);
expect(controlHeightForLayout("md", false)).toBe(32);
expect(controlHeightForLayout("sm", true)).toBe(28);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonTextSm.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(14);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonTextMd.fontSize);
// Segments sit inside a track, so they run one padding step tighter than a
// standalone button of the same size — the gap between segments reads as the padding.
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBeLessThan(
geometry.buttonXs.paddingHorizontal,
);
// Segments sit inside a track, so they run tighter than a standalone button of the same
// size — the gap between segments reads as the padding.
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBeLessThan(
geometry.buttonSm.paddingHorizontal,
);
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan(
geometry.buttonMd.paddingHorizontal,
);
for (const layout of ["xs", "md"] as const) {
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBeLessThan(
geometry.buttonMd.paddingHorizontal[layout],
);
}
});
it("sizes switches with the same two control sizes", () => {
expect(switchGeometryForLayout("md", false)).toEqual({
trackWidth: 34,
trackHeight: 20,
thumbSize: 16,
thumbTravel: 14,
reservedHeight: 32,
});
expect(switchGeometryForLayout("md", true)).toEqual({
trackWidth: 44,
trackHeight: 26,
thumbSize: 22,
thumbTravel: 18,
reservedHeight: 44,
});
// Tight chrome keeps the pointer-sized switch on every layout.
for (const isCompact of [true, false]) {
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
}
});
it("gives alert title and description one font size and a tight gap per size", () => {
@@ -188,7 +202,7 @@ describe("control geometry", () => {
const { alert } = createControlGeometry(theme);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
expect(alert[size].iconSlot.width).toBe(ALERT_ICON_SIZE[size]);
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
}
});
+106 -125
View File
@@ -1,9 +1,13 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "xs" | "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
/**
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
* `sm` is for tight chrome such as toolbars and nested rows.
*/
export type ControlSize = "sm" | "md";
/** Alerts are containers, not controls, so they keep their own density scale. */
export type AlertSize = "xs" | "sm" | "md" | "lg";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,58 +26,79 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SMALL_CONTROL_HEIGHT = 28;
// The default control keeps a full touch target in the compact layout and tightens where a
// pointer drives it.
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
export const HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
const SWITCH_TRACK_HEIGHT = 20;
const SWITCH_THUMB_SIZE = 16;
const SEGMENTED_INSET = 2;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
function byLayout(compact: number, wide: number): LayoutValue {
return { xs: compact, md: wide };
}
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return byLayout(map(value.xs), map(value.md));
}
/**
* The three control heights every button, field, and segmented control is built from.
* Exported so a row that hosts one of those controls can size itself from the same
* numbers instead of guessing a height the control then outgrows.
* The height of every button, field, and segmented control of a size. Exported so a row that
* hosts one of those controls can size itself from the same numbers instead of guessing a
* height the control then outgrows. `md` depends on the layout, so it only works inside a
* Unistyles stylesheet.
*/
export const CONTROL_HEIGHTS = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
sm: SMALL_CONTROL_HEIGHT,
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
} satisfies Record<ControlSize, number | LayoutValue>;
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
const height = CONTROL_HEIGHTS[size];
if (typeof height === "number") return height;
return isCompact ? height.xs : height.md;
}
export const controlIconSize: Record<ControlSize, number> = {
sm: ICON_SIZE.xs,
md: ICON_SIZE.sm,
};
export const buttonControlHeight: Record<ButtonControlSize, number> = {
xs: CONTROL_HEIGHTS.tight,
sm: CONTROL_HEIGHTS.compact,
md: CONTROL_HEIGHTS.field,
lg: CONTROL_HEIGHTS.field,
};
export interface SwitchGeometry {
trackWidth: number;
trackHeight: number;
thumbSize: number;
/** How far the thumb slides between off and on. */
thumbTravel: number;
/** The control height the switch reserves, so its row lines up with one holding a button. */
reservedHeight: number;
}
export const buttonIconSize: Record<ButtonControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
function switchSize(trackWidth: number, trackHeight: number, thumbSize: number) {
const inset = (trackHeight - thumbSize) / 2;
return { trackWidth, trackHeight, thumbSize, thumbTravel: trackWidth - thumbSize - inset * 2 };
}
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
const SWITCH_POINTER = switchSize(34, 20, 16);
const SWITCH_TOUCH = switchSize(44, 26, 22);
export const switchGeometry = {
trackWidth: SWITCH_TRACK_WIDTH,
trackHeight: SWITCH_TRACK_HEIGHT,
thumbSize: SWITCH_THUMB_SIZE,
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
/**
* A switch follows the same two sizes as every other control. Its thumb travel feeds an
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
*/
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
@@ -88,14 +113,21 @@ function fieldVerticalPadding(
}
/**
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
* Alert density steps. Each step's icon matches the text it leads.
* The icon and the first text sit in one centered lead row; everything below is indented
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
* description use one font size; only the container gap separates them.
*/
export const ALERT_ICON_SIZE: Record<AlertSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
function createAlertGeometry(theme: Theme) {
function alertSize(input: {
size: ButtonControlSize;
size: AlertSize;
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
@@ -103,7 +135,7 @@ function createAlertGeometry(theme: Theme) {
iconGap: number;
textGap: number;
}) {
const iconSize = buttonIconSize[input.size];
const iconSize = ALERT_ICON_SIZE[input.size];
return {
container: {
paddingVertical: input.paddingVertical,
@@ -155,7 +187,7 @@ function createAlertGeometry(theme: Theme) {
iconGap: theme.spacing[4],
textGap: theme.spacing[1],
}),
} satisfies Record<ButtonControlSize, unknown>;
} satisfies Record<AlertSize, unknown>;
}
export function getControlInteractionPhase(
@@ -188,45 +220,28 @@ export function resolveControlInteractionStyles(
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
CONTROL_HEIGHTS.sm,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: mapLayout(CONTROL_HEIGHTS.md, (height) =>
fieldVerticalPadding(height, fieldTextMdLineHeight, controlBorderWidth),
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
lineHeight: fieldTextSmLineHeight,
};
const fieldTextMd = {
@@ -234,43 +249,28 @@ export function createControlGeometry(theme: Theme) {
lineHeight: fieldTextMdLineHeight,
};
const alert = createAlertGeometry(theme);
const switchControl = {
minHeight: CONTROL_HEIGHTS.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
} satisfies { minHeight: number; justifyContent: "center" };
return {
buttonXs: {
minHeight: buttonControlHeight.xs,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
},
buttonSm: {
minHeight: buttonControlHeight.sm,
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.full,
},
buttonMd: {
minHeight: buttonControlHeight.md,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
borderRadius: theme.borderRadius.full,
},
buttonLg: {
minHeight: buttonControlHeight.lg,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
},
buttonText: {
fontSize: theme.fontSize.base,
},
buttonTextXs: {
// A button with only an icon is a circle of its size's height.
buttonIconOnlySm: { width: CONTROL_HEIGHTS.sm, paddingHorizontal: 0 },
buttonIconOnlyMd: { width: CONTROL_HEIGHTS.md, paddingHorizontal: 0 },
buttonTextSm: {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
buttonTextMd: {
fontSize: theme.fontSize.base,
},
alert,
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -279,14 +279,8 @@ export function createControlGeometry(theme: Theme) {
...fieldControlMd,
...fieldTextMd,
},
formTextInput: {
...fieldControlMd,
...fieldTextMd,
},
fieldControlXs,
fieldControlSm,
fieldControlMd,
fieldTextXs,
fieldTextSm,
fieldTextMd,
controlRest: {
@@ -311,39 +305,26 @@ export function createControlGeometry(theme: Theme) {
controlDisabled: {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: CONTROL_HEIGHTS.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: CONTROL_HEIGHTS.compact,
minHeight: CONTROL_HEIGHTS.sm,
padding: 0,
},
segmentedContainerMd: {
minHeight: CONTROL_HEIGHTS.field,
minHeight: CONTROL_HEIGHTS.md,
padding: 0,
},
segmentedSegmentXs: {
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.full,
},
segmentedSegmentMd: {
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height - SEGMENTED_INSET * 2),
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius["2xl"],
},
segmentedLabelXs: {
fontSize: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
},
segmentedLabelSm: {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
@@ -1,23 +1,21 @@
import { createContext, useContext, type ReactNode } from "react";
/** The control sizes a container can ask of the controls placed in it. */
export type ContainedControlSize = "xs" | "sm";
import type { ControlSize } from "@/components/ui/control-geometry";
// A row decides how big its controls are, so a button or field placed in it needs no size of
// its own. A size passed to the control still wins.
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
const ControlSizeContext = createContext<ControlSize | null>(null);
export function ControlSizeProvider({
size,
children,
}: {
size: ContainedControlSize;
size: ControlSize;
children: ReactNode;
}) {
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
}
/** The size the surrounding container asks for, or null outside one. */
export function useContainedControlSize(): ContainedControlSize | null {
export function useContainedControlSize(): ControlSize | null {
return useContext(ControlSizeContext);
}
@@ -1,11 +1,11 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
buttonIconSize,
controlIconSize,
createControlGeometry,
type ButtonControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
@@ -22,7 +22,7 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
children: string;
/** Icon or swatch before the label. */
leading?: ReactNode;
size?: ButtonControlSize;
size?: ControlSize;
}
/**
@@ -38,17 +38,8 @@ export function DropdownTrigger({
...props
}: DropdownTriggerProps): ReactElement {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "sm";
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
} else if (size === "md") {
sizeStyle = styles.md;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.sm;
}
const size = sizeProp ?? containedSize ?? "md";
const sizeStyle = size === "sm" ? styles.sm : styles.md;
const triggerStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.base,
@@ -58,7 +49,7 @@ export function DropdownTrigger({
],
[disabled, sizeStyle],
);
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
const labelStyle = useMemo(() => [styles.label, size === "sm" && styles.labelSm], [size]);
return (
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
@@ -67,7 +58,7 @@ export function DropdownTrigger({
{children}
</Text>
<View style={styles.chevron}>
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
<ThemedChevronDown size={controlIconSize[size]} uniProps={chevronColorMapping} />
</View>
</DropdownMenuTrigger>
);
@@ -84,10 +75,8 @@ const styles = StyleSheet.create((theme) => {
borderColor: theme.colors.borderAccent,
backgroundColor: "transparent",
},
xs: geometry.buttonXs,
sm: geometry.buttonSm,
md: geometry.buttonMd,
lg: geometry.buttonLg,
pressed: {
opacity: 0.85,
},
@@ -97,9 +86,9 @@ const styles = StyleSheet.create((theme) => {
label: {
flexShrink: 1,
color: theme.colors.foreground,
...geometry.buttonText,
...geometry.buttonTextMd,
},
labelXs: geometry.buttonTextXs,
labelSm: geometry.buttonTextSm,
chevron: {
transform: [{ translateY: 1 }],
},
@@ -1,5 +1,5 @@
import { useCallback, type ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ArrowUpRight } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -41,10 +41,10 @@ export function ExternalLink({
accessibilityRole="link"
accessibilityLabel={accessibilityLabel ?? label}
testID={testID}
style={styles.trigger}
>
<Text style={styles.label}>{children ?? label}</Text>
<ThemedArrowUpRight size={12} uniProps={iconColor} />
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<ExternalLinkLabel hovered={hovered}>{children ?? label}</ExternalLinkLabel>
)}
</Pressable>
);
@@ -62,7 +62,27 @@ export function ExternalLink({
);
}
/**
* What an external link looks like: muted text and an outward arrow, both turning foreground on
* hover. For a pressable that owns its own hover, such as a settings row that opens a page.
*/
export function ExternalLinkLabel({
hovered = false,
children,
}: {
hovered?: boolean;
children?: ReactNode;
}) {
return (
<View style={styles.trigger}>
{children ? <Text style={[styles.label, hovered && styles.hovered]}>{children}</Text> : null}
<ThemedArrowUpRight size={12} uniProps={hovered ? hoveredIconColor : iconColor} />
</View>
);
}
const iconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const hoveredIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const styles = StyleSheet.create((theme) => ({
@@ -75,6 +95,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hovered: {
color: theme.colors.foreground,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
+5 -14
View File
@@ -21,18 +21,15 @@ import type { EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
FieldControlSize,
string
>;
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
FieldControlSize,
const FIELD_CHROME = { sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
ControlSize,
string
>;
const FIELD_TEXT = { sm: "inputSm", md: "inputMd" } as const satisfies Record<ControlSize, string>;
interface FieldProps {
label: string;
@@ -77,7 +74,7 @@ export function Field({ label, children, trailing, hint, error, testID }: FieldP
}
type FormTextInputProps = AdaptiveTextInputProps & {
size?: FieldControlSize;
size?: ControlSize;
};
type FlatFormTextInputStyle = ViewStyle & TextStyle;
@@ -274,9 +271,6 @@ const formInputStyles = StyleSheet.create((theme) => {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeXs: {
...geometry.fieldControlXs,
},
chromeSm: {
...geometry.fieldControlSm,
},
@@ -304,9 +298,6 @@ const formInputStyles = StyleSheet.create((theme) => {
outlineColor: "transparent",
outlineWidth: 0,
},
inputXs: {
...geometry.fieldTextXs,
},
inputSm: {
...geometry.fieldTextSm,
},
@@ -4,8 +4,8 @@ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
createControlGeometry,
segmentedIconSize,
type SegmentedControlSize,
controlIconSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import type { Theme } from "@/styles/theme";
@@ -24,7 +24,7 @@ interface SegmentedControlProps<T extends string> {
options: SegmentedControlOption<T>[];
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
size?: ControlSize;
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
@@ -57,14 +57,13 @@ export function SegmentedControl<T extends string>({
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
}[size];
const containerSizeStyle = sizeStyles.container;
const segmentSizeStyle = sizeStyles.segment;
const labelSizeStyle = sizeStyles.label;
const iconSize = segmentedIconSize[size];
const iconSize = controlIconSize[size];
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
@@ -173,9 +172,6 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
},
containerSm: {
...geometry.segmentedContainerSm,
},
@@ -189,9 +185,6 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -218,9 +211,6 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -13,7 +13,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
type ControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -55,7 +55,7 @@ export interface SelectFieldProps<TValue> {
searchable?: boolean;
searchPlaceholder?: string;
title?: string;
size?: FieldControlSize;
size?: ControlSize;
getValueKey?: (value: TValue) => string;
renderOption?: (input: SelectFieldRenderOptionInput<TValue>) => ReactElement;
triggerLeading?: ReactNode;
@@ -75,7 +75,7 @@ export interface SelectFieldTriggerProps {
disabled?: boolean;
loading?: boolean;
leading?: ReactNode;
size?: FieldControlSize;
size?: ControlSize;
testID?: string;
}
@@ -0,0 +1,34 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, type StyleProp, type ViewStyle } from "react-native";
/** One looping value per skeleton, shared by its shapes so they pulse together. */
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
/** A placeholder shape; `style` sets its size, radius and color. */
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<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} />;
}
@@ -86,6 +86,7 @@ vi.mock("react-native-unistyles", () => ({
) =>
(props: Record<string, unknown>) =>
React.createElement(Component, { ...props, ...mapping?.(theme) }),
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
describe("Switch", () => {
+44 -21
View File
@@ -13,7 +13,9 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry";
import { switchGeometryForLayout, type SwitchGeometry } from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
interface SwitchProps {
@@ -29,6 +31,7 @@ const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
interface SwitchTrackProps {
value: boolean;
geometry: SwitchGeometry;
trackOffColor: string;
trackOnColor: string;
thumbOffColor: string;
@@ -37,6 +40,7 @@ interface SwitchTrackProps {
function SwitchTrack({
value,
geometry,
trackOffColor,
trackOnColor,
thumbOffColor,
@@ -48,15 +52,36 @@ function SwitchTrack({
backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]),
}));
const { thumbTravel } = geometry;
const thumbAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]),
transform: [{ translateX: progress.value * switchGeometry.thumbTravel }],
transform: [{ translateX: progress.value * thumbTravel }],
}));
const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]);
const trackSize = useMemo(
() => ({
width: geometry.trackWidth,
height: geometry.trackHeight,
borderRadius: geometry.trackHeight / 2,
padding: (geometry.trackHeight - geometry.thumbSize) / 2,
}),
[geometry],
);
const thumbSize = useMemo(
() => ({
width: geometry.thumbSize,
height: geometry.thumbSize,
borderRadius: geometry.thumbSize / 2,
}),
[geometry],
);
const trackStyle = useMemo(
() => [styles.switchTrack, trackSize, trackAnimatedStyle],
[trackSize, trackAnimatedStyle],
);
const thumbStyle = useMemo(
() => [styles.switchThumb, styles.thumb, thumbAnimatedStyle],
[thumbAnimatedStyle],
() => [thumbSize, styles.thumb, thumbAnimatedStyle],
[thumbSize, thumbAnimatedStyle],
);
return (
@@ -90,10 +115,19 @@ export function Switch({
[disabled, onValueChange, value],
);
const size = useContainedControlSize() ?? "md";
const isCompact = useIsCompactFormFactor();
const geometry = useMemo(() => switchGeometryForLayout(size, isCompact), [size, isCompact]);
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [styles.switchControl, disabled ? styles.disabled : null, style],
[disabled, style],
() => [
styles.switchControl,
{ minHeight: geometry.reservedHeight },
disabled ? styles.disabled : null,
style,
],
[disabled, geometry.reservedHeight, style],
);
return (
@@ -108,29 +142,18 @@ export function Switch({
testID={testID}
style={pressableStyle}
>
<ThemedSwitchTrack value={value} />
<ThemedSwitchTrack value={value} geometry={geometry} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
switchControl: {
...geometry.switchControl,
},
switchTrack: {
width: switchGeometry.trackWidth,
height: switchGeometry.trackHeight,
borderRadius: switchGeometry.trackHeight / 2,
padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2,
justifyContent: "center",
},
switchThumb: {
width: switchGeometry.thumbSize,
height: switchGeometry.thumbSize,
borderRadius: switchGeometry.thumbSize / 2,
switchTrack: {
justifyContent: "center",
},
thumb: {
shadowColor: "rgba(0, 0, 0, 0.25)",
@@ -283,7 +283,6 @@ export function WelcomeScreen() {
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={handleOpenSettings}
style={styles.settingsButton}
@@ -87,7 +87,7 @@ export function ComposerInputPresentation({
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
size="sm"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
@@ -111,7 +111,7 @@ export function ComposerInputPresentation({
{showFullscreenButton && !fullscreen ? (
<Button
variant="ghost"
size="xs"
size="sm"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
@@ -1635,10 +1635,10 @@ function BrowserElementAnnotationCard({
initialValue={comment}
/>
<View style={styles.annotationActions}>
<Button variant="ghost" size="sm" onPress={onCancel}>
<Button variant="ghost" onPress={onCancel}>
{t("workspace.browser.annotate.cancel")}
</Button>
<Button variant="default" size="sm" onPress={handleSubmit}>
<Button variant="default" onPress={handleSubmit}>
{t("workspace.browser.annotate.submit")}
</Button>
</View>
@@ -66,7 +66,6 @@ export function BrowserDataSection() {
</View>
<Button
variant="outline"
size="sm"
loading={isClearing}
disabled={isClearing}
onPress={handleClear}
@@ -59,7 +59,6 @@ export function DesktopNotificationsSection() {
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isPermissionBusy}
@@ -119,7 +118,6 @@ export function DesktopNotificationsSection() {
</View>
<Button
variant="outline"
size="sm"
onPress={handleSendTestNotification}
disabled={!notificationsGranted || isPermissionBusy || isSendingTestNotification}
>
@@ -58,7 +58,7 @@ export function DesktopPermissionRow({
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
<Button variant="outline" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? labels.requesting : labels.request}
</Button>
)}
@@ -41,7 +41,6 @@ export function DesktopPermissionsSection() {
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isBusy}
@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
@@ -187,12 +186,10 @@ function DaemonCliStatusModal({
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}>
<Button variant="outline" onPress={onClose}>
{t("common.actions.close")}
</Button>
<Button size="sm" onPress={onCopy}>
{t("common.actions.copy")}
</Button>
<Button onPress={onCopy}>{t("common.actions.copy")}</Button>
</View>
</View>
</AdaptiveModalSheet>
@@ -284,13 +281,12 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
<Button variant="outline" leftIcon={copyIcon} onPress={handleCopyLogPath}>
{t("desktop.daemon.logs.copyPath")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={fileTextIcon}
onPress={handleOpenLogs}
disabled={!daemonLogs}
@@ -306,7 +302,6 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
</View>
<Button
variant="outline"
size="sm"
leftIcon={activityIcon}
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
@@ -386,15 +381,6 @@ export function LocalDaemonSection() {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
[],
);
const advancedSettingsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const copyIcon = useMemo(
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
@@ -408,21 +394,13 @@ export function LocalDaemonSection() {
[theme.iconSize.sm, theme.colors.foreground],
);
const advancedSettingsButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={advancedSettingsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings}
accessibilityLabel={t("desktop.daemon.openAdvancedSettings")}
>
{t("desktop.daemon.advancedSettings")}
</Button>
),
[advancedSettingsIcon, handleOpenAdvancedSettings, t],
const advancedSettingsLink = useMemo(
() => ({
href: ADVANCED_DAEMON_SETTINGS_URL,
label: t("desktop.daemon.advancedSettings"),
accessibilityLabel: t("desktop.daemon.openAdvancedSettings"),
}),
[t],
);
if (!showSection) {
@@ -432,7 +410,7 @@ export function LocalDaemonSection() {
return (
<SettingsSection
title={t("desktop.daemon.title")}
trailing={advancedSettingsButton}
link={advancedSettingsLink}
testID="host-page-daemon-lifecycle-card"
>
{isLoading || isLoadingSettings ? (
@@ -471,7 +449,7 @@ export function LocalDaemonSection() {
</Text>
<Text style={settingsStyles.rowHint}>{daemonStatus.home}</Text>
</View>
<Button variant="outline" size="sm" onPress={handleStop} disabled={isStopping}>
<Button variant="outline" onPress={handleStop} disabled={isStopping}>
{isStopping
? t("desktop.daemon.lifecycle.stopping")
: t("desktop.daemon.lifecycle.stop")}
@@ -1,88 +1,72 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useCliInstall } from "@/desktop/hooks/use-install-status";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { status, isInstalling, install, refresh } = useCliInstall();
const cli = useCliInstall();
const { available, refresh, setInstalled } = cli;
useFocusEffect(
useCallback(() => {
if (showSection) refresh();
if (available) refresh();
return undefined;
}, [refresh, showSection]),
}, [refresh, available]),
);
const docsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
const handleValueChange = useCallback(
(next: boolean) => {
void setInstalled(next);
},
[setInstalled],
);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
const trailing = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
{t("settings.integrations.docs.cli")}
</Button>
),
[docsIcon, handleOpenDocs, t],
const docsLink = useMemo(
() => ({
href: CLI_DOCS_URL,
label: t("settings.integrations.docs.cli"),
accessibilityLabel: t("settings.integrations.docs.openCli"),
}),
[t],
);
if (!showSection) return null;
if (!available) return null;
return (
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.commandLine.title")}
</Text>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{status?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button variant="outline" size="sm" onPress={install} disabled={isInstalling}>
{isInstalling
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
</View>
<SettingsSection title={t("settings.integrations.title")} link={docsLink}>
<SettingsCard>
<CommandLineRow
value={cli.status?.installed ?? false}
onValueChange={handleValueChange}
disabled={cli.status === null || cli.isWorking}
/>
</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
/** The `paseo` CLI row: on means installed on this computer. */
export function CommandLineRow({
value,
onValueChange,
disabled,
}: {
value: boolean;
onValueChange: (next: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<SettingsRow
label={t("settings.integrations.commandLine.title")}
hint={t("settings.integrations.commandLine.description")}
>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
accessibilityLabel={t("settings.integrations.commandLine.accessibilityLabel")}
/>
</SettingsRow>
);
}
@@ -22,6 +22,8 @@ import {
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
export const CONNECTIVITY_DOCS_URL = "https://paseo.sh/docs/connectivity";
export const TAILSCALE_DOCS_URL = `${CONNECTIVITY_DOCS_URL}#tailscale`;
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedShieldCheck = withUnistyles(ShieldCheck);
@@ -169,7 +171,7 @@ function OfferLoadError({ message, onRetry }: { message: string; onRetry: () =>
const { t } = useTranslation();
return (
<Alert size="sm" variant="error" description={message}>
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
<Button variant="outline" leftIcon={RotateCw} onPress={onRetry}>
{t("pairing.device.retry")}
</Button>
</Alert>
@@ -219,7 +221,14 @@ function RelayConsent(props: PairDeviceBodyProps) {
</View>
<View style={styles.directRow}>
<ThemedNetwork size={14} style={styles.directIcon} />
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
<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>
</View>
</View>
);
@@ -254,12 +263,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
accessibilityLabel={t("pairing.link.label")}
/>
</View>
<Button
variant="outline"
size="sm"
leftIcon={props.copied ? Check : Copy}
onPress={props.onCopy}
>
<Button variant="outline" leftIcon={props.copied ? Check : Copy} onPress={props.onCopy}>
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button>
</View>
@@ -338,8 +342,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
marginTop: 1, // optical: seats the glyph on the hint's first text line
},
directHint: {
directContent: {
flex: 1,
alignItems: "flex-start",
gap: theme.spacing[2],
},
directHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
@@ -326,6 +326,10 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function uninstallCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("uninstall_cli"));
}
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
return invokeDesktopCommand("read_legacy_skill_selection") as Promise<AgentSkillSelection | null>;
@@ -1,10 +1,12 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import {
getCliInstallStatus,
installCli,
shouldUseDesktopDaemon,
uninstallCli,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
import {
@@ -14,60 +16,85 @@ import {
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
interface DesktopInstallHookResult {
interface CliInstall {
/** False outside the desktop app: the CLI installs on the machine running the app. */
available: boolean;
status: InstallStatus | null;
isLoading: boolean;
isInstalling: boolean;
error: Error | null;
install: () => void;
isWorking: boolean;
/**
* Installs or removes the CLI; resolves to whether it worked. Failures are reported. Removing
* keeps a paseo command this app did not install, says so, and resolves true.
*/
setInstalled: (installed: boolean) => Promise<boolean>;
refresh: () => void;
}
export function useCliInstall(): DesktopInstallHookResult {
export function useCliInstall(): CliInstall {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const toast = useToast();
const available = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: CLI_INSTALL_STATUS_QUERY_KEY,
queryFn: getCliInstallStatus,
enabled,
enabled: available,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
const { data: installStatus, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: t("desktop.integrations.cli.statusFailed"),
logLabel: "[Integrations] Failed to load CLI status",
});
const installMutation = useMutation<InstallStatus, Error>({
mutationFn: installCli,
onError: (error) => {
const mutation = useMutation<InstallStatus, Error, boolean>({
mutationFn: (installed) => (installed ? installCli() : uninstallCli()),
onError: (error, installed) => {
reportError({
error,
message: t("desktop.integrations.cli.installFailed"),
logLabel: "[Integrations] Failed to install CLI",
message: installed
? t("desktop.integrations.cli.installFailed")
: t("desktop.integrations.cli.uninstallFailed"),
logLabel: installed
? "[Integrations] Failed to install CLI"
: "[Integrations] Failed to uninstall CLI",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY });
},
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const { mutateAsync, isPending } = mutation;
// Removing only ever deletes the CLI this app installed. A paseo command put there some other way
// stays, and that still counts as done: the user is told rather than left with a switch that
// silently won't turn off.
const setInstalled = useCallback(
(installed: boolean) =>
mutateAsync(installed).then(
(status) => {
if (status.installed === installed) return true;
if (installed) return false;
toast.show(t("desktop.integrations.cli.keptForeign"));
return true;
},
() => false,
),
[mutateAsync, t, toast],
);
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
return {
available,
status: installStatus ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
isWorking: isPending,
setInstalled,
refresh,
};
}
+1 -1
View File
@@ -90,7 +90,7 @@ export function FilePane({
<Text style={styles.errorText}>
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
</Text>
<Button variant="outline" size="sm" onPress={handle.retry}>
<Button variant="outline" onPress={handle.retry}>
{t("common.actions.retry")}
</Button>
</>
@@ -30,11 +30,11 @@ export function FileConflictAlert({ state }: { state: FileConflictAlertState })
{state.kind === "changed" ? (
<View style={styles.actions}>
{state.canOverwrite ? (
<Button variant="outline" size="sm" onPress={state.onOverwrite}>
<Button variant="outline" onPress={state.onOverwrite}>
{t("panels.file.editor.overwrite")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={state.onReload}>
<Button variant="outline" onPress={state.onReload}>
{t("panels.file.editor.reload")}
</Button>
</View>
@@ -206,7 +206,7 @@ function EditableTextFile({
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{snapshot.error ?? t("panels.file.failedToLoad")}</Text>
<Button variant="outline" size="sm" onPress={handleRetry}>
<Button variant="outline" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
</View>
@@ -108,7 +108,7 @@ export function TextFileToolbar({
</View>
{view && onViewChange ? (
<SegmentedControl
size="xs"
size="sm"
value={view}
onValueChange={onViewChange}
testID="file-preview-mode"
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -273,7 +273,7 @@ const styles = StyleSheet.create((theme) => ({
},
splitButton: {
height: {
xs: buttonControlHeight.xs,
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
@@ -297,7 +297,7 @@ const styles = StyleSheet.create((theme) => ({
md: 42,
},
height: {
xs: buttonControlHeight.xs,
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
@@ -331,7 +331,7 @@ const styles = StyleSheet.create((theme) => ({
},
splitButtonCaret: {
width: {
xs: buttonControlHeight.xs,
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
@@ -341,11 +341,11 @@ const styles = StyleSheet.create((theme) => ({
},
iconButton: {
width: {
xs: buttonControlHeight.xs,
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: buttonControlHeight.xs,
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
+1 -1
View File
@@ -1134,7 +1134,7 @@ function DiffBodyContent({
{emptyAction ? (
<Button
variant="ghost"
size="xs"
size="sm"
testID="changes-empty-switch-mode"
onPress={emptyAction.onPress}
>
@@ -1,39 +1,9 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<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();
@@ -215,7 +215,7 @@ function CheckRow({
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
loading={isAddingLogsToChat}
onPress={handleAddLogsToChat}
@@ -15,7 +15,7 @@ export function PullRequestPaneError({
return (
<View style={styles.root} testID="pr-pane-error">
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
<Button variant="ghost" size="sm" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
</View>
@@ -2,7 +2,8 @@ import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { PrActivitySkeleton } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
@@ -484,7 +484,7 @@ export function PullRequestPane({
<View style={styles.toolbarActions}>
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={ExternalLink}
onPress={handleOpenPrUrl}
style={styles.viewButton}
@@ -587,7 +587,7 @@ export function PullRequestPane({
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
@@ -882,7 +882,7 @@ function SingleActivityCard({
<View style={styles.cardFooter}>
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddToChat}
>
@@ -959,7 +959,7 @@ function ReviewCard({
{collapsed && attachEnabled && canAddPullRequestActivityToChat(review) ? (
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddToChat}
style={styles.checkAddButton}
@@ -994,7 +994,7 @@ function ReviewCard({
<View style={styles.cardFooter}>
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddToChat}
>
@@ -1127,7 +1127,7 @@ function ThreadBlock({
<View style={styles.cardFooter}>
<Button
variant="ghost"
size="xs"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddThreadToChat}
>
@@ -163,11 +163,11 @@ export const sectionKitStyles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
// Fixed, not minHeight plus padding. A row's trailing slot holds an xs Button
// Fixed, not minHeight plus padding. A row's trailing slot holds an sm Button
// (28pt), which stacked on the vertical padding and made rows with an
// "Add to chat" button taller than rows without one. The row is single-line by
// construction, so it declares a height the button fits inside instead.
height: CONTROL_HEIGHTS.compact,
height: CONTROL_HEIGHTS.sm + theme.spacing[1],
},
checkName: {
fontSize: theme.fontSize.base,
@@ -21,7 +21,6 @@ import {
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
@@ -49,18 +48,10 @@ const styles = StyleSheet.create((theme) => {
minWidth: 0,
},
// Boxed adornment next to the field: same height and radius as the field
// chrome of the active form size, and the shared interaction phases.
iconButtonSm: {
minHeight: CONTROL_HEIGHTS.compact,
width: CONTROL_HEIGHTS.compact,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
iconButtonMd: {
minHeight: CONTROL_HEIGHTS.field,
width: CONTROL_HEIGHTS.field,
// chrome, and the shared interaction phases.
iconButton: {
minHeight: CONTROL_HEIGHTS.md,
width: CONTROL_HEIGHTS.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
@@ -106,7 +97,6 @@ export function AddRemoteSshHostModal({
}: AddRemoteSshHostModalProps) {
const { t } = useTranslation();
const hosts = useHosts();
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const passwordRef = useRef("");
@@ -193,7 +183,7 @@ export function AddRemoteSshHostModal({
(
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
): StyleProp<ViewStyle> => [
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
styles.iconButton,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
@@ -209,7 +199,7 @@ export function AddRemoteSshHostModal({
},
),
],
[isCompact, isSaving],
[isSaving],
);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
@@ -228,7 +218,6 @@ export function AddRemoteSshHostModal({
>
<FormTextInput
ref={inputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-target-input"
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
initialValue=""
@@ -246,7 +235,6 @@ export function AddRemoteSshHostModal({
<View style={styles.passwordInputWrap}>
<FormTextInput
ref={passwordInputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-password-input"
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
initialValue=""
@@ -47,7 +47,6 @@ export function HostConfirmationSheet() {
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={cancel}
testID="host-confirmation-cancel"
@@ -56,7 +55,6 @@ export function HostConfirmationSheet() {
</Button>
<Button
variant="default"
size="sm"
style={FLEX_1_STYLE}
onPress={connect}
testID="host-confirmation-connect"
+66 -10
View File
@@ -1541,6 +1541,8 @@ export const ar: TranslationResources = {
cli: {
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
installFailed: "غير قادر على تثبيت PaseoCLI.",
uninstallFailed: "تعذر إلغاء تثبيت Paseo CLI.",
keptForeign: "تم الإبقاء على أمر paseo لم يثبته Paseo. احذفه بنفسك لإلغاء تثبيته.",
},
},
},
@@ -1641,6 +1643,60 @@ export const ar: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "الخطوة {{current}} من {{total}}",
skip: "تخطي الإعداد",
continue: "متابعة",
finish: "إنهاء",
steps: {
appearance: {
label: "المظهر",
title: "مرحبًا بك في Paseo",
description: "لنبدأ باختيار المظهر",
},
providers: {
label: "المزوّدون",
title: "وكلاؤك",
description: "يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر",
},
projects: {
label: "المشاريع",
title: "أضف مشاريعك",
description: "أين تريد أن يعمل وكلاؤك",
addProject: "إضافة مشروع",
added: "تمت الإضافة",
suggestions: {
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
empty: "لم يتم العثور على مشاريع حديثة",
lastUsed: "آخر استخدام {{time}}",
addSelected_one: "إضافة مشروع واحد ({{count}})",
addSelected_other: "إضافة {{count}} مشاريع",
adding: "جارٍ الإضافة…",
addFailed: "تعذّرت إضافة هذا المجلد",
},
},
orchestration: {
label: "التنسيق",
title: "اسمح للوكلاء باستخدام Paseo",
description: "اسمح لوكلائك بإنشاء مساحات عمل والتحدث مع وكلاء آخرين",
},
phone: {
label: "الجوال",
title: "خذ وكلاءك معك",
description:
"يتيح لك تطبيق الجوال الأصلي كامل الميزات التحكم في أي كمبيوتر يعمل عليه Paseo من هاتفك",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "اختر طريقة الاتصال",
connectDocs: "الوثائق",
openConnectDocs: "افتح وثائق الاتصال",
recommended: "موصى به",
relay: { title: "التتابع", hint: "مشفّر من طرف إلى طرف، امسح رمز QR" },
tailscale: { title: "Tailscale", hint: "يتطلب بعض الإعداد" },
},
},
},
title: "مرحبا بكم في Paseo",
subtitle: "قم بتوصيل جهاز الكمبيوتر الخاص بك للبدء",
actions: {
@@ -1859,6 +1915,8 @@ export const ar: TranslationResources = {
notNow: "ليس الآن",
directConnectionHint:
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
tailscaleDocs: "إعداد Tailscale",
tailscaleDocsAccessibility: "اقرأ كيفية الاتصال عبر Tailscale",
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
@@ -2358,6 +2416,11 @@ export const ar: TranslationResources = {
general: {
title: "عام",
sending: "الإرسال",
setup: {
label: "الإعداد الأولي",
description: "أعد المرور على المظهر والمزوّدين والمشاريع وإقران الهاتف",
action: "إظهار الإعداد مجددًا",
},
browserData: {
title: "بيانات المتصفح",
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
@@ -2645,11 +2708,7 @@ export const ar: TranslationResources = {
commandLine: {
title: "سطر الأوامر",
description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك",
},
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
accessibilityLabel: "تثبيت Paseo CLI",
},
},
permissions: {
@@ -2727,10 +2786,9 @@ export const ar: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "مهارات التنسيق",
title: "مهارات التنسيق",
description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI",
description: "علّم وكلاءك كيفية استخدام أدوات Paseo",
updateAvailable: "التحديث متاح",
updateTitle: "تحديث مهارات Paseo ؟",
updateFallback: "مزامنة المهارات المجمعة لجهازك.",
uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟",
uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.",
choose: "اختيار المهارات",
@@ -2747,9 +2805,6 @@ export const ar: TranslationResources = {
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
update: "تحديث",
working: "عمل...",
remove: "إزالة",
@@ -2771,6 +2826,7 @@ export const ar: TranslationResources = {
title: "تمكين أدوات Paseo",
hint: "سيتمكن الوكلاء من إدارة أشجار العمل والوكلاء والجداول الزمنية",
accessibilityLabel: "حقن أدوات Paseo",
updateFailed: "تعذر تحديث أدوات Paseo.",
},
systemPrompt: {
title: "موجه النظام",
+67 -10
View File
@@ -1565,6 +1565,9 @@ export const en = {
cli: {
statusFailed: "Unable to check CLI install status.",
installFailed: "Unable to install the Paseo CLI.",
uninstallFailed: "Unable to uninstall the Paseo CLI.",
keptForeign:
"Kept a paseo command that Paseo didn't install. Remove it yourself to uninstall it.",
},
},
},
@@ -1666,6 +1669,60 @@ export const en = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "Step {{current}} of {{total}}",
skip: "Skip setup",
continue: "Continue",
finish: "Finish",
steps: {
appearance: {
label: "Appearance",
title: "Welcome to Paseo",
description: "Let's start by picking a look",
},
providers: {
label: "Providers",
title: "Your agents",
description: "Paseo runs the agent CLIs already on this computer",
},
projects: {
label: "Projects",
title: "Add your projects",
description: "Where do you want your agents to work on",
addProject: "Add project",
added: "Added",
suggestions: {
loading: "Looking for folders you've worked in",
empty: "No recent projects found",
lastUsed: "Last used {{time}}",
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: "Let your agents create workspaces and talk to other agents",
},
phone: {
label: "Mobile",
title: "Take your agents with you",
description:
"The fully featured native mobile app allows you to control any Paseo computer from your phone",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Choose how to connect",
connectDocs: "Docs",
openConnectDocs: "Open connectivity documentation",
recommended: "Recommended",
relay: { title: "Relay", hint: "End-to-end encrypted, scan a QR code" },
tailscale: { title: "Tailscale", hint: "Requires some setup" },
},
},
},
title: "Welcome to Paseo",
subtitle: "Connect your computer to get started",
actions: {
@@ -1884,6 +1941,8 @@ export const en = {
notNow: "Not now",
directConnectionHint:
"Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.",
tailscaleDocs: "Set up Tailscale",
tailscaleDocsAccessibility: "Read how to connect over Tailscale",
updateRequired: "Update the host to enable relay from Paseo Desktop.",
unavailable: "Pairing offer unavailable.",
hint: "Scan this QR code with Paseo on your phone, or copy the link below.",
@@ -2447,6 +2506,11 @@ export const en = {
general: {
title: "General",
sending: "Sending",
setup: {
label: "First-run setup",
description: "Walk through appearance, providers, projects and phone pairing again",
action: "Show setup again",
},
browserData: {
title: "Browser data",
siteData: "Cookies and site data",
@@ -2733,11 +2797,7 @@ export const en = {
commandLine: {
title: "Command line",
description: "Control and script agents from your terminal",
},
actions: {
install: "Install",
installing: "Installing...",
installed: "Installed",
accessibilityLabel: "Install the Paseo CLI",
},
},
permissions: {
@@ -2814,7 +2874,7 @@ export const en = {
skills: {
sectionTitle: "Orchestration skills",
title: "Orchestration skills",
description: "Teach your agents to orchestrate through the CLI",
description: "Teach your agents how to use Paseo tools",
docs: "Skills",
openDocs: "Open skills documentation",
unavailable: "Connect to this host to manage orchestration skills",
@@ -2825,7 +2885,6 @@ export const en = {
saveSelectionFailed: "Unable to save the orchestration skills selection.",
updateAvailable: "Update available",
updateTitle: "Update Paseo skills?",
updateFallback: "Sync bundled skills to this host.",
uninstallTitle: "Uninstall Paseo skills?",
uninstallMessage:
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex on this host.",
@@ -2839,9 +2898,6 @@ export const en = {
"{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex on this host. Anything you added inside those skill folders is deleted too.",
saveFailed: "Could not save your skill selection.",
actions: {
install: "Install",
installing: "Installing...",
installed: "Installed",
update: "Update",
working: "Working...",
remove: "Remove",
@@ -2863,6 +2919,7 @@ export const en = {
title: "Enable Paseo tools",
hint: "Agents will be able to manage worktrees, agents and schedules",
accessibilityLabel: "Inject Paseo tools",
updateFailed: "Unable to update Paseo tools.",
},
systemPrompt: {
title: "System prompt",
+68 -10
View File
@@ -1586,6 +1586,9 @@ export const es: TranslationResources = {
cli: {
statusFailed: "No se puede verificar el estado de instalación deCLI.",
installFailed: "No se puede instalar elPaseoCLI.",
uninstallFailed: "No se pudo desinstalar la CLI de Paseo.",
keptForeign:
"Se conservó un comando paseo que Paseo no instaló. Elimínalo tú mismo para desinstalarlo.",
},
},
},
@@ -1687,6 +1690,60 @@ export const es: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
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: "Empecemos por elegir una apariencia",
},
providers: {
label: "Proveedores",
title: "Tus agentes",
description: "Paseo usa las CLI de agentes que ya están en este ordenador",
},
projects: {
label: "Proyectos",
title: "Añade tus proyectos",
description: "Dónde quieres que trabajen tus agentes",
addProject: "Añadir proyecto",
added: "Añadido",
suggestions: {
loading: "Buscando carpetas en las que has trabajado",
empty: "No se encontraron proyectos recientes",
lastUsed: "Último uso {{time}}",
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: "Deja que tus agentes creen espacios de trabajo y hablen con otros agentes",
},
phone: {
label: "Móvil",
title: "Lleva tus agentes contigo",
description:
"La app móvil nativa completa te permite controlar cualquier ordenador con Paseo desde tu teléfono",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Elige cómo conectarte",
connectDocs: "Docs",
openConnectDocs: "Abrir la documentación de conectividad",
recommended: "Recomendado",
relay: { title: "Relé", hint: "Cifrado de extremo a extremo, escanea un código QR" },
tailscale: { title: "Tailscale", hint: "Requiere algo de configuración" },
},
},
},
title: "Bienvenido aPaseo",
subtitle: "Conecte su computadora para comenzar",
actions: {
@@ -1907,6 +1964,8 @@ export const es: TranslationResources = {
notNow: "Ahora no",
directConnectionHint:
"Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.",
tailscaleDocs: "Configurar Tailscale",
tailscaleDocsAccessibility: "Leer cómo conectarse con Tailscale",
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
unavailable: "Oferta de maridaje no disponible.",
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.",
@@ -2370,6 +2429,12 @@ export const es: TranslationResources = {
general: {
title: "General",
sending: "Envío",
setup: {
label: "Configuración inicial",
description:
"Vuelve a recorrer apariencia, proveedores, proyectos y emparejamiento del teléfono",
action: "Mostrar configuración de nuevo",
},
browserData: {
title: "Datos del navegador",
siteData: "Cookies y datos de sitios",
@@ -2665,11 +2730,7 @@ export const es: TranslationResources = {
commandLine: {
title: "línea de comando",
description: "Agentes de control y script desde tu terminal",
},
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
accessibilityLabel: "Instalar la CLI de Paseo",
},
},
permissions: {
@@ -2747,10 +2808,9 @@ export const es: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "Habilidades de orquestación",
title: "Habilidades de orquestación",
description: "Enseñe a sus agentes a orquestar a través delCLI",
description: "Enseña a tus agentes a usar las herramientas de Paseo",
updateAvailable: "Actualización disponible",
updateTitle: "¿Actualizar las habilidades dePaseo?",
updateFallback: "Sincronice las habilidades incluidas con su máquina.",
uninstallTitle: "¿Desinstalar las habilidadesPaseo?",
uninstallMessage:
"Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.",
@@ -2769,9 +2829,6 @@ export const es: TranslationResources = {
uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.",
saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.",
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Actualizar",
working: "Laboral...",
remove: "Eliminar",
@@ -2793,6 +2850,7 @@ export const es: TranslationResources = {
title: "Habilitar herramientasPaseo",
hint: "Los agentes podrán gestionar árboles de trabajo, agentes y horarios.",
accessibilityLabel: "Inyectar herramientasPaseo",
updateFailed: "No se pudieron actualizar las herramientas de Paseo.",
},
systemPrompt: {
title: "Aviso del sistema",
+69 -10
View File
@@ -1582,6 +1582,9 @@ export const fr: TranslationResources = {
cli: {
statusFailed: "Impossible de vérifier l’installation de la CLI.",
installFailed: "Impossible d’installer la CLI Paseo.",
uninstallFailed: "Impossible de désinstaller la CLI Paseo.",
keptForeign:
"Une commande paseo qui n’a pas été installée par Paseo a été conservée. Supprimez-la vous-même pour la désinstaller.",
},
},
},
@@ -1685,6 +1688,61 @@ export const fr: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "Étape {{current}} sur {{total}}",
skip: "Passer la configuration",
continue: "Continuer",
finish: "Terminer",
steps: {
appearance: {
label: "Apparence",
title: "Bienvenue dans Paseo",
description: "Commençons par choisir une apparence",
},
providers: {
label: "Fournisseurs",
title: "Vos agents",
description: "Paseo utilise les CLI d'agents déjà installées sur cet ordinateur",
},
projects: {
label: "Projets",
title: "Ajoutez vos projets",
description: "Où voulez-vous que vos agents travaillent",
addProject: "Ajouter un projet",
added: "Ajouté",
suggestions: {
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
empty: "Aucun projet récent trouvé",
lastUsed: "Dernière utilisation {{time}}",
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:
"Laissez vos agents créer des espaces de travail et parler à d'autres agents",
},
phone: {
label: "Mobile",
title: "Emportez vos agents partout",
description:
"L'application mobile native complète vous permet de contrôler n'importe quel ordinateur Paseo depuis votre téléphone",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Choisissez comment vous connecter",
connectDocs: "Docs",
openConnectDocs: "Ouvrir la documentation sur la connectivité",
recommended: "Recommandé",
relay: { title: "Relais", hint: "Chiffré de bout en bout, scannez un QR code" },
tailscale: { title: "Tailscale", hint: "Demande un peu de configuration" },
},
},
},
title: "Bienvenue dans Paseo",
subtitle: "Connectez votre ordinateur pour commencer",
actions: {
@@ -1905,6 +1963,8 @@ export const fr: TranslationResources = {
notNow: "Pas maintenant",
directConnectionHint:
"Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.",
tailscaleDocs: "Configurer Tailscale",
tailscaleDocsAccessibility: "Lire comment se connecter via Tailscale",
updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
unavailable: "Offre d’association indisponible.",
hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.",
@@ -2370,6 +2430,12 @@ export const fr: TranslationResources = {
general: {
title: "Général",
sending: "Envoi",
setup: {
label: "Configuration initiale",
description:
"Reprendre l'apparence, les fournisseurs, les projets et l'appairage du téléphone",
action: "Afficher la configuration",
},
browserData: {
title: "Données du navigateur",
siteData: "Cookies et données des sites",
@@ -2667,11 +2733,7 @@ export const fr: TranslationResources = {
commandLine: {
title: "Ligne de commande",
description: "Pilotez et scriptez vos agents depuis votre terminal",
},
actions: {
install: "Installer",
installing: "Installation…",
installed: "Installé",
accessibilityLabel: "Installer la CLI Paseo",
},
},
permissions: {
@@ -2750,10 +2812,9 @@ export const fr: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "Skills d’orchestration",
title: "Skills d’orchestration",
description: "Apprenez à vos agents à orchestrer via la CLI",
description: "Apprenez à vos agents à utiliser les outils Paseo",
updateAvailable: "Mise à jour disponible",
updateTitle: "Mettre à jour les skills Paseo ?",
updateFallback: "Synchronisez les skills fournis sur cet hôte.",
uninstallTitle: "Désinstaller les skills Paseo ?",
uninstallMessage:
"Supprime tous les skills d’orchestration Paseo de ~/.agents, ~/.claude et ~/.codex sur cet hôte.",
@@ -2772,9 +2833,6 @@ export const fr: TranslationResources = {
uninstallFailed: "Impossible de désinstaller les skills d’orchestration.",
saveSelectionFailed: "Impossible d’enregistrer la sélection des skills d’orchestration.",
actions: {
install: "Installer",
installing: "Installation…",
installed: "Installé",
update: "Mettre à jour",
working: "En cours…",
remove: "Supprimer",
@@ -2796,6 +2854,7 @@ export const fr: TranslationResources = {
title: "Activer les outils Paseo",
hint: "Les agents pourront gérer les worktrees, les agents et les planifications",
accessibilityLabel: "Injecter les outils Paseo",
updateFailed: "Impossible de mettre à jour les outils Paseo.",
},
systemPrompt: {
title: "Prompt système",
+69 -10
View File
@@ -1557,6 +1557,9 @@ export const ja: TranslationResources = {
cli: {
statusFailed: "CLIのインストール状態を確認できません。",
installFailed: "Paseo CLIをインストールできません。",
uninstallFailed: "Paseo CLI をアンインストールできません。",
keptForeign:
"Paseoがインストールしていないpaseoコマンドは残しました。アンインストールするには手動で削除してください。",
},
},
},
@@ -1658,6 +1661,61 @@ export const ja: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "ステップ {{current}}/{{total}}",
skip: "セットアップをスキップ",
continue: "続ける",
finish: "完了",
steps: {
appearance: {
label: "外観",
title: "Paseo へようこそ",
description: "まずは外観を選びましょう",
},
providers: {
label: "プロバイダー",
title: "エージェント",
description: "Paseo はこのコンピューターにインストール済みのエージェント CLI を使います",
},
projects: {
label: "プロジェクト",
title: "プロジェクトを追加",
description: "エージェントにどこで作業させますか",
addProject: "プロジェクトを追加",
added: "追加済み",
suggestions: {
loading: "作業したフォルダーを探しています",
empty: "最近のプロジェクトは見つかりませんでした",
lastUsed: "最終使用 {{time}}",
addSelected_one: "{{count}} 件のプロジェクトを追加",
addSelected_other: "{{count}} 件のプロジェクトを追加",
adding: "追加しています…",
addFailed: "このフォルダーを追加できませんでした",
},
},
orchestration: {
label: "オーケストレーション",
title: "エージェントに Paseo を使わせる",
description:
"エージェントがワークスペースを作成し、ほかのエージェントとやり取りできるようにします",
},
phone: {
label: "モバイル",
title: "エージェントを持ち歩く",
description:
"フル機能のネイティブモバイルアプリで、スマートフォンからどの Paseo コンピューターでも操作できます",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "接続方法を選択",
connectDocs: "ドキュメント",
openConnectDocs: "接続のドキュメントを開く",
recommended: "おすすめ",
relay: { title: "リレー", hint: "エンドツーエンド暗号化、QR コードをスキャン" },
tailscale: { title: "Tailscale", hint: "少し設定が必要です" },
},
},
},
title: "Paseoへようこそ",
subtitle: "始めるにはコンピューターに接続してください",
actions: {
@@ -1878,6 +1936,8 @@ export const ja: TranslationResources = {
notNow: "今はしない",
directConnectionHint:
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
tailscaleDocs: "Tailscale を設定",
tailscaleDocsAccessibility: "Tailscale で接続する方法を読む",
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
@@ -2343,6 +2403,12 @@ export const ja: TranslationResources = {
general: {
title: "一般",
sending: "送信",
setup: {
label: "初回セットアップ",
description:
"外観、プロバイダー、プロジェクト、スマートフォンのペアリングをもう一度設定します",
action: "セットアップを再表示",
},
browserData: {
title: "ブラウザーデータ",
siteData: "Cookie とサイトデータ",
@@ -2631,11 +2697,7 @@ export const ja: TranslationResources = {
commandLine: {
title: "コマンドライン",
description: "ターミナルからエージェントを制御し、スクリプトで操作",
},
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
accessibilityLabel: "Paseo CLI をインストール",
},
},
permissions: {
@@ -2714,10 +2776,9 @@ export const ja: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "オーケストレーションスキル",
title: "オーケストレーションスキル",
description: "エージェントがCLI経由でオーケストレーションできるようにします。",
description: "エージェントに Paseo ツールの使い方を教えます",
updateAvailable: "更新が利用可能",
updateTitle: "Paseoスキルを更新しますか?",
updateFallback: "バンドルされたスキルをマシンに同期します。",
uninstallTitle: "Paseoスキルをアンインストールしますか?",
uninstallMessage:
"~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。",
@@ -2736,9 +2797,6 @@ export const ja: TranslationResources = {
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
update: "更新",
working: "処理中...",
remove: "削除",
@@ -2760,6 +2818,7 @@ export const ja: TranslationResources = {
title: "Paseoツールを有効にする",
hint: "エージェントがワークツリー、エージェント、スケジュールを管理できるようになります",
accessibilityLabel: "Paseoツールを有効にする",
updateFailed: "Paseoツールを更新できませんでした。",
},
systemPrompt: {
title: "システムプロンプト",
+67 -10
View File
@@ -1550,6 +1550,9 @@ export const ko: TranslationResources = {
cli: {
statusFailed: "CLI 설치 상태를 확인할 수 없습니다.",
installFailed: "Paseo CLI를 설치할 수 없습니다.",
uninstallFailed: "Paseo CLI를 제거할 수 없습니다.",
keptForeign:
"Paseo가 설치하지 않은 paseo 명령은 그대로 두었습니다. 제거하려면 직접 삭제하세요.",
},
},
},
@@ -1651,6 +1654,60 @@ export const ko: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "{{total}}단계 중 {{current}}단계",
skip: "설정 건너뛰기",
continue: "계속",
finish: "완료",
steps: {
appearance: {
label: "모양",
title: "Paseo에 오신 것을 환영합니다",
description: "먼저 모양을 골라 보세요",
},
providers: {
label: "제공자",
title: "에이전트",
description: "Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다",
},
projects: {
label: "프로젝트",
title: "프로젝트 추가",
description: "에이전트가 어디에서 작업하길 원하나요",
addProject: "프로젝트 추가",
added: "추가됨",
suggestions: {
loading: "작업했던 폴더를 찾는 중",
empty: "최근 프로젝트를 찾지 못했습니다",
lastUsed: "마지막 사용 {{time}}",
addSelected_one: "프로젝트 {{count}}개 추가",
addSelected_other: "프로젝트 {{count}}개 추가",
adding: "추가하는 중…",
addFailed: "이 폴더를 추가하지 못했습니다",
},
},
orchestration: {
label: "오케스트레이션",
title: "에이전트가 Paseo를 사용하도록 허용",
description: "에이전트가 워크스페이스를 만들고 다른 에이전트와 대화하게 하세요",
},
phone: {
label: "모바일",
title: "어디서나 에이전트와 함께",
description:
"모든 기능을 갖춘 네이티브 모바일 앱으로 휴대폰에서 어떤 Paseo 컴퓨터든 제어할 수 있습니다",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "연결 방법 선택",
connectDocs: "문서",
openConnectDocs: "연결 문서 열기",
recommended: "권장",
relay: { title: "릴레이", hint: "엔드투엔드 암호화, QR 코드를 스캔하세요" },
tailscale: { title: "Tailscale", hint: "약간의 설정이 필요합니다" },
},
},
},
title: "Paseo에 오신 것을 환영합니다",
subtitle: "시작하려면 컴퓨터를 연결하세요",
actions: {
@@ -1869,6 +1926,8 @@ export const ko: TranslationResources = {
notNow: "지금은 아님",
directConnectionHint:
"릴레이 없이 TCP, Tailscale 또는 다른 VPN을 통해 직접 연결하세요. QR 코드가 생성되지 않습니다.",
tailscaleDocs: "Tailscale 설정",
tailscaleDocsAccessibility: "Tailscale로 연결하는 방법 읽기",
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
unavailable: "페어링 정보를 사용할 수 없습니다.",
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
@@ -2332,6 +2391,11 @@ export const ko: TranslationResources = {
general: {
title: "일반",
sending: "전송",
setup: {
label: "초기 설정",
description: "모양, 제공자, 프로젝트, 휴대폰 페어링을 다시 진행합니다",
action: "설정 다시 보기",
},
browserData: {
title: "브라우저 데이터",
siteData: "쿠키 및 사이트 데이터",
@@ -2621,11 +2685,7 @@ export const ko: TranslationResources = {
commandLine: {
title: "명령줄",
description: "터미널에서 에이전트를 제어하고 스크립팅합니다",
},
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
accessibilityLabel: "Paseo CLI 설치",
},
},
permissions: {
@@ -2703,10 +2763,9 @@ export const ko: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "오케스트레이션 스킬",
title: "오케스트레이션 스킬",
description: "CLI를 통해 에이전트가 오케스트레이션하도록 가르칩니다",
description: "에이전트에게 Paseo 도구 사용법을 알려 줍니다",
updateAvailable: "업데이트 사용 가능",
updateTitle: "Paseo 스킬을 업데이트할까요?",
updateFallback: "번들된 스킬을 내 컴퓨터에 동기화합니다.",
uninstallTitle: "Paseo 스킬을 제거할까요?",
uninstallMessage:
"~/.agents, ~/.claude, ~/.codex에서 모든 Paseo 오케스트레이션 스킬을 제거합니다.",
@@ -2724,9 +2783,6 @@ export const ko: TranslationResources = {
uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.",
saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.",
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
update: "업데이트",
working: "작업 중...",
remove: "제거",
@@ -2748,6 +2804,7 @@ export const ko: TranslationResources = {
title: "Paseo 도구 활성화",
hint: "에이전트가 워크트리, 에이전트, 일정을 관리할 수 있습니다",
accessibilityLabel: "Paseo 도구 주입",
updateFailed: "Paseo 도구를 업데이트할 수 없습니다.",
},
systemPrompt: {
title: "시스템 프롬프트",
+67 -10
View File
@@ -1571,6 +1571,9 @@ export const ptBR: TranslationResources = {
cli: {
statusFailed: "Não foi possível verificar o status de instalação da CLI.",
installFailed: "Não foi possível instalar a CLI do Paseo.",
uninstallFailed: "Não foi possível desinstalar a CLI do Paseo.",
keptForeign:
"Um comando paseo que o Paseo não instalou foi mantido. Remova-o manualmente para desinstalar.",
},
},
},
@@ -1672,6 +1675,60 @@ export const ptBR: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "Etapa {{current}} de {{total}}",
skip: "Pular configuração",
continue: "Continuar",
finish: "Concluir",
steps: {
appearance: {
label: "Aparência",
title: "Boas-vindas ao Paseo",
description: "Vamos começar escolhendo uma aparência",
},
providers: {
label: "Provedores",
title: "Seus agentes",
description: "O Paseo usa as CLIs de agentes já instaladas neste computador",
},
projects: {
label: "Projetos",
title: "Adicione seus projetos",
description: "Onde você quer que seus agentes trabalhem",
addProject: "Adicionar projeto",
added: "Adicionado",
suggestions: {
loading: "Procurando pastas em que você trabalhou",
empty: "Nenhum projeto recente encontrado",
lastUsed: "Último uso {{time}}",
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: "Deixe seus agentes criarem workspaces e conversarem com outros agentes",
},
phone: {
label: "Celular",
title: "Leve seus agentes com você",
description:
"O app móvel nativo completo permite controlar qualquer computador com Paseo pelo seu celular",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Escolha como se conectar",
connectDocs: "Docs",
openConnectDocs: "Abrir documentação de conectividade",
recommended: "Recomendado",
relay: { title: "Relay", hint: "Criptografia de ponta a ponta, escaneie um código QR" },
tailscale: { title: "Tailscale", hint: "Requer alguma configuração" },
},
},
},
title: "Bem-vindo ao Paseo",
subtitle: "Conecte seu computador para começar",
actions: {
@@ -1892,6 +1949,8 @@ export const ptBR: TranslationResources = {
notNow: "Agora não",
directConnectionHint:
"Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.",
tailscaleDocs: "Configurar Tailscale",
tailscaleDocsAccessibility: "Ler como conectar pelo Tailscale",
updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.",
unavailable: "Oferta de pareamento indisponível.",
hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.",
@@ -2355,6 +2414,11 @@ export const ptBR: TranslationResources = {
general: {
title: "Geral",
sending: "Envio",
setup: {
label: "Configuração inicial",
description: "Rever aparência, provedores, projetos e pareamento do celular",
action: "Mostrar configuração novamente",
},
browserData: {
title: "Dados do navegador",
siteData: "Cookies e dados de sites",
@@ -2647,11 +2711,7 @@ export const ptBR: TranslationResources = {
commandLine: {
title: "Linha de comando",
description: "Controle agentes e execute scripts pelo terminal",
},
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
accessibilityLabel: "Instalar a CLI do Paseo",
},
},
permissions: {
@@ -2729,10 +2789,9 @@ export const ptBR: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "Skills de orquestração",
title: "Skills de orquestração",
description: "Ensine seus agentes a orquestrar pela CLI",
description: "Ensine seus agentes a usar as ferramentas do Paseo",
updateAvailable: "Atualização disponível",
updateTitle: "Atualizar Paseo skills?",
updateFallback: "Sincronize as skills incluídas com sua máquina.",
uninstallTitle: "Desinstalar Paseo skills?",
uninstallMessage:
"Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.",
@@ -2751,9 +2810,6 @@ export const ptBR: TranslationResources = {
uninstallFailed: "Não foi possível desinstalar as skills de orquestração.",
saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.",
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Atualizar",
working: "Trabalhando...",
remove: "Remover",
@@ -2775,6 +2831,7 @@ export const ptBR: TranslationResources = {
title: "Ativar ferramentas do Paseo",
hint: "Agentes poderão gerenciar worktrees, agentes e agendamentos",
accessibilityLabel: "Injetar ferramentas do Paseo",
updateFailed: "Não foi possível atualizar as ferramentas do Paseo.",
},
systemPrompt: {
title: "Prompt do sistema",
+68 -10
View File
@@ -1568,6 +1568,9 @@ export const ru: TranslationResources = {
cli: {
statusFailed: "Не удалось проверить состояние установки CLI.",
installFailed: "Не удалось установить Paseo CLI.",
uninstallFailed: "Не удалось удалить Paseo CLI.",
keptForeign:
"Команда paseo, установленная не Paseo, оставлена. Чтобы удалить её, сделайте это вручную.",
},
},
},
@@ -1670,6 +1673,61 @@ export const ru: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "Шаг {{current}} из {{total}}",
skip: "Пропустить настройку",
continue: "Продолжить",
finish: "Готово",
steps: {
appearance: {
label: "Оформление",
title: "Добро пожаловать в Paseo",
description: "Начнём с выбора оформления",
},
providers: {
label: "Провайдеры",
title: "Ваши агенты",
description: "Paseo использует CLI агентов, уже установленные на этом компьютере",
},
projects: {
label: "Проекты",
title: "Добавьте проекты",
description: "Где будут работать ваши агенты",
addProject: "Добавить проект",
added: "Добавлено",
suggestions: {
loading: "Ищем папки, в которых вы работали",
empty: "Недавних проектов не найдено",
lastUsed: "Последнее использование {{time}}",
addSelected_one: "Добавить {{count}} проект",
addSelected_other: "Добавить проекты: {{count}}",
adding: "Добавляем…",
addFailed: "Не удалось добавить эту папку",
},
},
orchestration: {
label: "Оркестрация",
title: "Дайте агентам доступ к Paseo",
description:
"Разрешите агентам создавать рабочие пространства и общаться с другими агентами",
},
phone: {
label: "Мобильный",
title: "Агенты всегда под рукой",
description:
"Полнофункциональное нативное мобильное приложение позволяет управлять любым компьютером с Paseo с телефона",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Выберите способ подключения",
connectDocs: "Документация",
openConnectDocs: "Открыть документацию по подключению",
recommended: "Рекомендуется",
relay: { title: "Ретранслятор", hint: "Сквозное шифрование, отсканируйте QR-код" },
tailscale: { title: "Tailscale", hint: "Требует небольшой настройки" },
},
},
},
title: "Добро пожаловать в Paseo",
subtitle: "Подключите компьютер, чтобы начать",
actions: {
@@ -1891,6 +1949,8 @@ export const ru: TranslationResources = {
notNow: "Не сейчас",
directConnectionHint:
"Без ретранслятора подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код создаваться не будет.",
tailscaleDocs: "Настроить Tailscale",
tailscaleDocsAccessibility: "Узнать, как подключиться через Tailscale",
updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.",
unavailable: "Данные для сопряжения недоступны.",
hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.",
@@ -2357,6 +2417,11 @@ export const ru: TranslationResources = {
general: {
title: "Основные",
sending: "Отправка",
setup: {
label: "Первоначальная настройка",
description: "Снова пройти оформление, провайдеры, проекты и привязку телефона",
action: "Показать настройку снова",
},
browserData: {
title: "Данные браузера",
siteData: "Файлы cookie и данные сайтов",
@@ -2653,11 +2718,7 @@ export const ru: TranslationResources = {
commandLine: {
title: "Командная строка",
description: "Управляйте агентами и автоматизируйте их работу из терминала",
},
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
accessibilityLabel: "Установить Paseo CLI",
},
},
permissions: {
@@ -2736,14 +2797,13 @@ export const ru: TranslationResources = {
skills: {
sectionTitle: "Навыки оркестрации",
title: "Навыки оркестрации",
description: "Научите агентов координировать работу через CLI",
description: "Научите агентов пользоваться инструментами Paseo",
docs: "Навыки",
openDocs: "Открыть документацию по навыкам",
unavailable: "Подключитесь к этому хосту, чтобы управлять навыками оркестрации",
unsupported: "Обновите этот хост, чтобы управлять навыками оркестрации",
updateAvailable: "Доступно обновление",
updateTitle: "Обновить навыки Paseo?",
updateFallback: "Встроенные навыки будут синхронизированы с этим хостом.",
uninstallTitle: "Удалить навыки Paseo?",
uninstallMessage:
"Удаляет все навыки оркестрации Paseo из ~/.agents, ~/.claude и ~/.codex на этом хосте.",
@@ -2761,9 +2821,6 @@ export const ru: TranslationResources = {
uninstallFailed: "Не удалось удалить навыки оркестрации.",
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестрации.",
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
update: "Обновить",
working: "Выполняется...",
remove: "Удалить",
@@ -2785,6 +2842,7 @@ export const ru: TranslationResources = {
title: "Включить инструменты Paseo",
hint: "Агенты смогут управлять worktree, агентами и расписаниями.",
accessibilityLabel: "Добавить инструменты Paseo",
updateFailed: "Не удалось обновить инструменты Paseo.",
},
systemPrompt: {
title: "Системный промпт",
+65 -10
View File
@@ -1525,6 +1525,8 @@ export const zhCN: TranslationResources = {
cli: {
statusFailed: "无法检查 CLI 安装状态。",
installFailed: "无法安装 Paseo CLI。",
uninstallFailed: "无法卸载 Paseo CLI。",
keptForeign: "已保留一个不是由 Paseo 安装的 paseo 命令。如需卸载,请手动删除。",
},
},
},
@@ -1624,6 +1626,59 @@ export const zhCN: TranslationResources = {
},
},
onboarding: {
setup: {
title: "Paseo",
stepCounter: "第 {{current}} 步,共 {{total}} 步",
skip: "跳过设置",
continue: "继续",
finish: "完成",
steps: {
appearance: {
label: "外观",
title: "欢迎使用 Paseo",
description: "先选择一个外观",
},
providers: {
label: "提供方",
title: "你的代理",
description: "Paseo 使用这台电脑上已安装的代理 CLI",
},
projects: {
label: "项目",
title: "添加项目",
description: "你希望代理在哪里工作",
addProject: "添加项目",
added: "已添加",
suggestions: {
loading: "正在查找你工作过的文件夹",
empty: "未找到最近的项目",
lastUsed: "上次使用 {{time}}",
addSelected_one: "添加 {{count}} 个项目",
addSelected_other: "添加 {{count}} 个项目",
adding: "正在添加…",
addFailed: "无法添加此文件夹",
},
},
orchestration: {
label: "编排",
title: "让代理使用 Paseo",
description: "让你的代理创建工作区并与其他代理对话",
},
phone: {
label: "移动端",
title: "随身带上你的代理",
description: "功能完整的原生移动应用让你可以在手机上控制任意一台 Paseo 电脑",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "选择连接方式",
connectDocs: "文档",
openConnectDocs: "打开连接文档",
recommended: "推荐",
relay: { title: "中继", hint: "端到端加密,扫描二维码" },
tailscale: { title: "Tailscale", hint: "需要一些设置" },
},
},
},
title: "欢迎使用 Paseo",
subtitle: "连接你的电脑即可开始",
actions: {
@@ -1838,6 +1893,8 @@ export const zhCN: TranslationResources = {
notNow: "暂不",
directConnectionHint:
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
tailscaleDocs: "设置 Tailscale",
tailscaleDocsAccessibility: "了解如何通过 Tailscale 连接",
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
@@ -2299,6 +2356,11 @@ export const zhCN: TranslationResources = {
general: {
title: "通用",
sending: "发送",
setup: {
label: "首次设置",
description: "重新设置外观、提供方、项目和手机配对",
action: "再次显示设置",
},
browserData: {
title: "浏览器数据",
siteData: "Cookie 和网站数据",
@@ -2580,11 +2642,7 @@ export const zhCN: TranslationResources = {
commandLine: {
title: "命令行",
description: "从终端控制 Agent 并运行脚本",
},
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
accessibilityLabel: "安装 Paseo CLI",
},
},
permissions: {
@@ -2662,10 +2720,9 @@ export const zhCN: TranslationResources = {
...en.settings.host.skills,
sectionTitle: "编排 skills",
title: "编排 skills",
description: "教会 Agent 通过 CLI 编排任务",
description: "教你的代理如何使用 Paseo 工具",
updateAvailable: "有更新可用",
updateTitle: "更新 Paseo skills?",
updateFallback: "将内置 skills 同步到你的机器。",
uninstallTitle: "卸载 Paseo skills?",
uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。",
choose: "选择 skills",
@@ -2682,9 +2739,6 @@ export const zhCN: TranslationResources = {
uninstallFailed: "无法卸载编排 skills。",
saveSelectionFailed: "无法保存编排 skills 的选择。",
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
update: "更新",
working: "处理中...",
remove: "移除",
@@ -2706,6 +2760,7 @@ export const zhCN: TranslationResources = {
title: "启用 Paseo tools",
hint: "Agent 将能够管理 worktree、Agent 和计划",
accessibilityLabel: "注入 Paseo tools",
updateFailed: "无法更新 Paseo 工具。",
},
systemPrompt: {
title: "System prompt",
@@ -57,10 +57,10 @@ export function AgentRouteResolutionView({
<Text style={styles.error}>{resolution.error}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
<Button variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
<Button variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
{t("common.actions.back")}
</Button>
</View>
@@ -95,10 +95,10 @@ export function AgentRouteResolutionView({
</View>
{!isConnecting ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
<Button variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
<Button variant="outline" leftIcon={Settings} onPress={onManageHost}>
{t("workspace.route.manageHost")}
</Button>
</View>
+9
View File
@@ -0,0 +1,9 @@
/**
* First-run setup: a one-time modal that walks a new user through the surfaces Settings
* already has — appearance, providers, projects, Paseo tools and phone pairing.
*
* The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has,
* and how completion is remembered stay inside; import from `@/onboarding` only.
*/
export { OnboardingHost } from "./onboarding-host";
export { useOpenOnboarding } from "./use-open-onboarding";
@@ -0,0 +1,25 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import type { OnboardingCompletionStorage } from "./model";
export const ONBOARDING_STORAGE_KEY = "@paseo:onboarding";
const OnboardingRecordSchema = z.object({ completedAt: z.string() });
export const asyncStorageOnboardingCompletion: OnboardingCompletionStorage = {
async isCompleted() {
const record = await readValidatedJson(
AsyncStorage,
ONBOARDING_STORAGE_KEY,
OnboardingRecordSchema,
);
return record !== null;
},
async markCompleted() {
await AsyncStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ completedAt: new Date().toISOString() }),
);
},
};
@@ -0,0 +1,220 @@
import { describe, expect, it, vi } 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);
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("moving on from the last step finishes setup, unless the user left that step first", async () => {
const storage = new MemoryCompletionStorage();
const model = await openModel(storage);
model.applySetupHost(FRESH_HOST, { includePhoneStep: false });
model.goTo("orchestration");
// Finish is applying choices when the user goes back to Projects.
const finishingFrom = currentVisit(model);
model.back();
model.next(finishingFrom);
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
expect(storage.completed).toBe(false);
advance(model);
advance(model);
await vi.waitFor(() => expect(storage.completed).toBe(true));
expect(model.getState()).toEqual({ status: "hidden" });
});
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"]);
});
});
@@ -0,0 +1,170 @@
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; from the last step, that finishes setup. A no-op
* once that visit ended, so an action that finishes late never moves a step the user left or
* closes a setup they went back in, or 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();
async function complete() {
completion = "completed";
setState({ status: "hidden" });
await input.storage.markCompleted();
}
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) return;
if (state.stepIndex === state.steps.length - 1) void complete();
else 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);
},
complete,
};
}
@@ -0,0 +1,10 @@
import { asyncStorageOnboardingCompletion } from "./completion-storage";
import { createOnboardingModel, type OnboardingModel } from "./model";
let instance: OnboardingModel | null = null;
/** The app's one setup model, created on first use so storage is read once per launch. */
export function getOnboardingModel(): OnboardingModel {
instance ??= createOnboardingModel({ storage: asyncStorageOnboardingCompletion });
return instance;
}
@@ -0,0 +1,394 @@
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,
SheetFooter,
} 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 { useOrchestrationSetup, type OrchestrationSetupController } from "./orchestration-setup";
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 orchestration = useOrchestrationSetup(state.serverId);
const { apply: applyOrchestration } = orchestration;
const handleComplete = useCallback(() => {
void model.complete();
}, [model]);
const handleBack = useCallback(() => model.back(), [model]);
const { visit } = state;
// A step's own work runs before it moves on; from the last step, moving on finishes setup.
const handleAdvance = useCallback(() => model.next(visit), [model, visit]);
const handleAddSuggestions = useCallback(() => {
void (async () => {
if (await addChecked()) handleAdvance();
})();
}, [addChecked, handleAdvance]);
const handleApplyOrchestration = useCallback(() => {
void (async () => {
if (await applyOrchestration()) handleAdvance();
})();
}, [applyOrchestration, handleAdvance]);
const primary = usePrimaryAction({
step,
isLast,
projectSuggestions,
orchestration,
onAdvance: handleAdvance,
onAddSuggestions: handleAddSuggestions,
onApplyOrchestration: handleApplyOrchestration,
});
// The compact sheet stacks a title row, the step and the actions. The desktop card gives the
// step rail the full height instead: the brand tops the rail, the actions close the step pane,
// and the sheet floats its close button.
const compactHeader = useMemo(
() => ({
title: t("onboarding.setup.title"),
leading: <PaseoLogo size={LOGO_SIZE} />,
subtitle: (
<Text style={styles.stepCounter}>
{t("onboarding.setup.stepCounter", {
current: state.stepIndex + 1,
total: state.steps.length,
})}
</Text>
),
}),
[state.stepIndex, state.steps.length, t],
);
const actions = (
<>
<View>
{isLast ? null : (
<Button variant="outline" 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={isCompact ? compactHeader : undefined}
footer={isCompact ? actions : undefined}
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} />}
<View style={styles.pane}>
<ScrollView style={styles.paneScroll}>
{/* 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}
orchestration={orchestration}
/>
</View>
</ScrollView>
{isCompact ? null : <SheetFooter>{actions}</SheetFooter>}
</View>
</View>
</AdaptiveModalSheet>
);
}
/**
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
* every one was added. On Orchestration it applies the switches and moves on once they all took.
* Otherwise it moves on. Moving on from the last step finishes setup, so that step's work still
* runs first; Orchestration is last in the compact layout.
*/
function usePrimaryAction(input: {
step: OnboardingStepId;
isLast: boolean;
projectSuggestions: ProjectSuggestionsController;
orchestration: OrchestrationSetupController;
onAdvance: () => void;
onAddSuggestions: () => void;
onApplyOrchestration: () => void;
}): { label: string; busy: boolean; onPress: () => void } {
const { t } = useTranslation();
const {
step,
isLast,
projectSuggestions,
orchestration,
onAdvance,
onAddSuggestions,
onApplyOrchestration,
} = input;
const { checkedCount, isAdding } = projectSuggestions;
const advanceLabel = isLast ? t("onboarding.setup.finish") : t("onboarding.setup.continue");
// 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,
};
}
if (step === "orchestration") {
return { label: advanceLabel, busy: orchestration.isApplying, onPress: onApplyOrchestration };
}
return { label: advanceLabel, busy: false, onPress: onAdvance };
}
function StepRail({
state,
onSelect,
}: {
state: OpenState;
onSelect: (step: OnboardingStepId) => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.rail}>
<View style={styles.railBrand}>
<PaseoLogo size={LOGO_SIZE} />
<Text style={styles.railTitle} numberOfLines={1}>
{t("onboarding.setup.title")}
</Text>
</View>
<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>
</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 LOGO_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],
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
// Inset like a rail item, so the logo sits on the badges' column.
railBrand: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
marginBottom: theme.spacing[4],
},
railTitle: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
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.full,
},
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.base,
},
railBadgeTextActive: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.base,
},
railLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
railLabelCurrent: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
pane: {
flex: 1,
minHeight: 0,
},
paneScroll: {
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],
},
}));
@@ -0,0 +1,113 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
areSkillsInstalled,
hasSelectedSkills,
useAgentSkills,
type AgentSkillsController,
} from "@/agent-skills";
import { useToast } from "@/contexts/toast-context";
import { useCliInstall } from "@/desktop/hooks/use-install-status";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
export type OrchestrationChoice = "tools" | "skills" | "cli";
type Choices = Record<OrchestrationChoice, boolean>;
const ALL_ON: Choices = { tools: true, skills: true, cli: true };
export interface OrchestrationSetupController {
choices: Choices;
setChoice: (choice: OrchestrationChoice, on: boolean) => void;
/** Only the choices this host and app can act on: skills need host support, the CLI the desktop app. */
offered: Choices;
/** The skills row's picker and Update act on the host directly, through this. */
skills: AgentSkillsController;
isApplying: boolean;
/**
* Brings the host in line with the choices; resolves true only when every one took. Each failure
* is shown to the user.
*/
apply: () => Promise<boolean>;
}
/**
* The Orchestration step's choices. Everything starts on and nothing touches the host or this
* computer until Continue applies them, the way checked projects are added.
*/
export function useOrchestrationSetup(serverId: string): OrchestrationSetupController {
const { t } = useTranslation();
const toast = useToast();
const skills = useAgentSkills(serverId);
const cli = useCliInstall();
const { config, patchConfig } = useDaemonConfig(serverId);
const [chosen, setChosen] = useState<Choices>(ALL_ON);
// With nothing picked there is nothing to install, so skills read as off, as they do in Settings.
const skillsSelected = skills.status === null || hasSelectedSkills(skills.status);
const choices = useMemo<Choices>(
() => ({ ...chosen, skills: chosen.skills && skillsSelected }),
[chosen, skillsSelected],
);
const [isApplying, setIsApplying] = useState(false);
const offered: Choices = {
tools: true,
skills: skills.connected && skills.supported,
cli: cli.available,
};
const setChoice = useCallback((choice: OrchestrationChoice, on: boolean) => {
setChosen((current) => ({ ...current, [choice]: on }));
}, []);
// What the host and this computer have now, or null while it is still being read. A choice is
// skipped only when it is known to match; an unknown state is always written.
const toolsOn = config ? config.mcp.injectIntoAgents !== false : null;
const skillsInstalled = skills.status ? areSkillsInstalled(skills.status) : null;
const cliInstalled = cli.status ? cli.status.installed : null;
const { reconcile, uninstall } = skills;
const { setInstalled: setCliInstalled } = cli;
const offersSkills = offered.skills;
const offersCli = offered.cli;
const setTools = useCallback(
(on: boolean) =>
patchConfig({ mcp: { injectIntoAgents: on } })
.then(Boolean, () => false)
.then((done) => {
if (!done) toast.error(t("settings.host.orchestration.enableTools.updateFailed"));
return done;
}),
[patchConfig, t, toast],
);
const setSkillsInstalled = useCallback(
(on: boolean) => (on ? reconcile() : uninstall()),
[reconcile, uninstall],
);
const apply = useCallback(async () => {
setIsApplying(true);
try {
const results = await Promise.all([
choices.tools === toolsOn ? true : setTools(choices.tools),
!offersSkills || choices.skills === skillsInstalled
? true
: setSkillsInstalled(choices.skills),
!offersCli || choices.cli === cliInstalled ? true : setCliInstalled(choices.cli),
]);
return results.every(Boolean);
} finally {
setIsApplying(false);
}
}, [
choices,
toolsOn,
setTools,
offersSkills,
skillsInstalled,
setSkillsInstalled,
offersCli,
cliInstalled,
setCliInstalled,
]);
return { choices, setChoice, offered, skills, isApplying, apply };
}
@@ -0,0 +1,5 @@
import { ThemePicker } from "@/appearance/theme-picker";
export function AppearanceStep() {
return <ThemePicker />;
}
@@ -0,0 +1,33 @@
import type { ProjectSuggestionsController } from "@/project-suggestions";
import type { OnboardingStepId } from "../model";
import type { OrchestrationSetupController } from "../orchestration-setup";
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,
orchestration,
}: {
step: OnboardingStepId;
serverId: string;
projectSuggestions: ProjectSuggestionsController;
orchestration: OrchestrationSetupController;
}) {
switch (step) {
case "appearance":
return <AppearanceStep />;
case "providers":
return <ProvidersStep serverId={serverId} />;
case "projects":
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
case "orchestration":
return <OrchestrationStep setup={orchestration} />;
case "phone":
return <PhoneStep serverId={serverId} />;
}
}
@@ -0,0 +1,30 @@
import { useCallback } from "react";
import { AgentSkillsRow } from "@/agent-skills";
import { SettingsCard } from "@/components/settings";
import { CommandLineRow } from "@/desktop/components/integrations-section";
import { PaseoToolsRow } from "@/screens/settings/paseo-tools-card";
import type { OrchestrationSetupController } from "../orchestration-setup";
/** Three switches, all on to start; Continue applies them. */
export function OrchestrationStep({ setup }: { setup: OrchestrationSetupController }) {
const { choices, setChoice, offered, isApplying } = setup;
const setTools = useCallback((on: boolean) => setChoice("tools", on), [setChoice]);
const setSkills = useCallback((on: boolean) => setChoice("skills", on), [setChoice]);
const setCli = useCallback((on: boolean) => setChoice("cli", on), [setChoice]);
return (
<SettingsCard>
<PaseoToolsRow value={choices.tools} onValueChange={setTools} disabled={isApplying} />
{offered.skills ? (
<AgentSkillsRow
skills={setup.skills}
value={choices.skills}
onValueChange={setSkills}
disabled={isApplying}
/>
) : null}
{offered.cli ? (
<CommandLineRow value={choices.cli} onValueChange={setCli} disabled={isApplying} />
) : null}
</SettingsCard>
);
}

Some files were not shown because too many files have changed in this diff Show More