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.
This commit is contained in:
Mohamed Boudra
2026-11-02 17:07:23 +01:00
parent b3a3a4d903
commit d95e46ed9f
133 changed files with 1808 additions and 2019 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/components/add-host-modal.tsx:19-23`, `packages/app/src/components/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/components/add-host-modal.tsx`, `packages/app/src/components/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/components/add-host-modal.tsx`, `packages/app/src/components/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.
+24 -20
View File
@@ -9,10 +9,13 @@ import {
finishSetup,
goToSetupStep,
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
@@ -38,7 +41,7 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
await expectSetupStep(page, "Welcome to Paseo");
await pickTheme(page, "Midnight");
await expect(
setupModal(page).getByText(/^Run Claude Code, Codex and other agents/),
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") });
});
@@ -63,12 +66,13 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
});
await test.step("Orchestration shows the Paseo tools switch", async () => {
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: "Inject Paseo tools", exact: true }),
).toBeVisible();
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
});
@@ -110,23 +114,23 @@ test("setup suggests the repos the user already ran agents in", async ({ page },
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
await expect(projectSuggestion(page, repoName)).toContainText("3 sessions");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(setupModal(page).getByRole("checkbox")).toHaveCount(2);
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
});
await test.step("a provider that cannot read its history offers Retry", async () => {
await expect(
setupModal(page).getByText("Couldn't read Broken ACP history", { exact: true }),
).toBeVisible({ timeout: 60_000 });
await expect(setupModal(page).getByRole("button", { name: "Retry" })).toBeVisible();
await test.step("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 another project…", exact: true }),
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();
@@ -142,7 +146,7 @@ test("setup suggests the repos the user already ran agents in", async ({ page },
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
await expectSetupStep(page, "Add your projects");
await expect(projectSuggestion(page, scratchName)).toBeChecked();
await expect(projectSuggestion(page, scratchName)).toContainText("Directory not found");
await expect(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") });
});
@@ -198,24 +202,24 @@ test("setup choices carry over to Settings and the phone step pairs over relay",
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
await test.step("Paseo tools turned on in setup", async () => {
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))).not.toBeChecked();
await paseoToolsSwitch(setupModal(page)).click();
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
await continueSetup(page);
});
await test.step("Phone asks for relay consent, then shows the pairing QR", async () => {
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 expect(setupModal(page).getByText("Enable relay?", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("5-phone-consent.png") });
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 () => {
@@ -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") ?? "")
+31 -7
View File
@@ -33,7 +33,7 @@ export async function openAppAsNewUser(
}
export function setupModal(page: Page): Locator {
return page.getByRole("dialog").filter({ hasText: "Set up Paseo" });
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
export async function expectSetupStep(page: Page, title: string): Promise<void> {
@@ -85,7 +85,20 @@ export async function finishSetup(page: Page): Promise<void> {
await expect(setupModal(page)).toHaveCount(0);
}
/** Clicks the Tailscale docs link in the Phone step and returns the page it opened. */
/** 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()
@@ -93,7 +106,7 @@ export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
);
const popup = page.waitForEvent("popup");
await setupModal(page).getByRole("link", { name: "Read how to connect over Tailscale" }).click();
await setupModal(page).getByRole("link", { name: "Tailscale, Docs", exact: true }).click();
const docs = await popup;
await docs.waitForLoadState("domcontentloaded");
const url = docs.url();
@@ -109,8 +122,9 @@ export interface SetupHost {
/**
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
* never depend on the machine running the test. `withBrokenProvider` adds a provider that cannot
* start.
* 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,
@@ -122,9 +136,10 @@ export async function startSetupHost(
): 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, CLAUDE_CONFIG_DIR: claudeConfigDir },
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
mutableRelay: options.mutableRelay,
});
return {
@@ -133,6 +148,7 @@ export async function startSetupHost(
close: async () => {
await daemon.close();
await rm(claudeConfigDir, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
},
};
}
@@ -182,8 +198,16 @@ function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
};
}
/** The switch that picks a suggested project for adding. */
export function projectSuggestion(page: Page, name: string): Locator {
return setupModal(page).getByRole("checkbox", { name, exact: true });
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. */
@@ -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>
+124 -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,49 @@ 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";
}
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 +86,108 @@ 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,
}: {
state: AgentSkillsStatus["state"] | null;
isWorking: boolean;
onReconcile: () => void;
onInstall: () => void;
onUninstall: () => void;
skills: AgentSkillsController;
value: boolean;
onValueChange: (next: boolean) => void;
}) {
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={isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
) : null}
<Button
variant="ghost"
leftIcon={chooseIcon}
onPress={handleOpen}
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={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,
},
}));
@@ -11,6 +11,8 @@ import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
export type AgentSkillsController = ReturnType<typeof useAgentSkills>;
export function useAgentSkills(serverId: string) {
const { t } = useTranslation();
const toast = useToast();
@@ -83,12 +85,23 @@ export function useAgentSkills(serverId: string) {
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const runReconcile = useCallback(async () => {
await reconcileAsync().catch(() => undefined);
}, [reconcileAsync]);
const runUninstall = useCallback(async () => {
await uninstallAsync().catch(() => undefined);
}, [uninstallAsync]);
// 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],
);
const runUninstall = useCallback(
() =>
uninstallAsync().then(
(status) => status.state === "not-installed",
() => false,
),
[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>
+36 -24
View File
@@ -22,8 +22,7 @@ const PICKER_THEME_OPTIONS = [
...THEME_OPTIONS.filter((option) => option.name !== "auto"),
];
const PREVIEW_WIDTH = 128;
const PREVIEW_HEIGHT = 72;
const PREVIEW_ASPECT_RATIO = 16 / 9;
/**
* Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies
@@ -134,21 +133,27 @@ function ThemeTile({
[selected],
);
return (
<PressHighlight
style={tileStyle}
onPress={onPress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={styles.previewFrame}>
{previewTheme === "system" ? <SystemThemePreview /> : <ThemePreview theme={previewTheme} />}
</View>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
<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>
);
}
@@ -197,13 +202,18 @@ function SystemThemePreview() {
}
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",
gap: theme.spacing[3],
margin: -theme.spacing[3] / 2,
},
cell: {
width: { xs: "50%", md: "25%" },
padding: theme.spacing[3] / 2,
},
tile: {
width: PREVIEW_WIDTH + theme.spacing[1] * 2 + theme.borderWidth[2] * 2,
padding: theme.spacing[1],
gap: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
@@ -228,8 +238,8 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
},
preview: {
width: PREVIEW_WIDTH,
height: PREVIEW_HEIGHT,
width: "100%",
aspectRatio: PREVIEW_ASPECT_RATIO,
flexDirection: "row",
overflow: "hidden",
},
@@ -270,11 +280,13 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute",
top: 0,
bottom: 0,
left: PREVIEW_WIDTH / 2,
width: PREVIEW_WIDTH / 2,
left: "50%",
width: "50%",
overflow: "hidden",
},
// A full-width dark preview shifted left by half, so only its right half shows.
systemDarkOffset: {
marginLeft: -PREVIEW_WIDTH / 2,
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>
);
@@ -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 "./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=""
@@ -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"
@@ -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,
@@ -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;
}
@@ -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)",
@@ -41,7 +41,6 @@ export function MessagePreview({ children }: { children: ReactNode }) {
{overflowing ? (
<Button
variant="ghost"
size="sm"
style={[styles.toggle, expanded && styles.expandedToggle]}
accessibilityState={accessibilityState}
onPress={toggle}
@@ -77,7 +76,6 @@ export function MessageSender({
return (
<Button
variant="ghost"
size="sm"
leftIcon={renderProviderIcon}
style={styles.sender}
textStyle={styles.senderText}
@@ -306,7 +306,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<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,7 +22,8 @@ import {
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
const TAILSCALE_DOCS_URL = "https://paseo.sh/docs/connectivity#tailscale";
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);
@@ -170,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>
@@ -262,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>
@@ -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>;
@@ -5,6 +5,7 @@ import {
getCliInstallStatus,
installCli,
shouldUseDesktopDaemon,
uninstallCli,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
import {
@@ -14,60 +15,73 @@ 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. */
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 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;
const setInstalled = useCallback(
(installed: boolean) =>
mutateAsync(installed).then(
(status) => status.installed === installed,
() => false,
),
[mutateAsync],
);
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}
>
@@ -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>
@@ -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,
@@ -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"
+17 -49
View File
@@ -1541,6 +1541,7 @@ export const ar: TranslationResources = {
cli: {
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
installFailed: "غير قادر على تثبيت PaseoCLI.",
uninstallFailed: "تعذر إلغاء تثبيت Paseo CLI.",
},
},
},
@@ -1642,8 +1643,7 @@ export const ar: TranslationResources = {
},
onboarding: {
setup: {
title: "إعداد Paseo",
footnote: "كل ما هنا موجود أيضًا في الإعدادات",
title: "Paseo",
stepCounter: "الخطوة {{current}} من {{total}}",
skip: "تخطي الإعداد",
continue: "متابعة",
@@ -1652,30 +1652,23 @@ export const ar: TranslationResources = {
appearance: {
label: "المظهر",
title: "مرحبًا بك في Paseo",
description:
"شغّل Claude Code وCodex ووكلاء آخرين على هذا الكمبيوتر، من مكتبك أو من هاتفك. اختر مظهرًا أولًا.",
description: "لنبدأ باختيار المظهر",
},
providers: {
label: "المزوّدون",
title: "وكلاؤك",
description:
"يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر. أوقف ما لا تريده في منتقي الوكلاء.",
description: "يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر",
},
projects: {
label: "المشاريع",
title: "أضف مشاريعك",
description:
"أضف المجلدات التي تريد أن يعمل فيها الوكلاء. يمكنك إضافة المزيد لاحقًا من الشريط الجانبي.",
description: "أين تريد أن يعمل وكلاؤك",
addProject: "إضافة مشروع",
addAnotherProject: "إضافة مشروع آخر…",
added: "تمت الإضافة",
suggestions: {
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
stillReading: "ما زلنا نقرأ سجل {{providers}}…",
readFailed: "تعذّرت قراءة سجل {{provider}}",
empty: "لم يتم العثور على مشاريع حديثة",
sessions_one: "جلسة واحدة ({{count}})",
sessions_other: "{{count}} جلسات",
lastUsed: "آخر استخدام {{time}}",
addSelected_one: "إضافة مشروع واحد ({{count}})",
addSelected_other: "إضافة {{count}} مشاريع",
adding: "جارٍ الإضافة…",
@@ -1685,35 +1678,21 @@ export const ar: TranslationResources = {
orchestration: {
label: "التنسيق",
title: "اسمح للوكلاء باستخدام Paseo",
description:
"باستخدام أدوات Paseo، يمكن للوكيل تسليم العمل لوكلاء آخرين وإنشاء مساحات العمل والجداول بنفسه.",
capabilities: {
agents: {
title: "تشغيل وكلاء آخرين وتوجيههم",
description:
"شغّل وكلاء Codex أو Claude Code أو OpenCode وأرسل لهم الطلبات وانتظر نتائجهم.",
},
workspaces: {
title: "إنشاء مساحات عمل",
description: "افتح worktree لكل مهمة حتى لا يتعارض الوكلاء العاملون بالتوازي.",
},
schedules: {
title: "الجداول ونبضات المتابعة",
description: "تحقق من CI كل 10 دقائق وأصلح الإخفاقات.",
},
terminals: {
title: "الطرفيات والسكربتات",
description: "شغّل خادم التطوير لمساحة العمل واقرأ مخرجاته.",
},
},
description: "اسمح لوكلائك بإنشاء مساحات عمل والتحدث مع وكلاء آخرين",
},
phone: {
label: "الهاتف",
label: "الجوال",
title: "خذ وكلاءك معك",
description:
"يتصل Paseo لنظامي iOS وAndroid بهذا الكمبيوتر. يواصل الوكلاء العمل هنا، ويتابعهم هاتفك ويوافق على الأذونات ويرسل الطلبات.",
"يتيح لك تطبيق الجوال الأصلي كامل الميزات التحكم في أي كمبيوتر يعمل عليه Paseo من هاتفك",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "اختر طريقة الاتصال",
connectDocs: "الوثائق",
openConnectDocs: "افتح وثائق الاتصال",
recommended: "موصى به",
relay: { title: "التتابع", hint: "مشفّر من طرف إلى طرف، امسح رمز QR" },
tailscale: { title: "Tailscale", hint: "يتطلب بعض الإعداد" },
},
},
},
@@ -2728,11 +2707,7 @@ export const ar: TranslationResources = {
commandLine: {
title: "سطر الأوامر",
description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك",
},
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
accessibilityLabel: "تثبيت Paseo CLI",
},
},
permissions: {
@@ -2810,12 +2785,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: "اختيار المهارات",
chooseAll: "كل المهارات",
chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.",
@@ -2830,13 +2802,9 @@ export const ar: TranslationResources = {
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
update: "تحديث",
working: "عمل...",
remove: "إزالة",
uninstall: "إلغاء التثبيت",
save: "حفظ",
saving: "جارٍ الحفظ...",
cancel: "يلغي",
+17 -50
View File
@@ -1565,6 +1565,7 @@ 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.",
},
},
},
@@ -1667,8 +1668,7 @@ export const en = {
},
onboarding: {
setup: {
title: "Set up Paseo",
footnote: "Everything here is also in Settings",
title: "Paseo",
stepCounter: "Step {{current}} of {{total}}",
skip: "Skip setup",
continue: "Continue",
@@ -1677,30 +1677,23 @@ export const en = {
appearance: {
label: "Appearance",
title: "Welcome to Paseo",
description:
"Run Claude Code, Codex and other agents on this computer, from your desk or your phone. First, pick a look.",
description: "Let's start by picking a look",
},
providers: {
label: "Providers",
title: "Your agents",
description:
"Paseo runs the agent CLIs already on this computer. Turn off any you don't want in the agent picker.",
description: "Paseo runs the agent CLIs already on this computer",
},
projects: {
label: "Projects",
title: "Add your projects",
description:
"Add the folders you want agents to work in. You can add more later from the sidebar.",
description: "Where do you want your agents to work on",
addProject: "Add project",
addAnotherProject: "Add another project…",
added: "Added",
suggestions: {
loading: "Looking for folders you've worked in",
stillReading: "Still reading {{providers}} history…",
readFailed: "Couldn't read {{provider}} history",
empty: "No recent projects found",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
lastUsed: "Last used {{time}}",
addSelected_one: "Add {{count}} project",
addSelected_other: "Add {{count}} projects",
adding: "Adding…",
@@ -1710,35 +1703,21 @@ export const en = {
orchestration: {
label: "Orchestration",
title: "Let agents use Paseo",
description:
"With Paseo tools, an agent can hand work to other agents and set up its own workspaces and schedules.",
capabilities: {
agents: {
title: "Start and steer other agents",
description:
"Launch Codex, Claude Code or OpenCode agents, send them prompts and wait for their results.",
},
workspaces: {
title: "Create workspaces",
description: "Open a worktree for each task so parallel agents don't collide.",
},
schedules: {
title: "Schedules and heartbeats",
description: "Check CI every 10 minutes and fix failures.",
},
terminals: {
title: "Terminals and scripts",
description: "Run your workspace's dev server and read its output.",
},
},
description: "Let your agents create workspaces and talk to other agents",
},
phone: {
label: "Phone",
label: "Mobile",
title: "Take your agents with you",
description:
"Paseo for iOS and Android connects to this computer. Agents keep running here; your phone watches them, approves permissions and sends prompts.",
"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" },
},
},
},
@@ -2816,11 +2795,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: {
@@ -2897,7 +2872,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",
@@ -2908,10 +2883,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.",
choose: "Choose skills",
chooseAll: "All skills",
chooseAllHint: "Keep every bundled skill installed, including ones added later.",
@@ -2922,13 +2893,9 @@ 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",
uninstall: "Uninstall",
save: "Save",
saving: "Saving...",
cancel: "Cancel",
+17 -52
View File
@@ -1586,6 +1586,7 @@ 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.",
},
},
},
@@ -1688,8 +1689,7 @@ export const es: TranslationResources = {
},
onboarding: {
setup: {
title: "Configurar Paseo",
footnote: "Todo lo de aquí también está en Ajustes",
title: "Paseo",
stepCounter: "Paso {{current}} de {{total}}",
skip: "Omitir configuración",
continue: "Continuar",
@@ -1698,30 +1698,23 @@ export const es: TranslationResources = {
appearance: {
label: "Apariencia",
title: "Te damos la bienvenida a Paseo",
description:
"Ejecuta Claude Code, Codex y otros agentes en este ordenador, desde tu escritorio o tu teléfono. Primero, elige un aspecto.",
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. Desactiva las que no quieras ver en el selector de agentes.",
description: "Paseo usa las CLI de agentes que ya están en este ordenador",
},
projects: {
label: "Proyectos",
title: "Añade tus proyectos",
description:
"Añade las carpetas en las que quieres que trabajen los agentes. Puedes añadir más después desde la barra lateral.",
description: "Dónde quieres que trabajen tus agentes",
addProject: "Añadir proyecto",
addAnotherProject: "Añadir otro proyecto…",
added: "Añadido",
suggestions: {
loading: "Buscando carpetas en las que has trabajado",
stillReading: "Todavía leyendo el historial de {{providers}}…",
readFailed: "No se pudo leer el historial de {{provider}}",
empty: "No se encontraron proyectos recientes",
sessions_one: "{{count}} sesión",
sessions_other: "{{count}} sesiones",
lastUsed: "Último uso {{time}}",
addSelected_one: "Añadir {{count}} proyecto",
addSelected_other: "Añadir {{count}} proyectos",
adding: "Añadiendo…",
@@ -1731,37 +1724,21 @@ export const es: TranslationResources = {
orchestration: {
label: "Orquestación",
title: "Deja que los agentes usen Paseo",
description:
"Con las herramientas de Paseo, un agente puede pasar trabajo a otros agentes y crear sus propios espacios de trabajo y programaciones.",
capabilities: {
agents: {
title: "Iniciar y dirigir otros agentes",
description:
"Lanza agentes de Codex, Claude Code u OpenCode, envíales instrucciones y espera sus resultados.",
},
workspaces: {
title: "Crear espacios de trabajo",
description:
"Abre un worktree por tarea para que los agentes en paralelo no choquen.",
},
schedules: {
title: "Programaciones y heartbeats",
description: "Revisa la CI cada 10 minutos y corrige los fallos.",
},
terminals: {
title: "Terminales y scripts",
description:
"Ejecuta el servidor de desarrollo de tu espacio de trabajo y lee su salida.",
},
},
description: "Deja que tus agentes creen espacios de trabajo y hablen con otros agentes",
},
phone: {
label: "Teléfono",
label: "Móvil",
title: "Lleva tus agentes contigo",
description:
"Paseo para iOS y Android se conecta a este ordenador. Los agentes siguen ejecutándose aquí; tu teléfono los supervisa, aprueba permisos y envía instrucciones.",
"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" },
},
},
},
@@ -2751,11 +2728,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: {
@@ -2833,13 +2806,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.",
choose: "Elegir habilidades",
chooseAll: "Todas las habilidades",
chooseAllHint:
@@ -2855,13 +2824,9 @@ 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",
uninstall: "Desinstalar",
save: "Guardar",
saving: "Guardando...",
cancel: "Cancelar",
+17 -51
View File
@@ -1582,6 +1582,7 @@ 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.",
},
},
},
@@ -1686,8 +1687,7 @@ export const fr: TranslationResources = {
},
onboarding: {
setup: {
title: "Configurer Paseo",
footnote: "Tout ceci se trouve aussi dans les Réglages",
title: "Paseo",
stepCounter: "Étape {{current}} sur {{total}}",
skip: "Passer la configuration",
continue: "Continuer",
@@ -1696,30 +1696,23 @@ export const fr: TranslationResources = {
appearance: {
label: "Apparence",
title: "Bienvenue dans Paseo",
description:
"Lancez Claude Code, Codex et d'autres agents sur cet ordinateur, depuis votre bureau ou votre téléphone. Choisissez d'abord un style.",
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. Désactivez celles que vous ne voulez pas dans le sélecteur d'agents.",
description: "Paseo utilise les CLI d'agents déjà installées sur cet ordinateur",
},
projects: {
label: "Projets",
title: "Ajoutez vos projets",
description:
"Ajoutez les dossiers dans lesquels les agents doivent travailler. Vous pourrez en ajouter d'autres depuis la barre latérale.",
description: "Où voulez-vous que vos agents travaillent",
addProject: "Ajouter un projet",
addAnotherProject: "Ajouter un autre projet…",
added: "Ajouté",
suggestions: {
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
stillReading: "Lecture de l'historique {{providers}} en cours…",
readFailed: "Impossible de lire l'historique {{provider}}",
empty: "Aucun projet récent trouvé",
sessions_one: "{{count}} session",
sessions_other: "{{count}} sessions",
lastUsed: "Dernière utilisation {{time}}",
addSelected_one: "Ajouter {{count}} projet",
addSelected_other: "Ajouter {{count}} projets",
adding: "Ajout…",
@@ -1730,36 +1723,21 @@ export const fr: TranslationResources = {
label: "Orchestration",
title: "Laissez les agents utiliser Paseo",
description:
"Avec les outils Paseo, un agent peut confier du travail à d'autres agents et créer ses propres espaces de travail et planifications.",
capabilities: {
agents: {
title: "Lancer et piloter d'autres agents",
description:
"Lancez des agents Codex, Claude Code ou OpenCode, envoyez-leur des instructions et attendez leurs résultats.",
},
workspaces: {
title: "Créer des espaces de travail",
description:
"Ouvrez un worktree par tâche pour que les agents en parallèle ne se gênent pas.",
},
schedules: {
title: "Planifications et heartbeats",
description: "Vérifier la CI toutes les 10 minutes et corriger les échecs.",
},
terminals: {
title: "Terminaux et scripts",
description:
"Lancez le serveur de développement de votre espace de travail et lisez sa sortie.",
},
},
"Laissez vos agents créer des espaces de travail et parler à d'autres agents",
},
phone: {
label: "Téléphone",
label: "Mobile",
title: "Emportez vos agents partout",
description:
"Paseo pour iOS et Android se connecte à cet ordinateur. Les agents continuent de tourner ici ; votre téléphone les suit, approuve les permissions et envoie des instructions.",
"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" },
},
},
},
@@ -2753,11 +2731,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: {
@@ -2836,13 +2810,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.",
choose: "Choisir les skills",
chooseAll: "Tous les skills",
chooseAllHint:
@@ -2858,13 +2828,9 @@ 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",
uninstall: "Désinstaller",
save: "Enregistrer",
saving: "Enregistrement…",
cancel: "Annuler",
+17 -50
View File
@@ -1557,6 +1557,7 @@ export const ja: TranslationResources = {
cli: {
statusFailed: "CLIのインストール状態を確認できません。",
installFailed: "Paseo CLIをインストールできません。",
uninstallFailed: "Paseo CLI をアンインストールできません。",
},
},
},
@@ -1659,8 +1660,7 @@ export const ja: TranslationResources = {
},
onboarding: {
setup: {
title: "Paseo のセットアップ",
footnote: "ここでの設定はすべて設定画面でも変更できます",
title: "Paseo",
stepCounter: "ステップ {{current}}/{{total}}",
skip: "セットアップをスキップ",
continue: "続ける",
@@ -1669,30 +1669,23 @@ export const ja: TranslationResources = {
appearance: {
label: "外観",
title: "Paseo へようこそ",
description:
"このコンピューターで Claude Code、Codex などのエージェントを、デスクからでもスマートフォンからでも実行できます。まずは見た目を選びましょう。",
description: "まずは外観を選びましょう",
},
providers: {
label: "プロバイダー",
title: "エージェント",
description:
"Paseo はこのコンピューターにインストール済みのエージェント CLI を使います。エージェント選択に表示したくないものはオフにしてください。",
description: "Paseo はこのコンピューターにインストール済みのエージェント CLI を使います",
},
projects: {
label: "プロジェクト",
title: "プロジェクトを追加",
description:
"エージェントに作業させるフォルダーを追加します。後からサイドバーで追加することもできます。",
description: "エージェントにどこで作業させますか",
addProject: "プロジェクトを追加",
addAnotherProject: "別のプロジェクトを追加…",
added: "追加済み",
suggestions: {
loading: "作業したフォルダーを探しています",
stillReading: "{{providers}} の履歴を読み込んでいます…",
readFailed: "{{provider}} の履歴を読み込めませんでした",
empty: "最近のプロジェクトは見つかりませんでした",
sessions_one: "{{count}} セッション",
sessions_other: "{{count}} セッション",
lastUsed: "最終使用 {{time}}",
addSelected_one: "{{count}} 件のプロジェクトを追加",
addSelected_other: "{{count}} 件のプロジェクトを追加",
adding: "追加しています…",
@@ -1703,35 +1696,21 @@ export const ja: TranslationResources = {
label: "オーケストレーション",
title: "エージェントに Paseo を使わせる",
description:
"Paseo ツールを使うと、エージェントは他のエージェントに作業を任せたり、自分でワークスペースやスケジュールを作成したりできます。",
capabilities: {
agents: {
title: "他のエージェントを起動・操作",
description:
"Codex、Claude Code、OpenCode のエージェントを起動し、プロンプトを送って結果を待ちます。",
},
workspaces: {
title: "ワークスペースを作成",
description:
"タスクごとに worktree を作成し、並行するエージェント同士の衝突を防ぎます。",
},
schedules: {
title: "スケジュールとハートビート",
description: "10 分ごとに CI を確認して失敗を修正する。",
},
terminals: {
title: "ターミナルとスクリプト",
description: "ワークスペースの開発サーバーを起動し、その出力を読み取ります。",
},
},
"エージェントがワークスペースを作成し、ほかのエージェントとやり取りできるようにします",
},
phone: {
label: "スマートフォン",
label: "モバイル",
title: "エージェントを持ち歩く",
description:
"iOS と Android 版 Paseo はこのコンピューターに接続します。エージェントはここで動き続け、スマートフォンで状況を確認し、権限を承認し、プロンプトを送れます。",
"フル機能のネイティブモバイルアプリで、スマートフォンからどの Paseo コンピューターでも操作できます",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "接続方法を選択",
connectDocs: "ドキュメント",
openConnectDocs: "接続のドキュメントを開く",
recommended: "おすすめ",
relay: { title: "リレー", hint: "エンドツーエンド暗号化、QR コードをスキャン" },
tailscale: { title: "Tailscale", hint: "少し設定が必要です" },
},
},
},
@@ -2716,11 +2695,7 @@ export const ja: TranslationResources = {
commandLine: {
title: "コマンドライン",
description: "ターミナルからエージェントを制御し、スクリプトで操作",
},
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
accessibilityLabel: "Paseo CLI をインストール",
},
},
permissions: {
@@ -2799,13 +2774,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オーケストレーションスキルを削除します。",
choose: "スキルを選択",
chooseAll: "すべてのスキル",
chooseAllHint:
@@ -2821,13 +2792,9 @@ export const ja: TranslationResources = {
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
update: "更新",
working: "処理中...",
remove: "削除",
uninstall: "アンインストール",
save: "保存",
saving: "保存中...",
cancel: "キャンセル",
+17 -50
View File
@@ -1550,6 +1550,7 @@ export const ko: TranslationResources = {
cli: {
statusFailed: "CLI 설치 상태를 확인할 수 없습니다.",
installFailed: "Paseo CLI를 설치할 수 없습니다.",
uninstallFailed: "Paseo CLI를 제거할 수 없습니다.",
},
},
},
@@ -1652,8 +1653,7 @@ export const ko: TranslationResources = {
},
onboarding: {
setup: {
title: "Paseo 설정",
footnote: "여기의 모든 항목은 설정에서도 변경할 수 있습니다",
title: "Paseo",
stepCounter: "{{total}}단계 중 {{current}}단계",
skip: "설정 건너뛰기",
continue: "계속",
@@ -1662,30 +1662,23 @@ export const ko: TranslationResources = {
appearance: {
label: "모양",
title: "Paseo에 오신 것을 환영합니다",
description:
"이 컴퓨터에서 Claude Code, Codex 등 여러 에이전트를 책상에서도, 휴대폰에서도 실행하세요. 먼저 모양을 고르세요.",
description: "먼저 모양을 골라 보세요",
},
providers: {
label: "제공자",
title: "에이전트",
description:
"Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다. 에이전트 선택기에 표시하고 싶지 않은 항목은 끄세요.",
description: "Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다",
},
projects: {
label: "프로젝트",
title: "프로젝트 추가",
description:
"에이전트가 작업할 폴더를 추가하세요. 나중에 사이드바에서 더 추가할 수 있습니다.",
description: "에이전트가 어디에서 작업하길 원하나요",
addProject: "프로젝트 추가",
addAnotherProject: "다른 프로젝트 추가…",
added: "추가됨",
suggestions: {
loading: "작업했던 폴더를 찾는 중",
stillReading: "{{providers}} 기록을 아직 읽는 중…",
readFailed: "{{provider}} 기록을 읽지 못했습니다",
empty: "최근 프로젝트를 찾지 못했습니다",
sessions_one: "세션 {{count}}개",
sessions_other: "세션 {{count}}개",
lastUsed: "마지막 사용 {{time}}",
addSelected_one: "프로젝트 {{count}}개 추가",
addSelected_other: "프로젝트 {{count}}개 추가",
adding: "추가하는 중…",
@@ -1695,35 +1688,21 @@ export const ko: TranslationResources = {
orchestration: {
label: "오케스트레이션",
title: "에이전트가 Paseo를 사용하도록 허용",
description:
"Paseo 도구를 사용하면 에이전트가 다른 에이전트에게 작업을 넘기고 직접 워크스페이스와 일정을 만들 수 있습니다.",
capabilities: {
agents: {
title: "다른 에이전트 시작 및 조정",
description:
"Codex, Claude Code, OpenCode 에이전트를 시작하고 프롬프트를 보낸 뒤 결과를 기다립니다.",
},
workspaces: {
title: "워크스페이스 만들기",
description: "작업마다 worktree를 열어 병렬 에이전트끼리 충돌하지 않게 합니다.",
},
schedules: {
title: "일정과 하트비트",
description: "10분마다 CI를 확인하고 실패를 수정합니다.",
},
terminals: {
title: "터미널과 스크립트",
description: "워크스페이스의 개발 서버를 실행하고 출력을 읽습니다.",
},
},
description: "에이전트가 워크스페이스를 만들고 다른 에이전트와 대화하게 하세요",
},
phone: {
label: "휴대폰",
label: "모바일",
title: "어디서나 에이전트와 함께",
description:
"iOS 및 Android용 Paseo가 이 컴퓨터에 연결됩니다. 에이전트는 여기서 계속 실행되고, 휴대폰으로 상태를 보고 권한을 승인하며 프롬프트를 보냅니다.",
"모든 기능을 갖춘 네이티브 모바일 앱으로 휴대폰에서 어떤 Paseo 컴퓨터든 제어할 수 있습니다",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "연결 방법 선택",
connectDocs: "문서",
openConnectDocs: "연결 문서 열기",
recommended: "권장",
relay: { title: "릴레이", hint: "엔드투엔드 암호화, QR 코드를 스캔하세요" },
tailscale: { title: "Tailscale", hint: "약간의 설정이 필요합니다" },
},
},
},
@@ -2704,11 +2683,7 @@ export const ko: TranslationResources = {
commandLine: {
title: "명령줄",
description: "터미널에서 에이전트를 제어하고 스크립팅합니다",
},
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
accessibilityLabel: "Paseo CLI 설치",
},
},
permissions: {
@@ -2786,13 +2761,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 오케스트레이션 스킬을 제거합니다.",
choose: "스킬 선택",
chooseAll: "모든 스킬",
chooseAllHint: "나중에 추가되는 항목을 포함하여 모든 번들 스킬을 설치된 상태로 유지합니다.",
@@ -2807,13 +2778,9 @@ export const ko: TranslationResources = {
uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.",
saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.",
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
update: "업데이트",
working: "작업 중...",
remove: "제거",
uninstall: "제거",
save: "저장",
saving: "저장 중...",
cancel: "취소",
+16 -50
View File
@@ -1571,6 +1571,7 @@ 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.",
},
},
},
@@ -1673,8 +1674,7 @@ export const ptBR: TranslationResources = {
},
onboarding: {
setup: {
title: "Configurar o Paseo",
footnote: "Tudo aqui também está em Configurações",
title: "Paseo",
stepCounter: "Etapa {{current}} de {{total}}",
skip: "Pular configuração",
continue: "Continuar",
@@ -1683,30 +1683,23 @@ export const ptBR: TranslationResources = {
appearance: {
label: "Aparência",
title: "Boas-vindas ao Paseo",
description:
"Execute Claude Code, Codex e outros agentes neste computador, da sua mesa ou do seu celular. Primeiro, escolha um visual.",
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. Desative as que você não quer no seletor de agentes.",
description: "O Paseo usa as CLIs de agentes já instaladas neste computador",
},
projects: {
label: "Projetos",
title: "Adicione seus projetos",
description:
"Adicione as pastas em que os agentes devem trabalhar. Você pode adicionar mais depois pela barra lateral.",
description: "Onde você quer que seus agentes trabalhem",
addProject: "Adicionar projeto",
addAnotherProject: "Adicionar outro projeto…",
added: "Adicionado",
suggestions: {
loading: "Procurando pastas em que você trabalhou",
stillReading: "Ainda lendo o histórico de {{providers}}…",
readFailed: "Não foi possível ler o histórico de {{provider}}",
empty: "Nenhum projeto recente encontrado",
sessions_one: "{{count}} sessão",
sessions_other: "{{count}} sessões",
lastUsed: "Último uso {{time}}",
addSelected_one: "Adicionar {{count}} projeto",
addSelected_other: "Adicionar {{count}} projetos",
adding: "Adicionando…",
@@ -1716,36 +1709,21 @@ export const ptBR: TranslationResources = {
orchestration: {
label: "Orquestração",
title: "Deixe os agentes usarem o Paseo",
description:
"Com as ferramentas do Paseo, um agente pode passar trabalho para outros agentes e criar seus próprios workspaces e agendamentos.",
capabilities: {
agents: {
title: "Iniciar e guiar outros agentes",
description:
"Inicie agentes do Codex, Claude Code ou OpenCode, envie prompts e aguarde os resultados.",
},
workspaces: {
title: "Criar workspaces",
description:
"Abra um worktree por tarefa para que agentes em paralelo não entrem em conflito.",
},
schedules: {
title: "Agendamentos e heartbeats",
description: "Verificar a CI a cada 10 minutos e corrigir falhas.",
},
terminals: {
title: "Terminais e scripts",
description: "Execute o servidor de desenvolvimento do workspace e leia a saída.",
},
},
description: "Deixe seus agentes criarem workspaces e conversarem com outros agentes",
},
phone: {
label: "Celular",
title: "Leve seus agentes com você",
description:
"O Paseo para iOS e Android se conecta a este computador. Os agentes continuam rodando aqui; seu celular os acompanha, aprova permissões e envia prompts.",
"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" },
},
},
},
@@ -2731,11 +2709,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: {
@@ -2813,13 +2787,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.",
choose: "Escolher skills",
chooseAll: "Todas as skills",
chooseAllHint:
@@ -2835,13 +2805,9 @@ 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",
uninstall: "Desinstalar",
save: "Salvar",
saving: "Salvando...",
cancel: "Cancelar",
+17 -50
View File
@@ -1568,6 +1568,7 @@ export const ru: TranslationResources = {
cli: {
statusFailed: "Не удалось проверить состояние установки CLI.",
installFailed: "Не удалось установить Paseo CLI.",
uninstallFailed: "Не удалось удалить Paseo CLI.",
},
},
},
@@ -1671,8 +1672,7 @@ export const ru: TranslationResources = {
},
onboarding: {
setup: {
title: "Настройка Paseo",
footnote: "Всё это также есть в настройках",
title: "Paseo",
stepCounter: "Шаг {{current}} из {{total}}",
skip: "Пропустить настройку",
continue: "Продолжить",
@@ -1681,30 +1681,23 @@ export const ru: TranslationResources = {
appearance: {
label: "Оформление",
title: "Добро пожаловать в Paseo",
description:
"Запускайте Claude Code, Codex и других агентов на этом компьютере — за столом или с телефона. Сначала выберите оформление.",
description: "Начнём с выбора оформления",
},
providers: {
label: "Провайдеры",
title: "Ваши агенты",
description:
"Paseo использует CLI агентов, уже установленные на этом компьютере. Отключите те, которые не нужны в списке агентов.",
description: "Paseo использует CLI агентов, уже установленные на этом компьютере",
},
projects: {
label: "Проекты",
title: "Добавьте проекты",
description:
"Добавьте папки, в которых будут работать агенты. Позже можно добавить ещё из боковой панели.",
description: "Где будут работать ваши агенты",
addProject: "Добавить проект",
addAnotherProject: "Добавить другой проект…",
added: "Добавлено",
suggestions: {
loading: "Ищем папки, в которых вы работали",
stillReading: "Ещё читаем историю {{providers}}…",
readFailed: "Не удалось прочитать историю {{provider}}",
empty: "Недавних проектов не найдено",
sessions_one: "{{count}} сессия",
sessions_other: "Сессий: {{count}}",
lastUsed: "Последнее использование {{time}}",
addSelected_one: "Добавить {{count}} проект",
addSelected_other: "Добавить проекты: {{count}}",
adding: "Добавляем…",
@@ -1715,35 +1708,21 @@ export const ru: TranslationResources = {
label: "Оркестрация",
title: "Дайте агентам доступ к Paseo",
description:
"С инструментами Paseo агент может передавать работу другим агентам и сам создавать рабочие пространства и расписания.",
capabilities: {
agents: {
title: "Запуск других агентов и управление ими",
description:
"Запускайте агентов Codex, Claude Code или OpenCode, отправляйте им запросы и ждите результатов.",
},
workspaces: {
title: "Создание рабочих пространств",
description:
"Отдельный worktree для каждой задачи, чтобы параллельные агенты не мешали друг другу.",
},
schedules: {
title: "Расписания и heartbeat",
description: "Проверять CI каждые 10 минут и исправлять сбои.",
},
terminals: {
title: "Терминалы и скрипты",
description: "Запускайте dev-сервер рабочего пространства и читайте его вывод.",
},
},
"Разрешите агентам создавать рабочие пространства и общаться с другими агентами",
},
phone: {
label: "Телефон",
label: "Мобильный",
title: "Агенты всегда под рукой",
description:
"Paseo для iOS и Android подключается к этому компьютеру. Агенты продолжают работать здесь, а телефон следит за ними, одобряет разрешения и отправляет запросы.",
"Полнофункциональное нативное мобильное приложение позволяет управлять любым компьютером с Paseo с телефона",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "Выберите способ подключения",
connectDocs: "Документация",
openConnectDocs: "Открыть документацию по подключению",
recommended: "Рекомендуется",
relay: { title: "Ретранслятор", hint: "Сквозное шифрование, отсканируйте QR-код" },
tailscale: { title: "Tailscale", hint: "Требует небольшой настройки" },
},
},
},
@@ -2737,11 +2716,7 @@ export const ru: TranslationResources = {
commandLine: {
title: "Командная строка",
description: "Управляйте агентами и автоматизируйте их работу из терминала",
},
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
accessibilityLabel: "Установить Paseo CLI",
},
},
permissions: {
@@ -2820,17 +2795,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 на этом хосте.",
choose: "Выбрать навыки",
chooseAll: "Все навыки",
chooseAllHint: "Устанавливать все встроенные навыки, включая добавленные позже.",
@@ -2845,13 +2816,9 @@ export const ru: TranslationResources = {
uninstallFailed: "Не удалось удалить навыки оркестрации.",
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестрации.",
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
update: "Обновить",
working: "Выполняется...",
remove: "Удалить",
uninstall: "Удалить",
save: "Сохранить",
saving: "Сохранение...",
cancel: "Отмена",
+17 -47
View File
@@ -1525,6 +1525,7 @@ export const zhCN: TranslationResources = {
cli: {
statusFailed: "无法检查 CLI 安装状态。",
installFailed: "无法安装 Paseo CLI。",
uninstallFailed: "无法卸载 Paseo CLI。",
},
},
},
@@ -1625,8 +1626,7 @@ export const zhCN: TranslationResources = {
},
onboarding: {
setup: {
title: "设置 Paseo",
footnote: "这里的所有选项也都在设置中",
title: "Paseo",
stepCounter: "第 {{current}} 步,共 {{total}} 步",
skip: "跳过设置",
continue: "继续",
@@ -1635,28 +1635,23 @@ export const zhCN: TranslationResources = {
appearance: {
label: "外观",
title: "欢迎使用 Paseo",
description:
"在这台电脑上运行 Claude Code、Codex 和其他代理,无论在桌前还是用手机。先选一个外观。",
description: "先选择一个外观",
},
providers: {
label: "提供方",
title: "你的代理",
description: "Paseo 使用这台电脑上已安装的代理 CLI。关闭不想在代理选择器中显示的项。",
description: "Paseo 使用这台电脑上已安装的代理 CLI",
},
projects: {
label: "项目",
title: "添加项目",
description: "添加你希望代理工作的文件夹。之后也可以从侧边栏添加更多。",
description: "你希望代理在哪里工作",
addProject: "添加项目",
addAnotherProject: "添加其他项目…",
added: "已添加",
suggestions: {
loading: "正在查找你工作过的文件夹",
stillReading: "仍在读取 {{providers}} 历史记录…",
readFailed: "无法读取 {{provider}} 历史记录",
empty: "未找到最近的项目",
sessions_one: "{{count}} 个会话",
sessions_other: "{{count}} 个会话",
lastUsed: "上次使用 {{time}}",
addSelected_one: "添加 {{count}} 个项目",
addSelected_other: "添加 {{count}} 个项目",
adding: "正在添加…",
@@ -1666,34 +1661,20 @@ export const zhCN: TranslationResources = {
orchestration: {
label: "编排",
title: "让代理使用 Paseo",
description:
"启用 Paseo 工具后,代理可以把工作交给其他代理,并自行创建工作区和计划任务。",
capabilities: {
agents: {
title: "启动并引导其他代理",
description: "启动 Codex、Claude Code 或 OpenCode 代理,向它们发送提示并等待结果。",
},
workspaces: {
title: "创建工作区",
description: "为每个任务打开一个 worktree,避免并行代理相互冲突。",
},
schedules: {
title: "计划任务和心跳",
description: "每 10 分钟检查一次 CI 并修复失败。",
},
terminals: {
title: "终端和脚本",
description: "运行工作区的开发服务器并读取输出。",
},
},
description: "让你的代理创建工作区并与其他代理对话",
},
phone: {
label: "手机",
label: "移动端",
title: "随身带上你的代理",
description:
"iOS 和 Android 版 Paseo 会连接到这台电脑。代理继续在这里运行,手机用来查看进度、批准权限和发送提示。",
description: "功能完整的原生移动应用让你可以在手机上控制任意一台 Paseo 电脑",
appStore: "App Store",
googlePlay: "Google Play",
connectTitle: "选择连接方式",
connectDocs: "文档",
openConnectDocs: "打开连接文档",
recommended: "推荐",
relay: { title: "中继", hint: "端到端加密,扫描二维码" },
tailscale: { title: "Tailscale", hint: "需要一些设置" },
},
},
},
@@ -2660,11 +2641,7 @@ export const zhCN: TranslationResources = {
commandLine: {
title: "命令行",
description: "从终端控制 Agent 并运行脚本",
},
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
accessibilityLabel: "安装 Paseo CLI",
},
},
permissions: {
@@ -2742,12 +2719,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",
chooseAll: "全部 skills",
chooseAllHint: "保持安装所有内置 skills,包括以后新增的。",
@@ -2762,13 +2736,9 @@ export const zhCN: TranslationResources = {
uninstallFailed: "无法卸载编排 skills。",
saveSelectionFailed: "无法保存编排 skills 的选择。",
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
update: "更新",
working: "处理中...",
remove: "移除",
uninstall: "卸载",
save: "保存",
saving: "正在保存...",
cancel: "取消",
@@ -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>
@@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
import {
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";
@@ -12,6 +16,7 @@ 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" }>;
@@ -29,6 +34,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
// 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();
@@ -41,36 +48,46 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
if (await addChecked()) model.next(visit);
})();
}, [addChecked, model, visit]);
const handleApplyOrchestration = useCallback(() => {
void (async () => {
if (await applyOrchestration()) model.next(visit);
})();
}, [applyOrchestration, model, visit]);
const primary = usePrimaryAction({
step,
isLast,
projectSuggestions,
orchestration,
onFinish: handleComplete,
onNext: handleNext,
onAddSuggestions: handleAddSuggestions,
onApplyOrchestration: handleApplyOrchestration,
});
const header = useMemo(
// 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={20} />,
subtitle: isCompact ? (
leading: <PaseoLogo size={LOGO_SIZE} />,
subtitle: (
<Text style={styles.stepCounter}>
{t("onboarding.setup.stepCounter", {
current: state.stepIndex + 1,
total: state.steps.length,
})}
</Text>
) : undefined,
),
}),
[isCompact, state.stepIndex, state.steps.length, t],
[state.stepIndex, state.steps.length, t],
);
const footer = (
const actions = (
<>
<View>
{isLast ? null : (
<Button variant="ghost" onPress={handleComplete}>
<Button variant="outline" onPress={handleComplete}>
{t("onboarding.setup.skip")}
</Button>
)}
@@ -92,8 +109,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
<AdaptiveModalSheet
visible
onClose={handleComplete}
header={header}
footer={footer}
header={isCompact ? compactHeader : undefined}
footer={isCompact ? actions : undefined}
scrollable={false}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={880}
@@ -103,25 +120,28 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
>
<View style={styles.body}>
{isCompact ? null : <StepRail state={state} onSelect={model.goTo} />}
<ScrollView style={styles.pane}>
{/* Padding lives on a wrapper: the ScrollView's contentContainerStyle skips Unistyles. */}
<View style={styles.paneContent}>
<View style={styles.stepHeader}>
<Text style={styles.stepTitle} accessibilityRole="header">
{t(`onboarding.setup.steps.${step}.title`)}
</Text>
<Text style={styles.stepDescription}>
{t(`onboarding.setup.steps.${step}.description`)}
</Text>
<View 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>
<OnboardingStepContent
step={step}
serverId={state.serverId}
projectSuggestions={projectSuggestions}
onFinish={handleComplete}
/>
</View>
</ScrollView>
</ScrollView>
{isCompact ? null : <SheetFooter>{actions}</SheetFooter>}
</View>
</View>
</AdaptiveModalSheet>
);
@@ -129,18 +149,30 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
/**
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
* every one was added; otherwise it continues, or finishes on the last step.
* every one was added. On Orchestration it applies the switches and moves on once they all took.
* Otherwise it continues, or finishes on the last step.
*/
function usePrimaryAction(input: {
step: OnboardingStepId;
isLast: boolean;
projectSuggestions: ProjectSuggestionsController;
orchestration: OrchestrationSetupController;
onFinish: () => void;
onNext: () => void;
onAddSuggestions: () => void;
onApplyOrchestration: () => void;
}): { label: string; busy: boolean; onPress: () => void } {
const { t } = useTranslation();
const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input;
const {
step,
isLast,
projectSuggestions,
orchestration,
onFinish,
onNext,
onAddSuggestions,
onApplyOrchestration,
} = input;
const { checkedCount, isAdding } = projectSuggestions;
if (isLast) {
return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish };
@@ -155,6 +187,13 @@ function usePrimaryAction(input: {
onPress: onAddSuggestions,
};
}
if (step === "orchestration") {
return {
label: t("onboarding.setup.continue"),
busy: orchestration.isApplying,
onPress: onApplyOrchestration,
};
}
return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext };
}
@@ -168,6 +207,12 @@ function StepRail({
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
@@ -180,7 +225,6 @@ function StepRail({
/>
))}
</View>
<Text style={styles.railFootnote}>{t("onboarding.setup.footnote")}</Text>
</View>
);
}
@@ -234,6 +278,7 @@ function StepRailItem({
}
const BADGE_SIZE = 20;
const LOGO_SIZE = 20;
const styles = StyleSheet.create((theme) => ({
content: {
@@ -248,11 +293,25 @@ const styles = StyleSheet.create((theme) => ({
rail: {
width: 200,
padding: theme.spacing[3],
justifyContent: "space-between",
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],
},
@@ -262,7 +321,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.full,
},
railItemHovered: {
backgroundColor: theme.colors.surface2,
@@ -291,30 +350,29 @@ const styles = StyleSheet.create((theme) => ({
},
railBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
railBadgeTextActive: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
railLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
railLabelCurrent: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
railFootnote: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[2],
},
pane: {
flex: 1,
minHeight: 0,
},
paneScroll: {
flex: 1,
minHeight: 0,
},
paneContent: {
padding: theme.spacing[6],
gap: theme.spacing[6],
@@ -0,0 +1,83 @@
import { useCallback, useState } from "react";
import { areSkillsInstalled, useAgentSkills, type AgentSkillsController } from "@/agent-skills";
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. */
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 skills = useAgentSkills(serverId);
const cli = useCliInstall();
const { config, patchConfig } = useDaemonConfig(serverId);
const [choices, setChoices] = useState<Choices>(ALL_ON);
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) => {
setChoices((current) => ({ ...current, [choice]: on }));
}, []);
const toolsOn = config?.mcp.injectIntoAgents !== false;
const skillsInstalled = areSkillsInstalled(skills.status);
const cliInstalled = cli.status?.installed ?? false;
const { reconcile, uninstall } = skills;
const { setInstalled: setCliInstalled } = cli;
const offersSkills = offered.skills;
const offersCli = offered.cli;
const setSkillsInstalled = useCallback(
() => (choices.skills ? reconcile() : uninstall()),
[choices.skills, reconcile, uninstall],
);
const apply = useCallback(async () => {
setIsApplying(true);
try {
const results = await Promise.all([
choices.tools === toolsOn
? true
: patchConfig({ mcp: { injectIntoAgents: choices.tools } }).then(Boolean, () => false),
!offersSkills || choices.skills === skillsInstalled ? true : setSkillsInstalled(),
!offersCli || choices.cli === cliInstalled ? true : setCliInstalled(choices.cli),
]);
return results.every(Boolean);
} finally {
setIsApplying(false);
}
}, [
choices,
toolsOn,
patchConfig,
offersSkills,
skillsInstalled,
setSkillsInstalled,
offersCli,
cliInstalled,
setCliInstalled,
]);
return { choices, setChoice, offered, skills, isApplying, apply };
}
@@ -1,5 +1,6 @@
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";
@@ -10,12 +11,12 @@ export function OnboardingStepContent({
step,
serverId,
projectSuggestions,
onFinish,
orchestration,
}: {
step: OnboardingStepId;
serverId: string;
projectSuggestions: ProjectSuggestionsController;
onFinish: () => void;
orchestration: OrchestrationSetupController;
}) {
switch (step) {
case "appearance":
@@ -25,8 +26,8 @@ export function OnboardingStepContent({
case "projects":
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
case "orchestration":
return <OrchestrationStep serverId={serverId} />;
return <OrchestrationStep setup={orchestration} />;
case "phone":
return <PhoneStep serverId={serverId} onFinish={onFinish} />;
return <PhoneStep serverId={serverId} />;
}
}
@@ -1,86 +1,25 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CalendarClock, GitBranch, SquareTerminal, Users } from "lucide-react-native";
import { PaseoToolsCard } from "@/screens/settings/paseo-tools-card";
import type { Theme } from "@/styles/theme";
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";
const ThemedUsers = withUnistyles(Users);
type ThemedIcon = typeof ThemedUsers;
const CAPABILITIES: ReadonlyArray<{
key: "agents" | "workspaces" | "schedules" | "terminals";
icon: ThemedIcon;
}> = [
{ key: "agents", icon: ThemedUsers },
{ key: "workspaces", icon: withUnistyles(GitBranch) },
{ key: "schedules", icon: withUnistyles(CalendarClock) },
{ key: "terminals", icon: withUnistyles(SquareTerminal) },
];
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function OrchestrationStep({ serverId }: { serverId: string }) {
const { t } = useTranslation();
/** 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 (
<View style={styles.container}>
<View style={styles.capabilities}>
{CAPABILITIES.map(({ key, icon }) => (
<Capability
key={key}
icon={icon}
title={t(`onboarding.setup.steps.orchestration.capabilities.${key}.title`)}
description={t(`onboarding.setup.steps.orchestration.capabilities.${key}.description`)}
/>
))}
</View>
<PaseoToolsCard serverId={serverId} />
</View>
<SettingsCard>
<PaseoToolsRow value={choices.tools} onValueChange={setTools} disabled={isApplying} />
{offered.skills ? (
<AgentSkillsRow skills={setup.skills} value={choices.skills} onValueChange={setSkills} />
) : null}
{offered.cli ? (
<CommandLineRow value={choices.cli} onValueChange={setCli} disabled={isApplying} />
) : null}
</SettingsCard>
);
}
function Capability({
icon: Icon,
title,
description,
}: {
icon: ThemedIcon;
title: string;
description: string;
}) {
return (
<View style={styles.capability}>
<Icon size={16} uniProps={mutedMapping} />
<View style={styles.capabilityText}>
<Text style={styles.capabilityTitle}>{title}</Text>
<Text style={styles.capabilityDescription}>{description}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[6],
},
capabilities: {
gap: theme.spacing[4],
},
capability: {
flexDirection: "row",
gap: theme.spacing[3],
},
capabilityText: {
flex: 1,
gap: theme.spacing[1],
},
capabilityTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
capabilityDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
},
}));
@@ -1,34 +1,94 @@
import { useCallback } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AppleIcon } from "@/components/icons/apple-icon";
import { GooglePlayIcon } from "@/components/icons/google-play-icon";
import { SettingsCard, SettingsDetailRow, SettingsSection } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { StatusBadge } from "@/components/ui/status-badge";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import {
CONNECTIVITY_DOCS_URL,
TAILSCALE_DOCS_URL,
} from "@/desktop/components/pair-device-section";
import { ICON_SIZE } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
const APP_STORE_URL = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=sh.paseo";
export function PhoneStep({ serverId, onFinish }: { serverId: string; onFinish: () => void }) {
// Button calls a one-argument function with its icon colour, so the Apple mark follows the theme.
const renderAppleIcon = (color: string) => <AppleIcon color={color} size={ICON_SIZE.sm} />;
const renderGooglePlayIcon = () => <GooglePlayIcon size={ICON_SIZE.sm} />;
function openUrl(url: string) {
void openExternalUrl(url).catch(console.error);
}
/** Get the app, then connect it: the relay pairs in a modal, Tailscale is a docs page away. */
export function PhoneStep({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const openAppStore = useCallback(() => {
void openExternalUrl(APP_STORE_URL).catch(console.error);
}, []);
const openPlayStore = useCallback(() => {
void openExternalUrl(PLAY_STORE_URL).catch(console.error);
}, []);
const [isPairing, setIsPairing] = useState(false);
const openAppStore = useCallback(() => openUrl(APP_STORE_URL), []);
const openPlayStore = useCallback(() => openUrl(PLAY_STORE_URL), []);
const openTailscaleDocs = useCallback(() => openUrl(TAILSCALE_DOCS_URL), []);
const openPairing = useCallback(() => setIsPairing(true), []);
const closePairing = useCallback(() => setIsPairing(false), []);
const recommendedBadge = useMemo(
() => (
<StatusBadge
size="xs"
variant="success"
label={t("onboarding.setup.steps.phone.recommended")}
/>
),
[t],
);
const docsLink = useMemo(
() => ({
href: CONNECTIVITY_DOCS_URL,
label: t("onboarding.setup.steps.phone.connectDocs"),
accessibilityLabel: t("onboarding.setup.steps.phone.openConnectDocs"),
}),
[t],
);
return (
<View style={styles.container}>
<View style={styles.storeLinks}>
<Button variant="outline" onPress={openAppStore}>
<Button variant="outline" leftIcon={renderAppleIcon} onPress={openAppStore}>
{t("onboarding.setup.steps.phone.appStore")}
</Button>
<Button variant="outline" onPress={openPlayStore}>
<Button variant="outline" leftIcon={renderGooglePlayIcon} onPress={openPlayStore}>
{t("onboarding.setup.steps.phone.googlePlay")}
</Button>
</View>
<PairDeviceSection serverId={serverId} onClose={onFinish} />
<SettingsSection title={t("onboarding.setup.steps.phone.connectTitle")} link={docsLink}>
<SettingsCard>
<SettingsDetailRow
label={t("onboarding.setup.steps.phone.relay.title")}
labelAccessory={recommendedBadge}
hint={t("onboarding.setup.steps.phone.relay.hint")}
onPress={openPairing}
testID="onboarding-phone-relay"
/>
<SettingsDetailRow
label={t("onboarding.setup.steps.phone.tailscale.title")}
hint={t("onboarding.setup.steps.phone.tailscale.hint")}
summary={t("onboarding.setup.steps.phone.connectDocs")}
onPress={openTailscaleDocs}
external
testID="onboarding-phone-tailscale"
/>
</SettingsCard>
</SettingsSection>
<PairDeviceModal
serverId={serverId}
visible={isPairing}
onClose={closePairing}
testID="onboarding-pair-device-card"
/>
</View>
);
}
@@ -5,10 +5,14 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, FolderPlus } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { ProjectSuggestionList, type ProjectSuggestionsController } from "@/project-suggestions";
import {
FolderProjectIcon,
ProjectSuggestionList,
type ProjectSuggestionsController,
} from "@/project-suggestions";
import { useProjectRootPaths } from "@/stores/session-store-hooks";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedCheck = withUnistyles(Check);
const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[400] });
@@ -33,7 +37,11 @@ export function ProjectsStep({
return (
<View style={styles.container}>
<ProjectSuggestionList suggestions={suggestions} />
<View style={styles.actions}>
<Button variant="outline" leftIcon={FolderPlus} onPress={handleAddProject}>
{t("onboarding.setup.steps.projects.addProject")}
</Button>
</View>
{projectRootPaths.length > 0 ? (
<View style={settingsStyles.card}>
{projectRootPaths.map((rootPath, index) => (
@@ -42,10 +50,17 @@ export function ProjectsStep({
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]}
>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{basename(rootPath)}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
<View style={styles.titleLine}>
<FolderProjectIcon
serverId={serverId}
rootPath={rootPath}
name={basename(rootPath)}
/>
<Text style={[settingsStyles.rowTitle, styles.title]} numberOfLines={1}>
{basename(rootPath)}
</Text>
</View>
<Text style={[settingsStyles.rowHint, styles.underTitle]} numberOfLines={1}>
{rootPath}
</Text>
</View>
@@ -57,13 +72,7 @@ export function ProjectsStep({
))}
</View>
) : null}
<View style={styles.actions}>
<Button variant="outline" leftIcon={FolderPlus} onPress={handleAddProject}>
{suggestions.list.status === "ready" && suggestions.list.rows.length > 0
? t("onboarding.setup.steps.projects.addAnotherProject")
: t("onboarding.setup.steps.projects.addProject")}
</Button>
</View>
<ProjectSuggestionList serverId={serverId} suggestions={suggestions} />
</View>
);
}
@@ -77,6 +86,17 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
titleLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
title: {
flexShrink: 1,
},
underTitle: {
marginLeft: ICON_SIZE.md + theme.spacing[2],
},
addedBadge: {
flexDirection: "row",
alignItems: "center",
+2 -4
View File
@@ -145,8 +145,6 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
const controlSize = smallIconButtonChromeFrameSize(isCompact);
const glyphSize = paneContentToolbarIconSize(isCompact);
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
// Keep the replace actions on the same row height as the icon controls beside them.
const actionSize = isCompact ? "sm" : "xs";
const input = useRef<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(null);
useLayoutEffect(() => {
@@ -293,7 +291,7 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
/>
<Button
variant="ghost"
size={actionSize}
size="sm"
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
@@ -302,7 +300,7 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
</Button>
<Button
variant="ghost"
size={actionSize}
size="sm"
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
+1 -2
View File
@@ -217,7 +217,7 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: ()
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.statusText}>{message}</Text>
<View style={styles.errorAction}>
<Button size="sm" variant="secondary" onPress={onRetry} testID="agent-load-error-retry">
<Button variant="secondary" onPress={onRetry} testID="agent-load-error-retry">
{t("common.actions.retry")}
</Button>
</View>
@@ -1308,7 +1308,6 @@ function TimelineSyncErrorCallout({
{t("agentPanel.states.timelineSyncFailed")}
</Text>
<Button
size="sm"
variant="secondary"
onPress={onRetry}
disabled={isRetrying}
+1 -1
View File
@@ -155,7 +155,7 @@ function BlockedSetupNotice({
testID="workspace-setup-blocked"
>
{supportsSetupRun ? (
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup} size="sm">
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup}>
{t("workspace.setup.blocked.run")}
</Button>
) : null}
+1 -2
View File
@@ -85,7 +85,7 @@ function SettingsContent({
(error: string) => (
<View>
<Text style={styles.message}>{error}</Text>
<Button variant="outline" size="sm" onPress={retry}>
<Button variant="outline" onPress={retry}>
{t("common.actions.retry")}
</Button>
</View>
@@ -141,7 +141,6 @@ export function PluginSettingsContent({
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
@@ -2,6 +2,7 @@
* Project suggestions: folders the user already ran agents in, offered as projects to add.
* Requires the host's `projectSuggestions` feature; on older hosts the list renders nothing.
*/
export { FolderProjectIcon } from "./internal/folder-project-icon";
export { ProjectSuggestionList } from "./internal/project-suggestions";
export {
useProjectSuggestions,
@@ -0,0 +1,39 @@
import { StyleSheet } from "react-native-unistyles";
import { ProjectIconView } from "@/components/project-icon-view";
import { projectIconDataUri, useProjectIcon } from "@/projects/icons";
import { ICON_SIZE } from "@/styles/theme";
// The initial sits in a 16px square, below the smallest font-size token.
const INITIAL_FONT_SIZE = 10;
/**
* The icon of a folder that may not be a project yet: the icon the host discovers in it, or a
* colored initial. Keyed on the folder, so a suggestion keeps its color once it is added here.
*/
export function FolderProjectIcon({
serverId,
rootPath,
name,
}: {
serverId: string;
rootPath: string;
name: string;
}) {
const { icon } = useProjectIcon({ serverId, cwd: rootPath });
return (
<ProjectIconView
iconDataUri={projectIconDataUri(icon)}
initial={name.trim().charAt(0).toUpperCase() || "?"}
projectViewKey={rootPath}
size={ICON_SIZE.md}
textStyle={styles.initial}
/>
);
}
const styles = StyleSheet.create((theme) => ({
initial: {
fontSize: INITIAL_FONT_SIZE,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -1,267 +1,207 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PressHighlight } from "@/components/ui/press-highlight";
import { type Animated, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { projectIconRadius } from "@/components/project-icon-view";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ProjectSuggestion, ProviderSuggestions } from "./suggestions";
import { ICON_SIZE } from "@/styles/theme";
import { FolderProjectIcon } from "./folder-project-icon";
import type { ProjectSuggestion } from "./suggestions";
import type {
ProjectSuggestionsController,
SuggestionRow as SuggestionRowModel,
} from "./use-project-suggestions";
const ThemedCheck = withUnistyles(Check);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const SKELETON_ROWS = [0, 1, 2];
const INITIAL_SKELETON_ROWS = [0, 1, 2];
const TRAILING_SKELETON_ROWS = [0];
/**
* Folders the user already ran agents in, ready to add as projects. Rows appear as each provider
* answers and a failed provider can be retried. Adding the checked rows is the owner's primary
* action, through the same controller. Renders nothing on a host that cannot suggest projects.
* answers, with skeleton rows while any is still reading; a provider that cannot read its history
* adds nothing. Adding the checked rows is the owner's primary action, through the same
* controller. Renders nothing on a host that cannot suggest projects.
*/
export function ProjectSuggestionList({
serverId,
suggestions,
}: {
serverId: string;
suggestions: ProjectSuggestionsController;
}) {
const { t } = useTranslation();
const { list } = suggestions;
if (list.status === "unsupported") return null;
if (list.status === "loading") return <SuggestionsSkeleton />;
if (list.rows.length === 0 && !list.isReading) {
return (
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.empty")}
</Text>
);
}
return (
<View style={styles.container}>
{list.rows.length > 0 ? (
<View style={settingsStyles.card}>
{list.rows.map((row, index) => (
<SuggestionRow
key={row.suggestion.rootPath}
row={row}
first={index === 0}
providerLabels={list.providerLabels}
disabled={list.isAdding}
onToggle={list.toggle}
/>
))}
</View>
) : null}
{list.rows.length === 0 && list.reading.length === 0 ? (
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.empty")}
</Text>
) : null}
{list.reading.length > 0 ? <StillReading providers={list.reading} /> : null}
{list.failed.map((provider) => (
<ProviderFailure key={provider.provider} provider={provider} onRetry={list.retry} />
<View
style={settingsStyles.card}
accessibilityLabel={
list.isReading ? t("onboarding.setup.steps.projects.suggestions.loading") : undefined
}
>
{list.rows.map((row, index) => (
<SuggestionRow
key={row.suggestion.rootPath}
serverId={serverId}
row={row}
first={index === 0}
disabled={list.isAdding}
onToggle={list.toggle}
/>
))}
{list.isReading ? <SkeletonRows afterRows={list.rows.length > 0} /> : null}
</View>
);
}
function SuggestionRow({
serverId,
row,
first,
providerLabels,
disabled,
onToggle,
}: {
serverId: string;
row: SuggestionRowModel;
first: boolean;
providerLabels: ReadonlyMap<string, string>;
disabled: boolean;
onToggle: (suggestion: ProjectSuggestion) => void;
}) {
const { t } = useTranslation();
const { suggestion, checked, addFailure } = row;
const handlePress = useCallback(() => onToggle(suggestion), [onToggle, suggestion]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const handleValueChange = useCallback(() => onToggle(suggestion), [onToggle, suggestion]);
const lastActivity = useTimeAgo(useMemo(() => new Date(suggestion.lastActivityAt), [suggestion]));
const providers = suggestion.providerIds.map((id) => providerLabels.get(id) ?? id).join(" · ");
const rowStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
styles.row,
!first && settingsStyles.rowBorder,
hovered && styles.rowHovered,
],
[first],
);
return (
<PressHighlight
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={suggestion.displayName}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
{checked ? (
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
) : null}
</View>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{suggestion.displayName}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{suggestion.rootPath}
</Text>
{addFailure === undefined ? null : (
<Text style={settingsStyles.rowError}>
{addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")}
</Text>
)}
</View>
<View style={styles.meta}>
<Text style={styles.metaText} numberOfLines={1}>
{providers}
</Text>
<Text style={styles.metaText} numberOfLines={1}>
{t("onboarding.setup.steps.projects.suggestions.sessions", {
count: suggestion.sessionCount,
})}
{" · "}
{lastActivity}
</Text>
</View>
</PressHighlight>
);
}
function StillReading({ providers }: { providers: readonly ProviderSuggestions[] }) {
const { t } = useTranslation();
return (
<View style={styles.statusRow}>
<ThemedLoadingSpinner uniProps={mutedMapping} />
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.stillReading", {
providers: providers.map((provider) => provider.label).join(", "),
})}
</Text>
</View>
);
}
function ProviderFailure({
provider,
onRetry,
}: {
provider: ProviderSuggestions;
onRetry: (provider: string) => void;
}) {
const { t } = useTranslation();
const handleRetry = useCallback(() => onRetry(provider.provider), [onRetry, provider.provider]);
return (
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.readFailed", { provider: provider.label })}
</Text>
<Button variant="ghost" size="sm" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
function SuggestionsSkeleton() {
const { t } = useTranslation();
const pulse = useSkeletonPulse();
return (
<View
style={settingsStyles.card}
accessibilityLabel={t("onboarding.setup.steps.projects.suggestions.loading")}
testID="project-suggestions-skeleton"
style={[settingsStyles.row, !first && settingsStyles.rowBorder]}
testID="project-suggestion"
>
{SKELETON_ROWS.map((row) => (
<View
key={row}
style={[settingsStyles.row, styles.row, row > 0 && settingsStyles.rowBorder]}
>
<SkeletonPulse pulse={pulse} style={styles.skeletonCheckbox} />
<View style={styles.skeletonText}>
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
<SkeletonPulse pulse={pulse} style={styles.skeletonPath} />
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<FolderProjectIcon
serverId={serverId}
rootPath={suggestion.rootPath}
name={suggestion.displayName}
/>
<Text style={[settingsStyles.rowTitle, styles.title]} numberOfLines={1}>
{suggestion.displayName}
</Text>
</View>
))}
<View style={styles.underTitle}>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{suggestion.rootPath}
</Text>
<Text style={[settingsStyles.rowHint, styles.lastUsed]} numberOfLines={1}>
{t("onboarding.setup.steps.projects.suggestions.lastUsed", { time: lastActivity })}
</Text>
{addFailure === undefined ? null : (
<Text style={settingsStyles.rowError}>
{addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")}
</Text>
)}
</View>
</View>
<Switch
value={checked}
onValueChange={handleValueChange}
disabled={disabled}
accessibilityLabel={suggestion.displayName}
/>
</View>
);
}
const CHECKBOX_SIZE = 16;
/** Placeholder rows in the shape of a suggestion: icon and name, path, and the switch. */
function SkeletonRows({ afterRows }: { afterRows: boolean }) {
const pulse = useSkeletonPulse();
const rows = afterRows ? TRAILING_SKELETON_ROWS : INITIAL_SKELETON_ROWS;
return rows.map((row) => <SkeletonRow key={row} pulse={pulse} bordered={afterRows || row > 0} />);
}
function SkeletonRow({ pulse, bordered }: { pulse: Animated.Value; bordered: boolean }) {
return (
<View
style={[settingsStyles.row, bordered && settingsStyles.rowBorder]}
testID="project-suggestions-skeleton"
>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<SkeletonPulse pulse={pulse} style={styles.skeletonIcon} />
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
</View>
<View style={[styles.underTitle, styles.skeletonHints]}>
<SkeletonPulse pulse={pulse} style={styles.skeletonPath} />
<SkeletonPulse pulse={pulse} style={styles.skeletonLastUsed} />
</View>
</View>
<SkeletonPulse pulse={pulse} style={styles.skeletonSwitch} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
row: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
checkbox: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
checkboxChecked: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
meta: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
metaText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
statusRow: {
titleLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
title: {
flexShrink: 1,
},
// Path and last use line up under the name, past the icon.
underTitle: {
marginLeft: ICON_SIZE.md + theme.spacing[2],
},
lastUsed: {
color: theme.colors.foregroundExtraMuted,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
skeletonCheckbox: {
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
borderRadius: theme.borderRadius.sm,
skeletonSwitch: {
width: 34,
height: 20,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
skeletonText: {
flex: 1,
gap: theme.spacing[2],
skeletonIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
borderRadius: projectIconRadius(ICON_SIZE.md),
backgroundColor: theme.colors.surface2,
},
skeletonTitle: {
width: 140,
width: 120,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
skeletonHints: {
marginTop: theme.spacing[2],
gap: theme.spacing[2],
},
skeletonPath: {
width: 220,
maxWidth: "100%",
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
skeletonLastUsed: {
width: 90,
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
addSuggestedProjects,
isAwaitingFirstAnswer,
isStillReading,
isSuggestionChecked,
mergeProviderSuggestions,
type ProjectSuggestion,
@@ -30,8 +30,7 @@ describe("mergeProviderSuggestions", () => {
[
{
provider: "claude",
label: "Claude",
status: "ready",
status: "answered",
suggestions: [
suggestion("/dev/app", { sessionCount: 2, lastActivityAt: "2026-10-29T00:00:00.000Z" }),
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
@@ -39,9 +38,7 @@ describe("mergeProviderSuggestions", () => {
},
{
provider: "codex",
label: "Codex",
status: "failed",
message: "Timed out",
status: "answered",
suggestions: [
suggestion("/dev/app", {
sessionCount: 3,
@@ -50,7 +47,7 @@ describe("mergeProviderSuggestions", () => {
}),
],
},
{ provider: "opencode", label: "OpenCode", status: "loading" },
{ provider: "opencode", status: "loading" },
],
new Set(),
);
@@ -70,8 +67,7 @@ describe("mergeProviderSuggestions", () => {
[
{
provider: "claude",
label: "Claude",
status: "ready",
status: "answered",
suggestions: [suggestion("/dev/app"), suggestion("/dev/docs")],
},
],
@@ -131,19 +127,17 @@ describe("addSuggestedProjects", () => {
});
});
describe("isAwaitingFirstAnswer", () => {
it("stops waiting once any provider answers, so a failure shows while others still read", () => {
const reading: ProviderSuggestions = { provider: "codex", label: "Codex", status: "loading" };
const failed: ProviderSuggestions = {
describe("isStillReading", () => {
it("keeps reading while any provider has not answered", () => {
const reading: ProviderSuggestions = { provider: "codex", status: "loading" };
const answered: ProviderSuggestions = {
provider: "opencode",
label: "OpenCode",
status: "failed",
message: "Timed out",
status: "answered",
suggestions: [],
};
expect(isAwaitingFirstAnswer([reading, { ...reading, provider: "pi" }])).toBe(true);
expect(isAwaitingFirstAnswer([reading, failed])).toBe(false);
expect(isAwaitingFirstAnswer([])).toBe(false);
expect(isStillReading([answered, reading])).toBe(true);
expect(isStillReading([answered, { ...answered, provider: "pi" }])).toBe(false);
expect(isStillReading([])).toBe(false);
});
});
@@ -5,17 +5,13 @@ export type { ProjectSuggestion };
const PRESELECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const PRESELECT_MIN_SESSIONS = 3;
/** One provider's answer. Each provider is asked separately so a slow one never holds up the rest. */
/**
* One provider's answer. Each provider is asked separately so a slow one never holds up the rest.
* A provider that cannot read its history answers with whatever it found, often nothing.
*/
export type ProviderSuggestions =
| { provider: string; label: string; status: "loading" }
| { provider: string; label: string; status: "ready"; suggestions: ProjectSuggestion[] }
| {
provider: string;
label: string;
status: "failed";
message: string;
suggestions: ProjectSuggestion[];
};
| { provider: string; status: "loading" }
| { provider: string; status: "answered"; suggestions: ProjectSuggestion[] };
/** Combines the providers that answered into one row per folder, most recent first. */
export function mergeProviderSuggestions(
@@ -48,12 +44,9 @@ function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): Project
};
}
/**
* True until the first provider answers. After that the list shows what it has, including a failed
* provider's Retry, while the slower providers keep reading.
*/
export function isAwaitingFirstAnswer(providers: readonly ProviderSuggestions[]): boolean {
return providers.length > 0 && providers.every((provider) => provider.status === "loading");
/** True while any provider is still reading its history; the rows so far show meanwhile. */
export function isStillReading(providers: readonly ProviderSuggestions[]): boolean {
return providers.some((provider) => provider.status === "loading");
}
/** Folders the user worked in often and recently start checked. */
@@ -4,11 +4,10 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { useProjectRootPaths } from "@/stores/session-store-hooks";
import {
addSuggestedProjects,
isAwaitingFirstAnswer,
isStillReading,
isSuggestionChecked,
mergeProviderSuggestions,
type ProjectSuggestion,
type ProviderSuggestions,
} from "./suggestions";
import { useProviderSuggestions } from "./use-provider-suggestions";
@@ -21,18 +20,14 @@ export interface SuggestionRow {
export type SuggestionList =
| { status: "unsupported" }
/** Nothing to show yet: the provider list or the first provider's answer is pending. */
| { status: "loading" }
| {
status: "ready";
rows: SuggestionRow[];
providerLabels: ReadonlyMap<string, string>;
reading: ProviderSuggestions[];
failed: ProviderSuggestions[];
/** True while any provider, or the provider list itself, is still pending. */
isReading: boolean;
/** True while checked rows are being added; the selection is frozen until it settles. */
isAdding: boolean;
toggle: (suggestion: ProjectSuggestion) => void;
retry: (provider: string) => void;
};
export interface ProjectSuggestionsController {
@@ -88,8 +83,7 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr
}, [mutateAsync, checkedRootPaths]);
const list = useMemo((): SuggestionList => {
if (state.status !== "ready") return state;
if (isAwaitingFirstAnswer(state.providers)) return { status: "loading" };
if (state.status === "unsupported") return state;
return {
status: "ready",
rows: suggestions.map((suggestion) => ({
@@ -97,14 +91,9 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr
checked: checkedRootPaths.includes(suggestion.rootPath),
addFailure: addFailures.get(suggestion.rootPath),
})),
providerLabels: new Map(
state.providers.map((provider) => [provider.provider, provider.label]),
),
reading: state.providers.filter((provider) => provider.status === "loading"),
failed: state.providers.filter((provider) => provider.status === "failed"),
isReading: state.status === "loading" || isStillReading(state.providers),
isAdding: isPending,
toggle,
retry: state.retry,
};
}, [state, suggestions, checkedRootPaths, addFailures, isPending, toggle]);
@@ -1,5 +1,4 @@
import { useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useFetchQueries } from "@/data/query";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -16,7 +15,7 @@ export type ProjectSuggestionsState =
| { status: "unsupported" }
/** The provider list itself has not arrived yet. */
| { status: "loading" }
| { status: "ready"; providers: ProviderSuggestions[]; retry: (provider: string) => void };
| { status: "ready"; providers: ProviderSuggestions[] };
function suggestionsQueryKey(serverId: string, provider: string) {
return ["project-suggestions", serverId, provider] as const;
@@ -27,41 +26,30 @@ function suggestionsQueryKey(serverId: string, provider: string) {
* calling this early (when setup opens) means the Projects step usually finds them answered.
*/
export function useProviderSuggestions(serverId: string): ProjectSuggestionsState {
const queryClient = useQueryClient();
const supported = useHostFeature(serverId, "projectSuggestions");
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries } = useProvidersSnapshot(serverId, { enabled: supported });
const providers = useMemo(
() =>
entries
?.filter((entry) => entry.enabled !== false)
.map((entry) => ({ id: entry.provider, label: entry.label ?? entry.provider })),
() => entries?.filter((entry) => entry.enabled !== false).map((entry) => entry.provider),
[entries],
);
const results = useFetchQueries<SuggestionsResponse>(
(supported && providers ? providers : []).map((provider) => ({
queryKey: suggestionsQueryKey(serverId, provider.id),
queryKey: suggestionsQueryKey(serverId, provider),
dataShape: "value",
enabled: Boolean(client) && isConnected,
// A provider that cannot answer stays failed until the user presses Retry.
// Suggestions are a convenience: a provider that cannot answer simply adds nothing.
retry: false,
staleTimeMs: SUGGESTIONS_STALE_TIME_MS,
queryFn: () => {
if (!client) throw new Error("Host disconnected");
return client.listProjectSuggestions({ providers: [provider.id] });
return client.listProjectSuggestions({ providers: [provider] });
},
})),
);
const retry = useCallback(
(provider: string) => {
void queryClient.refetchQueries({ queryKey: suggestionsQueryKey(serverId, provider) });
},
[queryClient, serverId],
);
return useMemo((): ProjectSuggestionsState => {
if (!supported) return { status: "unsupported" };
if (!providers) return { status: "loading" };
@@ -70,26 +58,19 @@ export function useProviderSuggestions(serverId: string): ProjectSuggestionsStat
providers: providers.map((provider, index) =>
toProviderSuggestions(provider, results[index]),
),
retry,
};
}, [supported, providers, results, retry]);
}, [supported, providers, results]);
}
function toProviderSuggestions(
provider: { id: string; label: string },
provider: string,
result: { data?: SuggestionsResponse; error: Error | null; isFetching: boolean } | undefined,
): ProviderSuggestions {
const base = { provider: provider.id, label: provider.label };
const data = result?.data;
const providerError = data?.providerErrors.find((error) => error.provider === provider.id);
const failure = result?.error?.message ?? providerError?.message ?? null;
// A retry shows as reading again; a background refresh of good results keeps the rows.
if (!result || (result.isFetching && (failure !== null || !data))) {
return { ...base, status: "loading" };
if (result?.data) {
return { provider, status: "answered", suggestions: result.data.suggestions };
}
if (failure !== null) {
return { ...base, status: "failed", message: failure, suggestions: data?.suggestions ?? [] };
if (result?.error && !result.isFetching) {
return { provider, status: "answered", suggestions: [] };
}
if (!data) return { ...base, status: "loading" };
return { ...base, status: "ready", suggestions: data.suggestions };
return { provider, status: "loading" };
}
+2 -2
View File
@@ -30,7 +30,7 @@ function legacyIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, "legacy", cwd] as const;
}
function iconDataUri(icon: ProjectIcon | null): string | null {
export function projectIconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) return null;
return `data:${icon.mimeType};base64,${icon.data}`;
}
@@ -98,7 +98,7 @@ export function useProjectIcons(input: {
() => getHostRuntimeStore().getClient(request.serverId),
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
),
select: iconDataUri,
select: projectIconDataUri,
};
}),
});
@@ -53,7 +53,6 @@ function OpenReviewCommentSheet({
() => (
<View style={styles.actions}>
<Button
size="md"
variant="secondary"
accessibilityLabel={t("review.comment.cancelAccessibility")}
onPress={onCancelEditor}
@@ -61,7 +60,6 @@ function OpenReviewCommentSheet({
{t("review.comment.cancel")}
</Button>
<Button
size="md"
variant="default"
accessibilityLabel={t("review.comment.saveAccessibility")}
disabled={!canSave}
@@ -85,7 +83,6 @@ function OpenReviewCommentSheet({
>
<FormTextInput
key={editor.target.key + (editor.commentId ?? "new")}
size="md"
multiline
accessibilityLabel={t("review.comment.label")}
placeholder={t("review.comment.placeholder")}
+2 -3
View File
@@ -7,7 +7,7 @@ import { StyleSheet } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useMenuContext } from "@/components/ui/menu";
import { Button } from "@/components/ui/button";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { controlHeightForLayout } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { FloatingActionButton } from "@/components/ui/floating-action-button";
@@ -64,7 +64,7 @@ function OpenReviewFeedbackAction({
bottom:
bottom +
bottomOffset +
(isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]),
(isCompact ? 88 : controlHeightForLayout("md", isCompact) + SPACING[4] + SPACING[2]),
},
],
[bottom, bottomOffset, isCompact],
@@ -145,7 +145,6 @@ function FeedbackTrigger({
<Button
ref={triggerRef}
variant="default"
size="sm"
leftIcon={Send}
accessibilityLabel={label}
disabled={disabled}
@@ -14,7 +14,7 @@ import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { controlHeightForLayout } from "@/components/ui/control-geometry";
import { SPACING } from "@/styles/theme";
import { i18n } from "@/i18n/i18next";
import {
@@ -114,7 +114,9 @@ export function useReviewFeedback(input: {
state,
clearance:
state.commentCount > 0
? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44
? (isCompact
? FLOATING_ACTION_BUTTON_CLEARANCE
: controlHeightForLayout("md", isCompact) + SPACING[4]) + 44
: 0,
};
}

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